diff --git a/CHANGELOG.md b/CHANGELOG.md index a421efd..e7c5fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## beta 2.0.4.1023 - 2026-10-23 16:56:33 + +- 许愿池和 21 点小版本更新完成:`/抽奖` 已并入许愿池,21 点新增群内胜率统计并调整下注上限。 +- 旧 `/抽奖` 返倍玩法合并进许愿池,保留 `/抽奖 金额` 与 `/draw 金额` 入口,使用方式与 `/许愿池 金额` 一致。 +- 许愿池每 1000 摩拉结算 1 愿,单次支持 1000~10000 摩拉,可能获得摩拉回响、扫码概率券、Taffy 发话额度或原石。 +- 许愿池新增公共池水、8 愿无道具保底和池底大愿;游戏记录和 Taffy 摩拉统计会按“许愿池”归档。 +- 21 点单人下注范围调整为 1~2000,多人牌桌下注范围调整为 1~2000;`/21点 胜率` 会展示当前群玩家和 Taffy 的胜/负/平、胜率与净收支。 + +## beta 2.0.3.1023 - 2026-10-23 16:09:38 + +- 冷门知识问答小版本更新完成:题库外置为 500 题,并会优先抽取更少出现过的题。 +- 知识问答基础题库迁移到 `jwebot_kernel/data/trivia_questions.json`,运行时启动校验并展开为 500 道选项题。 +- 题库补充更多机制、数值、版本与活动细节题,覆盖反恐精英、顶点英雄、守望先锋、英雄联盟、少女乐团派对、韩流、原神、缪斯学园偶像和我的世界。 +- 出题历史新增 `ask_counts`,每次选题优先从当前主题内出现次数最低的基础题中抽取,并继续避开最近题目。 +- 问答题头新增 `出现次数:N` 标志,复看当前题时也能看到这题已经出现过几次。 + ## beta 2.0.2.1021 - 2026-10-21 18:11:35 - 上下文追问小版本更新完成:Taffy 现在会优先相信本地游戏状态,并能把短句追评接到上一条 21 点或主动发言。 diff --git a/README.md b/README.md index a329caa..f0519ef 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,13 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 并通过带视觉确认的 GUI 发送链路回复。 -当前版本:`beta 2.0.2.1021`,更新时间:2026-10-21 18:11:35。 +当前版本:`beta 2.0.4.1023`,更新时间:2026-10-23 16:56:33。 1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、 Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major 自走棋、生产部署与回滚门禁。CS Major 自走棋的选手、教练、叙事和规则 -已经外置为包内 JSON,虚拟摩拉支持转账、贷款、还款和群内排行榜。 +已经外置为包内 JSON,知识问答题库也以 500 题 JSON 数据随 Kernel +发布,虚拟摩拉支持转账、贷款、还款和群内排行榜。 完整命令见[命令目录](docs/command-catalog.md)。历史版本记录见 [CHANGELOG](CHANGELOG.md)。 @@ -82,15 +83,15 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh /功能列表 /上下文 [100|200|300](/context) /人物列表 [页码](/people);/人物 [名称或别名](/person) -/摩拉(兼容 /金币、/wallet);/抽奖 [金额](/draw) +/摩拉(兼容 /金币、/wallet);/许愿池 金额(兼 /许愿、/抽奖、/draw) /大富翁榜(/摩拉排行,兼容 /金币排行) /转账 对方 金额(/transfer name amount) /贷款 [金额](/loan amount);/还款 [金额|全部](/repay) /彩票 开始|报名|当前状态|开奖|取消(/lottery) -/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 +/21点 开始 [1~2000];开局后 /21点 要牌|停牌|状态|结束;/21点 胜率 /21点 多人 启动;/21点 加入;/21点 多人 开始;/21点 放200 /摩拉(兼 /原石);/原石祈愿;/原石榜;/扫码加权 人名(24小时+5);/Taffy发话 内容 -/知识问答 [主题];支持主题:反恐精英、顶点英雄、守望先锋、英雄联盟、少女乐团派对、韩流、原神、缪斯学园偶像、我的世界;/回答 A +/知识问答 [主题];500 题冷门题库,优先抽取少出现过的题并显示出现次数;支持主题:反恐精英、顶点英雄、守望先锋、英雄联盟、少女乐团派对、韩流、原神、缪斯学园偶像、我的世界;/回答 A /游戏记录 [页码](/gamehistory) /游戏记录 [页码](/gamehistory) /游戏帮助(/games) @@ -271,16 +272,17 @@ visually confirmed GUI bridge. Current version: ```text -beta 2.0.2.1021 +beta 2.0.4.1023 ``` -Updated: 2026-10-21 18:11:35. +Updated: 2026-10-23 16:56:33. The 1.0 Beta baseline consolidates the stable local command set, person and context indexes, Steam presence/playtime queries, virtual coin games, multiplayer lottery, blackjack, CS Major autochess, and production deployment -gates. CS Major autochess game content is now packaged as JSON data, and -virtual coins support transfer, loans, repayment, and group leaderboards. +gates. CS Major autochess game content and the 500-question trivia bank are +now packaged as JSON data, and virtual coins support transfer, loans, +repayment, and group leaderboards. ## Runtime Flow diff --git a/VERSION b/VERSION index 2f297d4..ef7f4ed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -beta 2.0.2.1021 +beta 2.0.4.1023 diff --git a/deploy/scripts/install-wechat-health-watch.sh b/deploy/scripts/install-wechat-health-watch.sh index 3293f73..1c79fe4 100755 --- a/deploy/scripts/install-wechat-health-watch.sh +++ b/deploy/scripts/install-wechat-health-watch.sh @@ -7,6 +7,7 @@ user_unit_source_dir="$source_dir/deploy/systemd/user" config_source_dir="$source_dir/packages/jwebot-config/src/jwebot_config" sdk_source_dir="$source_dir/packages/jwebot-sdk/src/jwebot_sdk" kernel_source_dir="$source_dir/packages/jwebot-kernel/src/jwebot_kernel" +kernel_data_source_dir="$kernel_source_dir/data" official_source_dir="$source_dir/packages/jwebot-official-extensions/src/jwebot_official_extensions" official_adapter_source_dir="$official_source_dir/adapters" official_autochess_data_source_dir="$official_source_dir/data/cs_autochess" @@ -358,6 +359,7 @@ install -d -m 0700 \ "$target_dir/jwebot_config" \ "$target_dir/jwebot_sdk" \ "$target_dir/jwebot_kernel" \ + "$target_dir/jwebot_kernel/data" \ "$target_dir/jwebot_official_extensions" \ "$target_dir/jwebot_official_extensions/adapters" \ "$target_dir/jwebot_official_extensions/data" \ @@ -371,6 +373,10 @@ done for path in "$kernel_source_dir"/*.py; do install -m 0600 "$path" "$target_dir/jwebot_kernel/$(basename "$path")" done +for path in "$kernel_data_source_dir"/*.json; do + [ -e "$path" ] || continue + install -m 0600 "$path" "$target_dir/jwebot_kernel/data/$(basename "$path")" +done for path in "$official_source_dir"/*.py; do install -m 0600 "$path" "$target_dir/jwebot_official_extensions/$(basename "$path")" done diff --git a/docs/command-catalog.md b/docs/command-catalog.md index 78bcbdc..29850c0 100644 --- a/docs/command-catalog.md +++ b/docs/command-catalog.md @@ -53,21 +53,22 @@ descriptor inventory; plugin code delegates to reviewed service profiles. | `/Taffy发话 定时 HH:MM 内容` | - | Schedule a Taffy speech through the guarded clock delivery queue. | | `/知识问答 [主题]` | - | Start a five-minute cold-knowledge quiz immediately, optionally limited to one supported topic. Supported topics: 反恐精英, 顶点英雄, 守望先锋, 英雄联盟, 少女乐团派对, 韩流, 原神, 缪斯学园偶像, 我的世界. English aliases like `LoveLive` and `Minecraft` are accepted as input. | | `/回答 A` | `/answer A` | Answer the active five-minute cold-knowledge quiz for a rarity-based Mora reward. | -| `/抽奖 [金额]` | `/draw` | Play a single-player prize draw. | +| `/许愿池 金额` | `/许愿`, `/抽奖`, `/draw` | Put 1000-10000 Mora into the wish pool in 1000-Mora units for Mora echoes, scan-boost tickets, Taffy speech credits, primogems, pool jackpot, and pity progress. | | `/彩票 开始` | `/lottery start` | Open a multiplayer lottery round. | | `/彩票 报名 数字 金额` | `/lottery join` | Join or update a lottery entry. | | `/彩票 状态` | `/lottery status` | Show the current lottery round. | | `/彩票 开奖` | `/lottery draw` | Host-only draw for the current lottery round. | | `/彩票 取消` | `/lottery cancel` | Host-only cancellation with refunds. | -| `/21点 开始 [1-500]` | `/blackjack start` | Start a blackjack round with the chosen stake. | +| `/21点 开始 [1-2000]` | `/blackjack start` | Start a blackjack round with the chosen stake. | | `/21点 要牌` | `/blackjack hit` | Draw one blackjack card. | | `/21点 停牌` | `/blackjack stand` | Stand and settle against the dealer. | | `/21点 状态` | `/blackjack status` | Show the current blackjack round. | | `/21点 结束` | `/blackjack end` | End the current blackjack round. | +| `/21点 胜率` | `/blackjack stats` | Show every player and Taffy's group-scoped win rate and net Mora result. | | `/21点 多人 启动` | - | Open a Taffy-hosted blackjack table and auto-seat the host. | | `/21点 加入` | - | Join the current blackjack table before betting starts. | | `/21点 多人 开始` | - | Host-only close joining and start ordered betting prompts. | -| `/21点 放金额` | - | Place the prompted table stake, 1-10000 coins. | +| `/21点 放金额` | - | Place the prompted table stake, 1-2000 Mora. | | `/游戏记录 [页码]` | `/gamehistory` | Show indexed personal game history. | | `/游戏帮助` | `/games` | Show local game rules. | diff --git a/docs/group-lottery-0.6.3.md b/docs/group-lottery-0.6.3.md index 0c80b68..c9cd113 100644 --- a/docs/group-lottery-0.6.3.md +++ b/docs/group-lottery-0.6.3.md @@ -50,7 +50,7 @@ ## 个人局号与记录 -抽奖、彩票和 21 点分别维护本人的游戏局号。第一次参与某种游戏显示 +许愿池、彩票和 21 点分别维护本人的游戏局号。第一次参与某种游戏显示 `Jacky #1:...`,下一把显示 `Jacky #2:...`。配置别名解析到同一人物时, 共用同一账户、局号和历史。 @@ -58,7 +58,7 @@ `/gamehistory [page]` 查看最近记录,每页五条。记录包含游戏、个人局号、 结果、下注、返还和净变化,最多保留最近 50 条。 -21 点下注范围同步调整为 1~500。`/21点 开始 100` 只表示本局下注 100; +21 点下注范围现为 1~2000。`/21点 开始 100` 只表示本局下注 100; 发牌后再单独发送 `/21点 要牌` 或 `/21点 停牌`。 ## 持久化与并发 diff --git a/docs/local-plugins-0.6.1.md b/docs/local-plugins-0.6.1.md index 171b7f5..de497d2 100644 --- a/docs/local-plugins-0.6.1.md +++ b/docs/local-plugins-0.6.1.md @@ -11,7 +11,7 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写, | coin-ranking | `/大富翁榜`、`/摩拉排行`(兼容 `/金币排行`) | `/richlist` | | coin-transfer | `/转账 对方 金额` | `/transfer name amount` | | coin-loan | `/贷款 [金额]`、`/还款 [金额\|全部]` | `/loan amount`、`/repay [amount\|all]` | -| single-player-games | `/抽奖 [金额]` | `/draw [金额]` | +| single-player-games | `/许愿池 金额`(兼容 `/许愿`、`/抽奖`) | `/draw 金额` | | single-player-games | `/彩票 开始/报名/当前状态/开奖/取消` | `/lottery start/join/status/draw/cancel` | | single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) | | single-player-games | `/21点 要牌/停牌/状态/结束` | `/blackjack hit/stand/status/end` | @@ -60,8 +60,9 @@ person_key。相同人物的已配置别名共用摩拉和牌局,其他群使 ## 虚拟摩拉与游戏管理 每个群内人物首次使用账户时一次性获得 1000 虚拟摩拉。再次查询、 -重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和 -21 点下注为 1~500;余额不足或参数非法不扣款。 +重启服务或切换已配置别名都不会重复领取。许愿池投入为 1000~10000, +按 1000 摩拉递增;彩票下注为 1~500,21 点下注为 1~2000; +余额不足或参数非法不扣款。 `/转账 对方 金额` 会把本人的虚拟摩拉转给同群已初始化账户,英文为 `/transfer name amount`。金额范围 1~100000;不允许给自己转账。 @@ -76,12 +77,14 @@ person_key。相同人物的已配置别名共用摩拉和牌局,其他群使 调用者账户。榜单按当前摩拉余额降序排列,人数范围 1~20;账户存在 未还贷款时会在名字后标注贷款本金。 -抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。 +`/许愿池 金额` 会把摩拉投入公共许愿池,每 1000 摩拉结算 1 愿; +`/许愿`、`/抽奖` 和 `/draw` 是同一功能入口。许愿可能获得摩拉回响、 +扫码概率券、Taffy 发话额度、原石或池底大愿,8 愿没有道具类奖励后触发保底。 alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关, 下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见 [多人彩票与游戏记录](group-lottery-0.6.3.md)。 -21 点默认下注 50、范围 1~500。`/21点 开始 100` 表示拿 100 摩拉作为本局下注进场, +21 点默认下注 50、范围 1~2000。`/21点 开始 100` 表示拿 100 摩拉作为本局下注进场, 等价的 OCR 紧凑形式是 `/21点开始100`。开局会扣除下注并发两张牌, 然后根据牌面单独发送 `/21点 要牌` 或 `/21点 停牌`;开局与操作写在 同一条消息时不会扣款,而会返回分步提示。牌局使用洗牌后的单副 52 张牌, @@ -93,12 +96,15 @@ alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数 多人 21 点使用 `/21点 多人 启动` 创建 Taffy 牌桌,开桌者自动入座。 其他玩家在下注前用 `/21点 加入` 进入当前牌桌;主持人发送 `/21点 多人 开始` -后,Taffy 会按座位顺序提示每个人 `/21点 放200` 放筹码,范围 1~10000。 +后,Taffy 会按座位顺序提示每个人 `/21点 放200` 放筹码,范围 1~2000。 所有玩家下注后统一发牌,轮到本人时用 `/21点 要牌` 或 `/21点 停牌` 操作; 结算按座位顺序列出每个人的下注、返还、净变化和当前摩拉。 自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍, 平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔 -抽奖或 21 点开局,多人彩票报名仍可独立进行。 +许愿池或 21 点开局,多人彩票报名仍可独立进行。 + +`/21点 胜率` 会列出当前群所有有已结算记录的玩家,以及 Taffy 的 +胜/负/平、胜率和累计净赚净亏;主动认输和超时计负,牌桌取消不计入统计。 十分钟无有效游戏操作的牌局在下一次账户操作时按认输结束;查询状态 不延长有效期。主动结束同样按认输处理,不退下注。其他人可以各自 diff --git a/extensions/jwebot/single-player-games/README.md b/extensions/jwebot/single-player-games/README.md index b2a6528..feb8ef5 100644 --- a/extensions/jwebot/single-player-games/README.md +++ b/extensions/jwebot/single-player-games/README.md @@ -6,7 +6,7 @@ Author: `jwebot` Help: -- `/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助` +- `/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助` Registered commands: @@ -14,6 +14,8 @@ Registered commands: - `/金币` - `/wallet` - `/原石` +- `/许愿池` +- `/许愿` - `/抽奖` - `/draw` - `/彩票` diff --git a/extensions/jwebot/single-player-games/plugin.toml b/extensions/jwebot/single-player-games/plugin.toml index 4c830ee..e4a9b7e 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 = "摩拉游戏" -help = ["/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助"] +help = ["/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助"] 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", "/游戏帮助", "/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 6fafc53..10a9c87 100644 --- a/extensions/jwebot/single-player-games/src/single_player_games.py +++ b/extensions/jwebot/single-player-games/src/single_player_games.py @@ -6,8 +6,8 @@ from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'single-player-games' AUTHOR = 'jwebot' DESCRIPTION = '摩拉游戏' -HELP_ENTRIES = ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',) -COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games') +HELP_ENTRIES = ('/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助',) +COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/许愿池', '/许愿', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games') ADAPTER = ServiceCommandAdapter( PROFILE, metadata={"local_extension": PROFILE, "author": AUTHOR}, diff --git a/extensions/jwebot/single-player-games/tests/test_plugin.py b/extensions/jwebot/single-player-games/tests/test_plugin.py index 981e890..82b00ec 100644 --- a/extensions/jwebot/single-player-games/tests/test_plugin.py +++ b/extensions/jwebot/single-player-games/tests/test_plugin.py @@ -20,9 +20,9 @@ class PluginTests(unittest.TestCase): ] self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(DESCRIPTION, '摩拉游戏') - self.assertEqual(HELP_ENTRIES, ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',)) + self.assertEqual(HELP_ENTRIES, ('/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助',)) self.assertIn('/摩拉', registered) - self.assertEqual(len(registered), 14) + self.assertEqual(len(registered), 16) if __name__ == "__main__": diff --git a/packages/jwebot-config/pyproject.toml b/packages/jwebot-config/pyproject.toml index 3f7dddc..aa6dd31 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 = "2.0.2b1+1021" +version = "2.0.4b1+1023" 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 3f65a6b..7446596 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__ = "2.0.2b1+1021" +__version__ = "2.0.4b1+1023" diff --git a/packages/jwebot-extension-host/pyproject.toml b/packages/jwebot-extension-host/pyproject.toml index 34cb4bb..79f17e9 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 = "2.0.2b1+1021" +version = "2.0.4b1+1023" description = "Single-release, single-invocation process host for JWeBot extensions" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==2.0.2b1+1021", - "jwebot-kernel==2.0.2b1+1021", + "jwebot-sdk==2.0.4b1+1023", + "jwebot-kernel==2.0.4b1+1023", ] [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 4f1ab66..e64257e 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__ = "2.0.2b1+1021" +__version__ = "2.0.4b1+1023" diff --git a/packages/jwebot-kernel/pyproject.toml b/packages/jwebot-kernel/pyproject.toml index 1fa9fd4..983f378 100644 --- a/packages/jwebot-kernel/pyproject.toml +++ b/packages/jwebot-kernel/pyproject.toml @@ -4,15 +4,18 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-kernel" -version = "2.0.2b1+1021" +version = "2.0.4b1+1023" description = "Stable routing and extension authority for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-config==2.0.2b1+1021", - "jwebot-sdk==2.0.2b1+1021", - "jwebot-official-extensions==2.0.2b1+1021", + "jwebot-config==2.0.4b1+1023", + "jwebot-sdk==2.0.4b1+1023", + "jwebot-official-extensions==2.0.4b1+1023", "tomli>=2; python_version < '3.11'", ] [tool.setuptools.packages.find] where = ["src"] + +[tool.setuptools.package-data] +jwebot_kernel = ["data/*.json"] diff --git a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py index 0911b4f..1294521 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__ = "2.0.2b1+1021" +__version__ = "2.0.4b1+1023" diff --git a/packages/jwebot-kernel/src/jwebot_kernel/data/trivia_questions.json b/packages/jwebot-kernel/src/jwebot_kernel/data/trivia_questions.json new file mode 100644 index 0000000..aa675b4 --- /dev/null +++ b/packages/jwebot-kernel/src/jwebot_kernel/data/trivia_questions.json @@ -0,0 +1,1313 @@ +{ + "schema_version": 1, + "target_question_count": 500, + "templates": [ + "【{domain} 冷知识】{prompt}", + "冷门机制题:{prompt}", + "老玩家边角料:{prompt}", + "不查资料猜一下:{prompt}", + "细节题:{prompt}" + ], + "facts": [ + { + "id": "cs-c4-timer", + "domain": "反恐精英", + "prompt": "官方竞技默认炸弹安放后倒计时是多少秒?", + "answer": "四十秒", + "distractors": [ + "三十五秒", + "四十五秒", + "五十秒" + ], + "explanation": "反恐精英竞技常规炸弹倒计时是四十秒,很多残局路线都按这个时间算。", + "rarity": 4 + }, + { + "id": "cs-smoke-duration", + "domain": "反恐精英", + "prompt": "反恐精英2 里烟雾弹完全展开后通常持续多久?", + "answer": "十八秒", + "distractors": [ + "十五秒", + "二十秒", + "二十五秒" + ], + "explanation": "常规烟雾持续约十八秒,卡回防和爆弹节奏时很关键。", + "rarity": 4 + }, + { + "id": "cs-fire-duration", + "domain": "反恐精英", + "prompt": "燃烧弹或燃烧瓶火焰通常持续多久?", + "answer": "七秒", + "distractors": [ + "五秒", + "九秒", + "十二秒" + ], + "explanation": "火焰持续约七秒,拖延拆包或清点都围绕这个窗口。", + "rarity": 4 + }, + { + "id": "cs-mr12-premier", + "domain": "反恐精英", + "prompt": "反恐精英2 优先模式常规半场采用哪种回合制?", + "answer": "单方最多十二回合", + "distractors": [ + "单方最多十五回合", + "单方最多十回合", + "单方最多八回合" + ], + "explanation": "优先模式常规为十二回合半场,和旧十五回合半场节奏不同。", + "rarity": 3 + }, + { + "id": "cs-defuse-kit", + "domain": "反恐精英", + "prompt": "带拆弹钳时,完整拆包时间会缩短到多少秒?", + "answer": "五秒", + "distractors": [ + "四秒", + "六秒", + "七秒" + ], + "explanation": "无钳拆包十秒,有钳拆包五秒,残局听钳声和骗拆都靠这个数。", + "rarity": 3 + }, + { + "id": "cs-defuse-no-kit", + "domain": "反恐精英", + "prompt": "没有拆弹钳时,完整拆包需要多少秒?", + "answer": "十秒", + "distractors": [ + "七秒", + "八秒", + "十二秒" + ], + "explanation": "无钳完整拆包为十秒,有钳才是五秒。", + "rarity": 4 + }, + { + "id": "cs-plant-time", + "domain": "反恐精英", + "prompt": "炸弹从开始安放到成功落包大约需要多久?", + "answer": "三点二秒", + "distractors": [ + "二点五秒", + "四秒", + "五秒" + ], + "explanation": "安放炸弹约需 3.2 秒,很多压秒转点会卡这个窗口。", + "rarity": 5 + }, + { + "id": "cs-loss-bonus-cap", + "domain": "反恐精英", + "prompt": "连续失败奖励达到上限后,每名队员单回合基础失败奖励是多少?", + "answer": "三千四百美元", + "distractors": [ + "二千九百美元", + "三千美元", + "三千七百美元" + ], + "explanation": "失败奖励阶梯最高为三千四百美元,是强起和经济局判断的关键数值。", + "rarity": 5 + }, + { + "id": "cs-awp-kill-reward", + "domain": "反恐精英", + "prompt": "AWP 击杀的金钱奖励是多少?", + "answer": "一百美元", + "distractors": [ + "三百美元", + "五百美元", + "六百美元" + ], + "explanation": "AWP 击杀奖励只有 100 美元,这会影响狙击手经济滚雪球速度。", + "rarity": 5 + }, + { + "id": "cs-knife-kill-reward", + "domain": "反恐精英", + "prompt": "刀杀通常会给击杀者多少金钱奖励?", + "answer": "一千五百美元", + "distractors": [ + "一千美元", + "两千美元", + "三百美元" + ], + "explanation": "刀杀奖励 1500 美元,是最极端的击杀奖励之一。", + "rarity": 5 + }, + { + "id": "cs-zeus-kill-reward", + "domain": "反恐精英", + "prompt": "宙斯电击枪击杀通常给多少击杀奖励?", + "answer": "零美元", + "distractors": [ + "一百美元", + "三百美元", + "六百美元" + ], + "explanation": "宙斯击杀主要换人和整活,击杀奖励不是它的收益来源。", + "rarity": 5 + }, + { + "id": "cs-standard-kill-reward", + "domain": "反恐精英", + "prompt": "大多数步枪和手枪击杀的基础金钱奖励是多少?", + "answer": "三百美元", + "distractors": [ + "一百美元", + "五百美元", + "九百美元" + ], + "explanation": "多数枪械击杀奖励为 300 美元,和霰弹枪、冲锋枪、AWP 等特殊奖励区分。", + "rarity": 4 + }, + { + "id": "apex-shield-cell-time", + "domain": "顶点英雄", + "prompt": "使用小电池恢复护甲通常需要多久?", + "answer": "三秒", + "distractors": [ + "二秒", + "五秒", + "八秒" + ], + "explanation": "小电池使用时间是三秒,和大电池五秒、医疗箱八秒节奏不同。", + "rarity": 3 + }, + { + "id": "apex-shield-battery-time", + "domain": "顶点英雄", + "prompt": "大电池完整使用时间是多少?", + "answer": "五秒", + "distractors": [ + "三秒", + "八秒", + "十秒" + ], + "explanation": "大电池五秒回满护甲,是换甲和拉扯节奏的核心读条。", + "rarity": 4 + }, + { + "id": "apex-syringe-time", + "domain": "顶点英雄", + "prompt": "注射器完整使用时间是多少?", + "answer": "五秒", + "distractors": [ + "三秒", + "四秒", + "八秒" + ], + "explanation": "注射器五秒只回复一段生命值,经常比小电池更难在交火中塞进去。", + "rarity": 4 + }, + { + "id": "apex-medkit-time", + "domain": "顶点英雄", + "prompt": "医疗箱完整使用时间是多少?", + "answer": "八秒", + "distractors": [ + "五秒", + "十秒", + "十二秒" + ], + "explanation": "医疗箱八秒回满生命值,和凤凰治疗包十秒不同。", + "rarity": 4 + }, + { + "id": "apex-phoenix-time", + "domain": "顶点英雄", + "prompt": "凤凰治疗包完整使用时间是多少?", + "answer": "十秒", + "distractors": [ + "八秒", + "十二秒", + "十五秒" + ], + "explanation": "凤凰治疗包同时回满血和甲,但需要十秒完整读条。", + "rarity": 4 + }, + { + "id": "apex-grenade-stack", + "domain": "顶点英雄", + "prompt": "当前背包里一个格子通常最多能放几颗手雷?", + "answer": "一颗", + "distractors": [ + "两颗", + "三颗", + "四颗" + ], + "explanation": "手雷单格一颗会显著影响背包管理,让投掷物压制成本更高。", + "rarity": 4 + }, + { + "id": "apex-wattson-accelerant", + "domain": "顶点英雄", + "prompt": "哪位传奇使用终极技能加速剂时能直接充满终极技能?", + "answer": "华森", + "distractors": [ + "命脉", + "班加罗尔", + "瓦尔基里" + ], + "explanation": "华森的被动让终极技能加速剂直接充满终极技能。", + "rarity": 5 + }, + { + "id": "apex-controller-console", + "domain": "顶点英雄", + "prompt": "哪类传奇可以使用环形控制台读取下一圈信息?", + "answer": "控制位", + "distractors": [ + "突击位", + "游击位", + "支援位" + ], + "explanation": "职业系统里控制位能读取下一圈信息,和侦察位扫描敌人信息不同。", + "rarity": 4 + }, + { + "id": "apex-support-banner", + "domain": "顶点英雄", + "prompt": "哪类传奇可以通过复制器制作队友旗帜?", + "answer": "支援位", + "distractors": [ + "控制位", + "突击位", + "游击位" + ], + "explanation": "支援位能制作队友旗帜,这会改变残局复活路线。", + "rarity": 4 + }, + { + "id": "apex-recon-beacon", + "domain": "顶点英雄", + "prompt": "侦察位传奇使用调查信标时,主要获得哪类信息?", + "answer": "敌方队伍位置情报", + "distractors": [ + "下一圈中心点", + "空投武器列表", + "队友制造材料" + ], + "explanation": "侦察位读取调查信标会暴露敌方位置方向信息,不是读取下一圈。", + "rarity": 5 + }, + { + "id": "apex-skirmisher-care-package", + "domain": "顶点英雄", + "prompt": "游击位传奇远看空投时,额外能识别什么?", + "answer": "空投内高价值物品", + "distractors": [ + "敌人护甲等级", + "下一圈边界", + "复制器配方" + ], + "explanation": "游击位职业被动可以从远处查看空投内容,用来判断是否值得转点。", + "rarity": 5 + }, + { + "id": "overwatch-ramattra-pummel", + "domain": "守望先锋", + "prompt": "拉玛刹天罚形态的拳波对屏障更接近哪一种?", + "answer": "可以穿过屏障打人", + "distractors": [ + "会被屏障完全挡住", + "只能打屏障本体", + "会治疗屏障后队友" + ], + "explanation": "天罚形态的拳波可以穿过屏障命中目标,是打盾后站位的关键细节。", + "rarity": 5 + }, + { + "id": "overwatch-sigma-grasp", + "domain": "守望先锋", + "prompt": "西格玛动能俘获不能吸收哪类伤害?", + "answer": "光束类伤害", + "distractors": [ + "飞行弹道", + "火箭弹", + "榴弹" + ], + "explanation": "动能俘获吃弹道,不吃光束和近战类伤害。", + "rarity": 4 + }, + { + "id": "overwatch-dva-matrix", + "domain": "守望先锋", + "prompt": "D.Va 防御矩阵通常不能拦截哪类攻击?", + "answer": "近战挥击", + "distractors": [ + "飞行弹丸", + "部分终极弹道", + "治疗弹道" + ], + "explanation": "防御矩阵主要吃弹道,近战、光束等类型不会被矩阵吞掉。", + "rarity": 4 + }, + { + "id": "overwatch-ana-nade", + "domain": "守望先锋", + "prompt": "安娜生物手雷命中敌人后,最核心的负面效果是什么?", + "answer": "阻止其受到治疗", + "distractors": [ + "降低其移动速度", + "禁用其换弹", + "减少其弹药上限" + ], + "explanation": "生物手雷的禁疗效果能直接改变团战集火窗口。", + "rarity": 3 + }, + { + "id": "overwatch-lucio-crossfade", + "domain": "守望先锋", + "prompt": "卢西奥切歌的两个基础光环分别对应什么?", + "answer": "治疗和加速", + "distractors": [ + "增伤和护盾", + "隐身和侦测", + "减伤和吸血" + ], + "explanation": "卢西奥核心循环就是治疗歌和加速歌的切换。", + "rarity": 2 + }, + { + "id": "overwatch-mei-wall-cancel", + "domain": "守望先锋", + "prompt": "小美冰墙放出后,再次按技能键通常可以做什么?", + "answer": "提前取消冰墙", + "distractors": [ + "让冰墙回血", + "让冰墙移动", + "把冰墙变成护盾" + ], + "explanation": "冰墙可以手动提前取消,很多封路和救人操作都靠这个细节。", + "rarity": 3 + }, + { + "id": "overwatch-genji-deflect-beam", + "domain": "守望先锋", + "prompt": "源氏闪不能反弹哪类攻击?", + "answer": "光束类伤害", + "distractors": [ + "多数飞行弹丸", + "半藏箭矢", + "部分治疗弹道" + ], + "explanation": "闪主要处理弹道,查莉娅、温斯顿等光束类攻击不会被反弹。", + "rarity": 5 + }, + { + "id": "overwatch-winston-tesla-matrix", + "domain": "守望先锋", + "prompt": "温斯顿特斯拉炮和 D.Va 防御矩阵的关系更接近哪一种?", + "answer": "不会被矩阵吞掉", + "distractors": [ + "会被完全吞掉", + "会给矩阵充能", + "会被反弹给温斯顿" + ], + "explanation": "特斯拉炮属于持续电击/光束式伤害,不是矩阵能吃的飞行弹道。", + "rarity": 5 + }, + { + "id": "overwatch-kiriko-suzu", + "domain": "守望先锋", + "prompt": "雾子铃最核心的团队功能是哪一种?", + "answer": "净化负面效果并短暂无敌", + "distractors": [ + "永久增加护甲", + "刷新队友技能", + "制造屏障" + ], + "explanation": "铃的净化和短暂无敌常用于解禁疗、睡眠等关键状态。", + "rarity": 4 + }, + { + "id": "overwatch-baptiste-lamp", + "domain": "守望先锋", + "prompt": "巴蒂斯特维生力场的核心作用是什么?", + "answer": "阻止范围内队友死亡", + "distractors": [ + "提高队友换弹速度", + "让敌人禁疗", + "给队友隐身" + ], + "explanation": "维生力场让范围内目标不会被降到死亡线以下,发生器本体可被摧毁。", + "rarity": 4 + }, + { + "id": "overwatch-zen-transcendence", + "domain": "守望先锋", + "prompt": "禅雅塔开大招“圣”时,他本人通常处于什么状态?", + "answer": "无敌但不能攻击", + "distractors": [ + "隐身并能攻击", + "获得护盾但可被击杀", + "只能给单人治疗" + ], + "explanation": "圣让禅雅塔本人无敌并高速治疗周围队友,但期间不能开火。", + "rarity": 4 + }, + { + "id": "lol-control-ward", + "domain": "英雄联盟", + "prompt": "控制守卫通常不能直接显形哪类状态的英雄?", + "answer": "真正隐形", + "distractors": [ + "伪装", + "隐形陷阱", + "普通守卫" + ], + "explanation": "控制守卫能看见伪装和陷阱,但不能直接让真正隐形单位显形。", + "rarity": 5 + }, + { + "id": "lol-inhibitor-respawn", + "domain": "英雄联盟", + "prompt": "召唤师峡谷水晶被摧毁后,通常多久后重生?", + "answer": "五分钟", + "distractors": [ + "三分钟", + "四分钟", + "六分钟" + ], + "explanation": "水晶重生时间是五分钟,带线和远古资源交换经常围绕它计算。", + "rarity": 4 + }, + { + "id": "lol-turret-plates", + "domain": "英雄联盟", + "prompt": "防御塔镀层会在游戏时间什么时候脱落?", + "answer": "十四分钟", + "distractors": [ + "十分钟", + "十二分钟", + "十五分钟" + ], + "explanation": "镀层十四分钟脱落,前期越塔和换线收益会受这个时间点影响。", + "rarity": 3 + }, + { + "id": "lol-baron-spawn", + "domain": "英雄联盟", + "prompt": "纳什男爵首次刷新时间是?", + "answer": "二十分钟", + "distractors": [ + "十五分钟", + "十八分钟", + "二十五分钟" + ], + "explanation": "男爵二十分钟刷新,前一波视野和兵线准备通常会提前开始。", + "rarity": 2 + }, + { + "id": "lol-yasuo-wall", + "domain": "英雄联盟", + "prompt": "亚索风墙主要拦截哪类东西?", + "answer": "飞行弹道", + "distractors": [ + "防御塔攻击", + "持续光束", + "地形效果" + ], + "explanation": "风墙挡飞行弹道,不是所有远程效果都会被挡。", + "rarity": 4 + }, + { + "id": "lol-gangplank-orange", + "domain": "英雄联盟", + "prompt": "普朗克橘子的核心功能除回血外还包括什么?", + "answer": "解除控制", + "distractors": [ + "刷新火药桶", + "获得隐身", + "重置大招" + ], + "explanation": "坏血病疗法能解除控制,是船长对线和团战容错的重要细节。", + "rarity": 3 + }, + { + "id": "lol-buff-respawn", + "domain": "英雄联盟", + "prompt": "红蓝增益野怪被击杀后,常规重生间隔是多少?", + "answer": "五分钟", + "distractors": [ + "三分钟", + "四分钟", + "七分钟" + ], + "explanation": "红蓝增益营地重生间隔通常按五分钟规划,打野会围绕这个时间回线。", + "rarity": 4 + }, + { + "id": "lol-minion-spawn", + "domain": "英雄联盟", + "prompt": "召唤师峡谷第一波小兵从基地出发的时间点通常是?", + "answer": "一分零五秒", + "distractors": [ + "三十秒", + "一分十五秒", + "一分三十秒" + ], + "explanation": "小兵约在 1:05 出发,到线时间决定前期站位和一级入侵收尾。", + "rarity": 4 + }, + { + "id": "lol-scuttle-first", + "domain": "英雄联盟", + "prompt": "河道迅捷蟹第一轮通常在什么时间刷新?", + "answer": "三分三十秒", + "distractors": [ + "二分十五秒", + "三分钟", + "四分钟" + ], + "explanation": "第一只河蟹时间点常决定打野前期路线和河道争夺。", + "rarity": 5 + }, + { + "id": "lol-dragon-soul", + "domain": "英雄联盟", + "prompt": "一支队伍通常需要击杀几条元素龙才能获得龙魂?", + "answer": "四条", + "distractors": [ + "两条", + "三条", + "五条" + ], + "explanation": "同队拿到第四条元素龙时获得龙魂,随后远古龙才进入核心争夺。", + "rarity": 4 + }, + { + "id": "lol-flash-cooldown", + "domain": "英雄联盟", + "prompt": "召唤师技能闪现的基础冷却时间通常是多少?", + "answer": "三百秒", + "distractors": [ + "二百四十秒", + "二百七十秒", + "三百六十秒" + ], + "explanation": "闪现基础冷却五分钟,职业和高分局会精确记闪。", + "rarity": 4 + }, + { + "id": "gbp-fever", + "domain": "少女乐团派对", + "prompt": "协力演出里,队伍表现达标后进入的全队加分段通常叫什么?", + "answer": "狂热时间", + "distractors": [ + "安可时间", + "挑战时间", + "应援时间" + ], + "explanation": "协力演出里的狂热时间会影响整队得分,是多人房节奏点。", + "rarity": 4 + }, + { + "id": "gbp-judgement-order", + "domain": "少女乐团派对", + "prompt": "演奏判定从最好到较差,紧跟在完美之后的是哪一档?", + "answer": "很好", + "distractors": [ + "好", + "差", + "失误" + ], + "explanation": "判定顺序里完美之后是很好,再往下才是好、差和失误。", + "rarity": 3 + }, + { + "id": "gbp-live-boost", + "domain": "少女乐团派对", + "prompt": "通常一次演出最多可以消耗多少个演出能量来提高奖励?", + "answer": "三个", + "distractors": [ + "一个", + "五个", + "十个" + ], + "explanation": "演出能量影响奖励倍率,单次常规消耗上限是三个。", + "rarity": 4 + }, + { + "id": "gbp-area-item", + "domain": "少女乐团派对", + "prompt": "练习室、海报等区域道具主要影响什么?", + "answer": "成员综合力", + "distractors": [ + "抽卡概率", + "歌曲速度", + "体力上限" + ], + "explanation": "区域道具会给乐队或属性提供加成,是配队总综合力的一部分。", + "rarity": 3 + }, + { + "id": "gbp-challenge-live", + "domain": "少女乐团派对", + "prompt": "挑战演出活动里,进入挑战曲通常要先消耗什么?", + "answer": "挑战点数", + "distractors": [ + "星石", + "好友点", + "练习券" + ], + "explanation": "挑战演出需要先通过普通演出获取挑战点数,再打挑战曲。", + "rarity": 4 + }, + { + "id": "gbp-special-chart", + "domain": "少女乐团派对", + "prompt": "特殊难度谱面和专家谱面的关系更接近哪一种?", + "answer": "另一张独立谱面", + "distractors": [ + "专家谱自动加速", + "专家谱隐藏轨道", + "专家谱减少判定" + ], + "explanation": "特殊难度通常是独立谱面,不只是把专家谱调快。", + "rarity": 4 + }, + { + "id": "gbp-life-start", + "domain": "少女乐团派对", + "prompt": "常规演出开始时,生命值通常是多少?", + "answer": "一千", + "distractors": [ + "五百", + "一千五百", + "两千" + ], + "explanation": "演出生命值通常从 1000 开始,失误会扣生命,回体技能能拉回容错。", + "rarity": 5 + }, + { + "id": "gbp-flick-note", + "domain": "少女乐团派对", + "prompt": "粉色带箭头、需要上滑处理的音符通常是哪类音符?", + "answer": "滑动音符", + "distractors": [ + "长按音符", + "普通点击音符", + "连打音符" + ], + "explanation": "滑动音符需要按方向滑动处理,是高难谱面失误来源之一。", + "rarity": 4 + }, + { + "id": "gbp-band-rank-story", + "domain": "少女乐团派对", + "prompt": "乐队等级提升后,最直接解锁的是哪类内容?", + "answer": "乐队剧情", + "distractors": [ + "抽卡保底", + "谱面速度", + "协力房上限" + ], + "explanation": "乐队等级和乐队剧情绑定,读剧情还能拿到对应奖励。", + "rarity": 4 + }, + { + "id": "gbp-practice-ticket", + "domain": "少女乐团派对", + "prompt": "练习券主要用于什么?", + "answer": "提升成员等级", + "distractors": [ + "重置抽卡池", + "购买演出能量", + "改变歌曲属性" + ], + "explanation": "练习券是成员养成素材,用于提升等级而不是改谱面或抽卡。", + "rarity": 3 + }, + { + "id": "gbp-score-skill-window", + "domain": "少女乐团派对", + "prompt": "加分技能真正影响的是哪一段判定窗口?", + "answer": "技能生效期间的得分", + "distractors": [ + "整首歌固定加成", + "抽卡概率", + "演出能量恢复" + ], + "explanation": "加分技能有持续时间和触发时点,高分编成会计算技能覆盖。", + "rarity": 5 + }, + { + "id": "kpop-newjeans-attention", + "domain": "韩流", + "prompt": "新牛仔(NewJeans)出道时最先空降公开的音乐录像是哪首?", + "answer": "注意", + "distractors": [ + "炒作男孩", + "饼干", + "受伤" + ], + "explanation": "新牛仔(NewJeans)出道营销先公开《注意》的音乐录像,再连续展开其他内容。", + "rarity": 4 + }, + { + "id": "kpop-redvelvet-concept", + "domain": "韩流", + "prompt": "红贝贝(Red Velvet)的团体概念常被拆成哪两面?", + "answer": "红与丝绒", + "distractors": [ + "月与太阳", + "甜与苦", + "黑与粉" + ], + "explanation": "红贝贝(Red Velvet)常用红与丝绒两面概念区分明亮和成熟风格。", + "rarity": 3 + }, + { + "id": "kpop-music-show-encore", + "domain": "韩流", + "prompt": "韩国打歌节目获一位后,现场常见的收尾舞台叫什么?", + "answer": "安可舞台", + "distractors": [ + "预录舞台", + "回归预告", + "签售舞台" + ], + "explanation": "一位后的安可舞台经常被粉丝拿来观察现场唱功和团体互动。", + "rarity": 3 + }, + { + "id": "kpop-lightstick-name", + "domain": "韩流", + "prompt": "韩流粉丝应援棒在中文语境里通常被简称为什么?", + "answer": "应援棒", + "distractors": [ + "打歌棒", + "投票棒", + "站位棒" + ], + "explanation": "应援棒是演唱会和打歌现场常见的视觉应援道具。", + "rarity": 2 + }, + { + "id": "kpop-photocard", + "domain": "韩流", + "prompt": "专辑小卡随机配置最直接影响粉丝哪种行为?", + "answer": "拆专收卡", + "distractors": [ + "直播投票", + "现场排队", + "舞台换位" + ], + "explanation": "随机小卡会驱动拆专、交换和收集,是韩流专辑消费的重要机制。", + "rarity": 4 + }, + { + "id": "kpop-fanchant", + "domain": "韩流", + "prompt": "打歌舞台里粉丝按固定词句一起喊的应援内容通常叫?", + "answer": "应援词", + "distractors": [ + "站位表", + "预告词", + "抽选词" + ], + "explanation": "应援词会配合歌曲段落出现,是现场文化的一部分。", + "rarity": 3 + }, + { + "id": "kpop-pak", + "domain": "韩流", + "prompt": "韩流语境里的 PAK 通常指什么榜单成就?", + "answer": "完美全榜一位", + "distractors": [ + "首周销量破百万", + "巡演全场售罄", + "年末大赏提名" + ], + "explanation": "PAK 即 Perfect All-Kill,指主要音源榜和综合榜同时达成一位。", + "rarity": 5 + }, + { + "id": "kpop-hanteo", + "domain": "韩流", + "prompt": "韩流专辑销量里,Hanteo 榜更偏向统计哪类数据?", + "answer": "加盟店实时销售", + "distractors": [ + "全球出货总量", + "电台播放次数", + "演唱会入场人数" + ], + "explanation": "Hanteo 更接近参与店铺的实时销售数据,和 Circle 的流通口径不同。", + "rarity": 5 + }, + { + "id": "kpop-first-week-sales", + "domain": "韩流", + "prompt": "粉丝常说的“初动”通常指专辑发行后的哪段销量?", + "answer": "首周销量", + "distractors": [ + "首日播放量", + "首月下载量", + "年末总销量" + ], + "explanation": "初动通常指发行第一周销量,是粉丝衡量回归动员的重要指标。", + "rarity": 4 + }, + { + "id": "kpop-maknae", + "domain": "韩流", + "prompt": "韩流团体里 maknae 一词通常指什么成员?", + "answer": "年龄最小的成员", + "distractors": [ + "队长", + "主唱", + "编舞老师" + ], + "explanation": "maknae 是韩语忙内,指团内年龄最小的成员。", + "rarity": 3 + }, + { + "id": "kpop-triple-crown", + "domain": "韩流", + "prompt": "打歌节目里的 Triple Crown 通常表示同一首歌达成什么?", + "answer": "在该节目获得三次一位", + "distractors": [ + "三家电视台同时播出", + "三张专辑连续发行", + "三名成员个人出道" + ], + "explanation": "很多打歌节目以三次一位称为 Triple Crown,之后可能不再参与该节目同曲竞争。", + "rarity": 5 + }, + { + "id": "genshin-superconduct", + "domain": "原神", + "prompt": "超导反应最核心的战斗收益是什么?", + "answer": "降低物理抗性", + "distractors": [ + "降低水元素抗性", + "生成护盾", + "恢复元素能量" + ], + "explanation": "超导会降低敌人物理抗性,主要服务物理伤害打法。", + "rarity": 4 + }, + { + "id": "genshin-dendro-core-limit", + "domain": "原神", + "prompt": "场上草原核数量超过上限时,通常会怎样?", + "answer": "较早生成的草原核会提前爆炸", + "distractors": [ + "所有草原核消失", + "刷新持续时间", + "变成晶片" + ], + "explanation": "草原核有场上数量限制,超过后旧核会提前触发爆炸。", + "rarity": 5 + }, + { + "id": "genshin-hyperbloom", + "domain": "原神", + "prompt": "超绽放由什么元素触发草原核产生?", + "answer": "雷元素", + "distractors": [ + "火元素", + "冰元素", + "风元素" + ], + "explanation": "雷元素命中草原核会触发超绽放,生成追踪性质的伤害。", + "rarity": 3 + }, + { + "id": "genshin-burgeon", + "domain": "原神", + "prompt": "烈绽放由什么元素触发草原核产生?", + "answer": "火元素", + "distractors": [ + "雷元素", + "岩元素", + "冰元素" + ], + "explanation": "火元素命中草原核会触发烈绽放,造成范围草元素伤害。", + "rarity": 3 + }, + { + "id": "genshin-crystallize", + "domain": "原神", + "prompt": "结晶反应掉落的晶片主要提供什么?", + "answer": "对应元素护盾", + "distractors": [ + "元素能量", + "暴击率加成", + "治疗效果" + ], + "explanation": "岩元素和可结晶元素反应会产生晶片,拾取后形成对应元素护盾。", + "rarity": 3 + }, + { + "id": "genshin-resin-cap", + "domain": "原神", + "prompt": "当前原粹树脂自然恢复上限是多少?", + "answer": "二百", + "distractors": [ + "一百六十", + "一百八十", + "二百四十" + ], + "explanation": "原粹树脂上限后来提高到二百,日常体力规划随之变化。", + "rarity": 4 + }, + { + "id": "genshin-swirl-elements", + "domain": "原神", + "prompt": "扩散反应通常不能直接扩散哪种元素?", + "answer": "草元素", + "distractors": [ + "火元素", + "水元素", + "雷元素" + ], + "explanation": "风元素主要扩散火、水、雷、冰,不能直接扩散草、岩、风。", + "rarity": 5 + }, + { + "id": "genshin-freeze", + "domain": "原神", + "prompt": "冻结反应由哪两类元素共同触发?", + "answer": "水元素和冰元素", + "distractors": [ + "火元素和冰元素", + "雷元素和冰元素", + "草元素和水元素" + ], + "explanation": "冻结来自水和冰,后续可被重攻击等方式打出碎冰。", + "rarity": 3 + }, + { + "id": "genshin-shatter", + "domain": "原神", + "prompt": "冻结后的碎冰伤害通常由哪类攻击触发?", + "answer": "重攻击或钝击类效果", + "distractors": [ + "纯治疗效果", + "普通扩散", + "拾取晶片" + ], + "explanation": "被冻结目标受到双手剑、岩元素或部分重击类效果时可触发碎冰。", + "rarity": 5 + }, + { + "id": "genshin-burning", + "domain": "原神", + "prompt": "燃烧反应由哪两类元素触发?", + "answer": "火元素和草元素", + "distractors": [ + "火元素和雷元素", + "水元素和草元素", + "冰元素和草元素" + ], + "explanation": "燃烧来自火与草,会造成持续火元素伤害。", + "rarity": 4 + }, + { + "id": "genshin-quicken", + "domain": "原神", + "prompt": "原激化反应由哪两类元素触发?", + "answer": "雷元素和草元素", + "distractors": [ + "火元素和草元素", + "水元素和雷元素", + "冰元素和雷元素" + ], + "explanation": "雷与草触发原激化,后续能衍生超激化或蔓激化。", + "rarity": 4 + }, + { + "id": "lovelive-snow-halation", + "domain": "缪斯学园偶像", + "prompt": "《雪色光晕》演出中,观众应援色由白转橙通常对应谁的独唱段?", + "answer": "高坂穗乃果", + "distractors": [ + "南小鸟", + "园田海未", + "绚濑绘里" + ], + "explanation": "白转橙是《雪色光晕》现场文化中非常有名的段落。", + "rarity": 4 + }, + { + "id": "lovelive-token-event", + "domain": "缪斯学园偶像", + "prompt": "学园偶像祭传统代币活动里,打活动曲通常要消耗什么?", + "answer": "活动道具", + "distractors": [ + "友情点", + "练习贴纸", + "社员证" + ], + "explanation": "代币活动先刷普通歌曲拿活动道具,再消耗道具打活动曲。", + "rarity": 4 + }, + { + "id": "lovelive-score-match", + "domain": "缪斯学园偶像", + "prompt": "学园偶像祭的分数赛活动核心更接近哪种玩法?", + "answer": "多人按分数排名", + "distractors": [ + "单人剧情解谜", + "抽卡保底挑战", + "社团领地战" + ], + "explanation": "分数赛会把玩家匹配到一起,按歌曲得分排位结算。", + "rarity": 4 + }, + { + "id": "lovelive-medley", + "domain": "缪斯学园偶像", + "prompt": "学园偶像祭的串烧嘉年华一次最多连续打几首歌?", + "answer": "三首", + "distractors": [ + "两首", + "四首", + "五首" + ], + "explanation": "串烧嘉年华可以选择连续打一到三首歌,体力和稳定性都更吃紧。", + "rarity": 4 + }, + { + "id": "lovelive-bibi", + "domain": "缪斯学园偶像", + "prompt": "小队碧碧(BiBi)由哪组成员组成?", + "answer": "绘里、真姬、妮可", + "distractors": [ + "穗乃果、小鸟、花阳", + "海未、凛、希", + "小鸟、海未、绘里" + ], + "explanation": "碧碧(BiBi)成员是绚濑绘里、西木野真姬、矢泽妮可。", + "rarity": 3 + }, + { + "id": "lovelive-printemps", + "domain": "缪斯学园偶像", + "prompt": "小队春色少女(Printemps)由哪组成员组成?", + "answer": "穗乃果、小鸟、花阳", + "distractors": [ + "绘里、真姬、妮可", + "海未、凛、希", + "穗乃果、凛、妮可" + ], + "explanation": "春色少女(Printemps)成员是高坂穗乃果、南小鸟、小泉花阳。", + "rarity": 3 + }, + { + "id": "lovelive-lily-white", + "domain": "缪斯学园偶像", + "prompt": "小队百合白(lily white)由哪组成员组成?", + "answer": "海未、凛、希", + "distractors": [ + "绘里、真姬、妮可", + "穗乃果、小鸟、花阳", + "花阳、妮可、绘里" + ], + "explanation": "百合白(lily white)成员是园田海未、星空凛、东条希。", + "rarity": 4 + }, + { + "id": "lovelive-sif-attributes", + "domain": "缪斯学园偶像", + "prompt": "学园偶像祭卡牌和歌曲的三大属性是哪组?", + "answer": "甜美、清纯、冷艳", + "distractors": [ + "火、水、雷", + "红、蓝、绿", + "声乐、舞蹈、视觉" + ], + "explanation": "SIF 的属性对应 Smile、Pure、Cool,影响队伍加成和得分。", + "rarity": 4 + }, + { + "id": "lovelive-debut-single", + "domain": "缪斯学园偶像", + "prompt": "μ's 的首张单曲是哪首?", + "answer": "我们的LIVE 与你的LIFE", + "distractors": [ + "雪色光晕", + "夏色笑颜1,2,Jump!", + "MOMENT RING" + ], + "explanation": "《我们的LIVE 与你的LIFE》是 μ's 的首张单曲。", + "rarity": 5 + }, + { + "id": "lovelive-final-live-venue", + "domain": "缪斯学园偶像", + "prompt": "μ's Final LoveLive! 的举办场馆是哪一个?", + "answer": "东京巨蛋", + "distractors": [ + "埼玉超级竞技场", + "横滨体育馆", + "大阪城音乐厅" + ], + "explanation": "μ's Final LoveLive! 于东京巨蛋举行,是粉丝记忆里的重要节点。", + "rarity": 5 + }, + { + "id": "lovelive-moment-ring", + "domain": "缪斯学园偶像", + "prompt": "μ's Final LoveLive! 前推出、常被视作收束节点的单曲是哪首?", + "answer": "MOMENT RING", + "distractors": [ + "KiRa-KiRa Sensation!", + "No brand girls", + "Wonderful Rush" + ], + "explanation": "《MOMENT RING》对应 μ's 后期收束语境,常和 Final LoveLive 一起被提起。", + "rarity": 5 + }, + { + "id": "minecraft-beacon-base", + "domain": "我的世界", + "prompt": "满级信标金字塔如果全部铺满,一共需要多少个方块?", + "answer": "一百六十四个", + "distractors": [ + "一百二十八个", + "一百四十四个", + "一百九十二个" + ], + "explanation": "满级信标底座为九乘九、七乘七、五乘五、三乘三,合计一百六十四个方块。", + "rarity": 5 + }, + { + "id": "minecraft-bookshelves", + "domain": "我的世界", + "prompt": "附魔台达到三十级附魔通常最多需要多少个书架参与加成?", + "answer": "十五个", + "distractors": [ + "十二个", + "十八个", + "二十个" + ], + "explanation": "十五个有效书架即可提供三十级附魔所需的书架加成。", + "rarity": 3 + }, + { + "id": "minecraft-nether-portal-min", + "domain": "我的世界", + "prompt": "不算四个角,最小下界传送门框架需要多少个黑曜石?", + "answer": "十个", + "distractors": [ + "八个", + "十二个", + "十四个" + ], + "explanation": "最小传送门外框是四乘五,四角可省,所以最少十个黑曜石。", + "rarity": 4 + }, + { + "id": "minecraft-respawn-anchor", + "domain": "我的世界", + "prompt": "重生锚在哪个维度用于设定重生点?", + "answer": "下界", + "distractors": [ + "主世界", + "末地", + "深暗之域" + ], + "explanation": "重生锚用于下界重生,在主世界或末地使用会爆炸。", + "rarity": 4 + }, + { + "id": "minecraft-comparator-composter", + "domain": "我的世界", + "prompt": "堆肥桶满级时,比较器读取到的红石信号强度是多少?", + "answer": "八", + "distractors": [ + "七", + "九", + "十五" + ], + "explanation": "堆肥桶从空到满对应零到八级比较器信号。", + "rarity": 5 + }, + { + "id": "minecraft-sponge-nether", + "domain": "我的世界", + "prompt": "湿海绵放在下界时会发生什么?", + "answer": "立刻变干", + "distractors": [ + "变成岩浆", + "掉落成物品", + "无法放置" + ], + "explanation": "湿海绵在下界会立即干燥,这是处理湿海绵的冷门方法。", + "rarity": 4 + }, + { + "id": "minecraft-hopper-cooldown", + "domain": "我的世界", + "prompt": "漏斗每次转移物品后的传输冷却大约是多少游戏刻?", + "answer": "八个游戏刻", + "distractors": [ + "两个游戏刻", + "十六个游戏刻", + "二十个游戏刻" + ], + "explanation": "漏斗传输冷却为 8 游戏刻,大约每秒 2.5 个物品。", + "rarity": 5 + }, + { + "id": "minecraft-nether-ratio", + "domain": "我的世界", + "prompt": "主世界和下界水平坐标的常规换算比例是多少?", + "answer": "八比一", + "distractors": [ + "四比一", + "十六比一", + "一比一" + ], + "explanation": "下界水平移动一格约等于主世界八格,是交通网络的基础。", + "rarity": 4 + }, + { + "id": "minecraft-end-portal-frames", + "domain": "我的世界", + "prompt": "完整末地传送门框架一共需要多少个末地传送门框架方块?", + "answer": "十二个", + "distractors": [ + "八个", + "十个", + "十六个" + ], + "explanation": "末地传送门是三乘三空心结构,四边共十二个框架方块。", + "rarity": 4 + }, + { + "id": "minecraft-candle-stack", + "domain": "我的世界", + "prompt": "同一方块空间内最多可以放几根同色蜡烛?", + "answer": "四根", + "distractors": [ + "两根", + "三根", + "八根" + ], + "explanation": "蜡烛可在同一格叠放到四根,蛋糕上的蜡烛不按这个方式叠。", + "rarity": 5 + }, + { + "id": "minecraft-budding-amethyst", + "domain": "我的世界", + "prompt": "紫水晶芽只能从哪种方块上自然长出?", + "answer": "紫水晶母岩", + "distractors": [ + "普通紫水晶块", + "方解石", + "平滑玄武岩" + ], + "explanation": "只有紫水晶母岩能长出紫水晶芽,普通紫水晶块不能替代。", + "rarity": 5 + } + ] +} diff --git a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py index 028cb79..aa05a8e 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py @@ -25,6 +25,7 @@ import urllib.request from dataclasses import dataclass from datetime import datetime, timedelta, timezone from functools import lru_cache +from importlib import resources from pathlib import Path from typing import Any, Callable, Iterable, Mapping from zoneinfo import ZoneInfo @@ -82,6 +83,7 @@ from jwebot_official_extensions.player_games import ( lottery_achievement_lines, next_game_index, normalize_player_account, + record_blackjack_result, spend_primogems, ) from jwebot_sdk import Event, PlanKind @@ -358,73 +360,8 @@ SCAN_BOOST_PITY_MISS_LIMIT = 2 STEAM_GROUP_ROSTER: tuple[dict[str, object], ...] = () PERSON_ALIAS_MIGRATION_VERSION = "person-aliases-empty" PROFILE_ALIAS_REPAIR_CANONICALS: frozenset[str] = frozenset() -TRIVIA_QUESTIONS: tuple[dict[str, object], ...] = () -TRIVIA_FACTS: tuple[tuple[str, str, str, str, tuple[str, str, str], str, int], ...] = ( - ("cs-c4-timer", "反恐精英", "官方竞技规则里,炸弹安放后默认爆炸倒计时是多少秒?", "四十秒", ("三十五秒", "四十五秒", "五十秒"), "反恐精英竞技常规炸弹倒计时是四十秒,很多残局路线都按这个时间算。", 4), - ("cs-smoke-duration", "反恐精英", "反恐精英2 里烟雾弹完全展开后通常持续多久?", "十八秒", ("十五秒", "二十秒", "二十五秒"), "常规烟雾持续约十八秒,卡回防和爆弹节奏时很关键。", 4), - ("cs-fire-duration", "反恐精英", "燃烧弹或燃烧瓶火焰通常持续多久?", "七秒", ("五秒", "九秒", "十二秒"), "火焰持续约七秒,拖延拆包或清点都围绕这个窗口。", 4), - ("cs-mr12-premier", "反恐精英", "反恐精英2 优先模式(Premier)常规半场采用哪种回合制?", "单方最多十二回合", ("单方最多十五回合", "单方最多十回合", "单方最多八回合"), "优先模式常规为十二回合半场,和旧十五回合半场节奏不同。", 3), - ("cs-defuse-kit", "反恐精英", "带拆弹钳时,完整拆包时间会缩短到多少秒?", "五秒", ("四秒", "六秒", "七秒"), "无钳拆包十秒,有钳拆包五秒,残局听钳声和骗拆都靠这个数。", 3), - ("cs-loss-bonus", "反恐精英", "连续失败奖励在达到上限后,每名队员单回合基础失败奖励是多少?", "三千四百美元", ("二千九百美元", "三千美元", "三千七百美元"), "失败奖励阶梯最高为三千四百美元,是强起和经济局判断的关键数值。", 5), - ("apex-shield-cell-time", "顶点英雄", "使用小电池恢复护甲通常需要多久?", "三秒", ("二秒", "五秒", "八秒"), "小电池使用时间是三秒,和大电池五秒、医疗箱八秒节奏不同。", 3), - ("apex-phoenix-time", "顶点英雄", "凤凰治疗包完整使用时间是多少?", "十秒", ("八秒", "十二秒", "十五秒"), "凤凰治疗包同时回满血和甲,但需要十秒完整读条。", 4), - ("apex-grenade-stack", "顶点英雄", "当前背包里一个格子通常最多能放几颗手雷?", "一颗", ("两颗", "三颗", "四颗"), "手雷单格一颗会显著影响背包管理,这个改动让投掷物压制成本更高。", 4), - ("apex-wattson-accelerant", "顶点英雄", "哪位传奇使用终极技能加速剂时有特殊收益,能直接充满终极技能?", "华森", ("命脉", "班加罗尔", "瓦尔基里"), "华森的被动让终极技能加速剂直接充满终极技能。", 5), - ("apex-controller-console", "顶点英雄", "哪类传奇可以使用地图上的环形控制台读取下一圈信息?", "控制位", ("突击位", "游击位", "支援位"), "职业系统里控制位能读取下一圈信息,和侦察位扫描敌人信息不同。", 4), - ("apex-support-banner", "顶点英雄", "哪类传奇可以通过复制器制作队友旗帜?", "支援位", ("控制位", "突击位", "游击位"), "支援位能制作队友旗帜,这会改变残局复活路线。", 4), - ("overwatch-ramattra-pummel", "守望先锋", "拉玛刹进入天罚形态后,普通攻击拳波对屏障的关系更接近哪一种?", "可以穿过屏障打人", ("会被屏障完全挡住", "只能打屏障本体", "会治疗屏障后队友"), "天罚形态的拳波可以穿过屏障命中目标,是打盾后站位的关键细节。", 5), - ("overwatch-sigma-grasp", "守望先锋", "西格玛动能俘获不能吸收哪类伤害?", "光束类伤害", ("飞行弹道", "火箭弹", "榴弹"), "动能俘获吃弹道,不吃光束和近战类伤害。", 4), - ("overwatch-dva-matrix", "守望先锋", "D.Va 防御矩阵通常不能拦截哪类攻击?", "近战挥击", ("飞行弹丸", "部分终极弹道", "治疗弹道"), "防御矩阵主要吃弹道,近战、光束等类型不会被矩阵吞掉。", 4), - ("overwatch-ana-nade", "守望先锋", "安娜生物手雷命中敌人后,最核心的负面效果是什么?", "阻止其受到治疗", ("降低其移动速度", "禁用其换弹", "减少其弹药上限"), "生物手雷的禁疗效果能直接改变团战集火窗口。", 3), - ("overwatch-lucio-crossfade", "守望先锋", "卢西奥切歌的两个基础光环分别对应什么?", "治疗和加速", ("增伤和护盾", "隐身和侦测", "减伤和吸血"), "卢西奥核心循环就是治疗歌和加速歌的切换。", 2), - ("overwatch-mei-wall-cancel", "守望先锋", "小美冰墙放出后,玩家再次按技能键通常可以做什么?", "提前取消冰墙", ("让冰墙回血", "让冰墙移动", "把冰墙变成护盾"), "冰墙可以手动提前取消,很多封路和救人操作都靠这个细节。", 3), - ("lol-control-ward", "英雄联盟", "控制守卫通常不能直接显形哪类状态的英雄?", "真正隐形", ("伪装", "隐形陷阱", "普通守卫"), "控制守卫能看见伪装和陷阱,但不能直接让真正隐形单位显形。", 5), - ("lol-inhibitor-respawn", "英雄联盟", "召唤师峡谷水晶被摧毁后,通常多久后重生?", "五分钟", ("三分钟", "四分钟", "六分钟"), "水晶重生时间是五分钟,带线和远古资源交换经常围绕它计算。", 4), - ("lol-turret-plates", "英雄联盟", "防御塔镀层会在游戏时间什么时候脱落?", "十四分钟", ("十分钟", "十二分钟", "十五分钟"), "镀层十四分钟脱落,前期越塔和换线收益会受这个时间点影响。", 3), - ("lol-baron-spawn", "英雄联盟", "纳什男爵首次刷新时间是?", "二十分钟", ("十五分钟", "十八分钟", "二十五分钟"), "男爵二十分钟刷新,前一波视野和兵线准备通常会提前开始。", 2), - ("lol-yasuo-wall", "英雄联盟", "亚索风墙主要拦截哪类东西?", "飞行弹道", ("防御塔攻击", "持续光束", "地形效果"), "风墙挡飞行弹道,不是所有远程效果都会被挡。", 4), - ("lol-gangplank-orange", "英雄联盟", "普朗克橘子的核心功能除回血外还包括什么?", "解除控制", ("刷新火药桶", "获得隐身", "重置大招"), "坏血病疗法能解除控制,是船长对线和团战容错的重要细节。", 3), - ("gbp-fever", "少女乐团派对", "协力演出里,队伍表现达标后进入的全队加分段通常叫什么?", "狂热时间", ("安可时间", "挑战时间", "应援时间"), "协力演出里的狂热时间会影响整队得分,是多人房节奏点。", 4), - ("gbp-judgement-order", "少女乐团派对", "演奏判定从最好到较差,紧跟在完美之后的是哪一档?", "很好", ("好", "差", "失误"), "判定顺序里完美之后是很好,再往下才是好、差和失误。", 3), - ("gbp-live-boost", "少女乐团派对", "通常一次演出最多可以消耗多少个演出能量来提高奖励?", "三个", ("一个", "五个", "十个"), "演出能量影响奖励倍率,单次常规消耗上限是三个。", 4), - ("gbp-area-item", "少女乐团派对", "练习室、海报等区域道具主要影响什么?", "成员综合力", ("抽卡概率", "歌曲速度", "体力上限"), "区域道具会给乐队或属性提供加成,是配队总综合力的一部分。", 3), - ("gbp-challenge-live", "少女乐团派对", "挑战演出活动里,进入挑战曲通常要先消耗什么?", "挑战点数", ("星石", "好友点", "练习券"), "挑战演出需要先通过普通演出获取挑战点数,再打挑战曲。", 4), - ("gbp-special-chart", "少女乐团派对", "特殊难度谱面和专家谱面的关系更接近哪一种?", "另一张独立谱面", ("专家谱自动加速", "专家谱隐藏轨道", "专家谱减少判定"), "特殊难度通常是独立谱面,不只是把专家谱调快。", 4), - ("kpop-newjeans-attention", "韩流", "新牛仔(NewJeans)出道时最先空降公开的音乐录像是哪首?", "注意", ("炒作男孩", "饼干", "受伤"), "新牛仔(NewJeans)出道营销先公开《注意》的音乐录像,再连续展开其他内容。", 4), - ("kpop-redvelvet-concept", "韩流", "红贝贝(Red Velvet)的团体概念常被拆成哪两面?", "红与丝绒", ("月与太阳", "甜与苦", "黑与粉"), "红贝贝(Red Velvet)常用红与丝绒两面概念区分明亮和成熟风格。", 3), - ("kpop-music-show-encore", "韩流", "韩国打歌节目获一位后,现场常见的收尾舞台叫什么?", "安可舞台", ("预录舞台", "回归预告", "签售舞台"), "一位后的安可舞台经常被粉丝拿来观察现场唱功和团体互动。", 3), - ("kpop-lightstick-name", "韩流", "韩流粉丝应援棒在中文语境里通常被简称为什么?", "应援棒", ("打歌棒", "投票棒", "站位棒"), "应援棒是演唱会和打歌现场常见的视觉应援道具。", 2), - ("kpop-photocard", "韩流", "专辑小卡随机配置最直接影响粉丝哪种行为?", "拆专收卡", ("直播投票", "现场排队", "舞台换位"), "随机小卡会驱动拆专、交换和收集,是韩流专辑消费的重要机制。", 4), - ("kpop-fanchant", "韩流", "打歌舞台里粉丝按固定词句一起喊的应援内容通常叫?", "应援词", ("站位表", "预告词", "抽选词"), "应援词会配合歌曲段落出现,是现场文化的一部分。", 3), - ("genshin-superconduct", "原神", "超导反应最核心的战斗收益是什么?", "降低物理抗性", ("降低水元素抗性", "生成护盾", "恢复元素能量"), "超导会降低敌人物理抗性,主要服务物理伤害打法。", 4), - ("genshin-dendro-core-limit", "原神", "场上草原核数量超过上限时,通常会怎样?", "较早生成的草原核会提前爆炸", ("所有草原核消失", "刷新持续时间", "变成晶片"), "草原核有场上数量限制,超过后旧核会提前触发爆炸。", 5), - ("genshin-hyperbloom", "原神", "超绽放由什么元素触发草原核产生?", "雷元素", ("火元素", "冰元素", "风元素"), "雷元素命中草原核会触发超绽放,生成追踪性质的伤害。", 3), - ("genshin-burgeon", "原神", "烈绽放由什么元素触发草原核产生?", "火元素", ("雷元素", "岩元素", "冰元素"), "火元素命中草原核会触发烈绽放,造成范围草元素伤害。", 3), - ("genshin-crystallize", "原神", "结晶反应掉落的晶片主要提供什么?", "对应元素护盾", ("元素能量", "暴击率加成", "治疗效果"), "岩元素和可结晶元素反应会产生晶片,拾取后形成对应元素护盾。", 3), - ("genshin-resin-cap", "原神", "当前原粹树脂自然恢复上限是多少?", "二百", ("一百六十", "一百八十", "二百四十"), "原粹树脂上限后来提高到二百,日常体力规划随之变化。", 4), - ("lovelive-snow-halation", "缪斯学园偶像", "《雪色光晕》演出中,观众应援色由白转橙通常对应谁的独唱段?", "高坂穗乃果", ("南小鸟", "园田海未", "绚濑绘里"), "白转橙是《雪色光晕》现场文化中非常有名的段落。", 4), - ("lovelive-token-event", "缪斯学园偶像", "学园偶像祭传统代币活动里,打活动曲通常要消耗什么?", "活动道具", ("友情点", "练习贴纸", "社员证"), "代币活动先刷普通歌曲拿活动道具,再消耗道具打活动曲。", 4), - ("lovelive-score-match", "缪斯学园偶像", "学园偶像祭的分数赛活动核心更接近哪种玩法?", "多人按分数排名", ("单人剧情解谜", "抽卡保底挑战", "社团领地战"), "分数赛会把玩家匹配到一起,按歌曲得分排位结算。", 4), - ("lovelive-medley", "缪斯学园偶像", "学园偶像祭的串烧嘉年华一次最多连续打几首歌?", "三首", ("两首", "四首", "五首"), "串烧嘉年华可以选择连续打一到三首歌,体力和稳定性都更吃紧。", 4), - ("lovelive-bibi", "缪斯学园偶像", "小队碧碧(BiBi)由哪组成员组成?", "绘里、真姬、妮可", ("穗乃果、小鸟、花阳", "海未、凛、希", "小鸟、海未、绘里"), "碧碧(BiBi)成员是绚濑绘里、西木野真姬、矢泽妮可。", 3), - ("lovelive-printemps", "缪斯学园偶像", "小队春色少女(Printemps)由哪组成员组成?", "穗乃果、小鸟、花阳", ("绘里、真姬、妮可", "海未、凛、希", "穗乃果、凛、妮可"), "春色少女(Printemps)成员是高坂穗乃果、南小鸟、小泉花阳。", 3), - ("minecraft-beacon-base", "我的世界", "满级信标金字塔如果全部铺满,一共需要多少个方块?", "一百六十四个", ("一百二十八个", "一百四十四个", "一百九十二个"), "满级信标底座为九乘九、七乘七、五乘五、三乘三,合计一百六十四个方块。", 5), - ("minecraft-bookshelves", "我的世界", "附魔台达到三十级附魔通常最多需要多少个书架参与加成?", "十五个", ("十二个", "十八个", "二十个"), "十五个有效书架即可提供三十级附魔所需的书架加成。", 3), - ("minecraft-nether-portal-min", "我的世界", "不算四个角,最小下界传送门框架需要多少个黑曜石?", "十个", ("八个", "十二个", "十四个"), "最小传送门外框是四乘五,四角可省,所以最少十个黑曜石。", 4), - ("minecraft-respawn-anchor", "我的世界", "重生锚在哪个维度用于设定重生点?", "下界", ("主世界", "末地", "深暗之域"), "重生锚用于下界重生,在主世界或末地使用会爆炸。", 4), - ("minecraft-comparator-composter", "我的世界", "堆肥桶满级时,比较器读取到的红石信号强度是多少?", "八", ("七", "九", "十五"), "堆肥桶从空到满对应零到八级比较器信号。", 5), - ("minecraft-sponge-nether", "我的世界", "湿海绵放在下界时会发生什么?", "立刻变干", ("变成岩浆", "掉落成物品", "无法放置"), "湿海绵在下界会立即干燥,这是处理湿海绵的冷门方法。", 4), -) -TRIVIA_TEMPLATES = ( - "【{domain} 冷知识】{prompt}", - "塔菲冷门题:{prompt}", - "不查资料猜一下:{prompt}", - "这个有点偏:{prompt}", - "冷知识选项题:{prompt}", - "老玩家小测:{prompt}", - "边角料问题:{prompt}", - "五分钟抢答:{prompt}", -) +TRIVIA_DATA_RESOURCE = "data/trivia_questions.json" +TRIVIA_TARGET_QUESTION_COUNT = 500 TRIVIA_OPTION_LABELS = ("A", "B", "C", "D") @@ -451,22 +388,66 @@ def supported_trivia_topics_message() -> str: return "支持主题:" + "、".join(TRIVIA_TOPIC_NAMES) +@lru_cache(maxsize=1) +def trivia_data_payload() -> dict[str, object]: + resource = resources.files(__package__ or "jwebot_kernel").joinpath( + TRIVIA_DATA_RESOURCE + ) + with resource.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict): + raise RuntimeError("trivia data payload must be a JSON object") + return payload + + @lru_cache(maxsize=1) def trivia_question_bank() -> tuple[dict[str, object], ...]: - questions: list[dict[str, object]] = [ - { - **question, - "base_id": str(question["id"]), - "topic": canonical_trivia_topic(question.get("domain")) or str(question["domain"]), - "rarity": int(question.get("rarity", 4)), - "reward": trivia_reward(int(question.get("rarity", 4))), - } - for question in TRIVIA_QUESTIONS - ] - for base_id, domain, prompt, answer, distractors, explanation, rarity in TRIVIA_FACTS: + payload = trivia_data_payload() + templates = tuple( + normalize_text(template) + for template in payload.get("templates", []) + if normalize_text(template) + ) + facts = payload.get("facts", []) + if not templates or not isinstance(facts, list): + raise RuntimeError("trivia data requires templates and facts") + + questions: list[dict[str, object]] = [] + seen_base_ids: set[str] = set() + for index, raw in enumerate(facts, start=1): + if not isinstance(raw, dict): + raise RuntimeError(f"trivia fact #{index} must be an object") + base_id = bounded_text(raw.get("id"), limit=80) + domain = bounded_text(raw.get("domain"), limit=80) + prompt = bounded_text(raw.get("prompt"), limit=300) + answer = bounded_text(raw.get("answer"), limit=120) + explanation = bounded_text(raw.get("explanation"), limit=500) + raw_distractors = raw.get("distractors", []) + if ( + not base_id + or not domain + or not prompt + or not answer + or base_id in seen_base_ids + or not isinstance(raw_distractors, list) + or len(raw_distractors) != 3 + ): + raise RuntimeError(f"invalid trivia fact: {base_id or index}") + distractors = tuple( + bounded_text(item, limit=120) for item in raw_distractors + ) + if len(set((answer, *distractors))) != 4 or any( + not item for item in distractors + ): + raise RuntimeError(f"invalid trivia options: {base_id}") + seen_base_ids.add(base_id) + rarity = max(1, min(5, int(raw.get("rarity", 4)))) choices = (answer, *distractors) - for variant, template in enumerate(TRIVIA_TEMPLATES): - rotated = choices[variant % len(choices) :] + choices[: variant % len(choices)] + for variant, template in enumerate(templates): + rotated = ( + choices[variant % len(choices) :] + + choices[: variant % len(choices)] + ) answer_label = TRIVIA_OPTION_LABELS[rotated.index(answer)] questions.append( { @@ -482,6 +463,11 @@ def trivia_question_bank() -> tuple[dict[str, object], ...]: "reward": trivia_reward(rarity), } ) + if len(questions) != TRIVIA_TARGET_QUESTION_COUNT: + raise RuntimeError( + "trivia data expanded to " + f"{len(questions)} questions, expected {TRIVIA_TARGET_QUESTION_COUNT}" + ) return tuple(questions) ALIAS_IDENTITY_QUESTION = re.compile( r"(?:谁(?:啊|呀)?|哪(?:位|个|个人)|什么人|叫(?:什么|啥)|全名)", @@ -878,7 +864,7 @@ def thread_topic_label(route: str, reason: str) -> str: if "lottery" in reason: return "彩票" if "draw" in reason: - return "抽奖" + return "许愿池" if "steam" in reason: return "Steam 状态" if route == "broadcast": @@ -5563,6 +5549,12 @@ class Ledger: stake=bet, payout=payout, ) + record_blackjack_result( + account, + outcome=outcome, + stake=bet, + payout=payout, + ) achievements = blackjack_achievement_lines( account, outcome=outcome, @@ -6500,6 +6492,136 @@ class Ledger: "game_counts": dict(account["game_counts"]), } + def player_blackjack_stats(self, group: str) -> dict[str, Any]: + group = bounded_text(group, limit=256) + self.seed_person_aliases(group) + with self.lock: + account_rows = [ + dict(row) + for row in self.connection.execute( + """ + SELECT person_key, state_json + FROM player_accounts + WHERE group_fingerprint = ? + """, + (group,), + ).fetchall() + ] + alias_rows = [ + dict(row) + for row in self.connection.execute( + """ + SELECT DISTINCT canonical_name + FROM person_aliases + WHERE group_fingerprint = ? + """, + (group,), + ).fetchall() + ] + profile_rows = [ + dict(row) + for row in self.connection.execute( + """ + SELECT person_key, display_name + FROM person_profiles + WHERE group_fingerprint = ? + """, + (group,), + ).fetchall() + ] + observation_rows = [ + dict(row) + for row in self.connection.execute( + """ + SELECT person_key, display_name + FROM person_observations + WHERE group_fingerprint = ? + ORDER BY observed_at DESC, created_at DESC + """, + (group,), + ).fetchall() + ] + labels: dict[str, str] = {} + for row in alias_rows: + canonical = bounded_text(row["canonical_name"], limit=120) + if canonical: + labels[person_scope_key(group, canonical)] = person_display_label( + canonical + ) + for row in profile_rows: + labels.setdefault( + str(row["person_key"]), + person_display_label(row["display_name"]), + ) + for row in observation_rows: + labels.setdefault( + str(row["person_key"]), + person_display_label(row["display_name"]), + ) + + players: list[dict[str, Any]] = [] + total_wins = 0 + total_losses = 0 + total_ties = 0 + total_stake = 0 + total_payout = 0 + for row in account_rows: + person_key = str(row["person_key"]) + try: + account = json.loads(str(row["state_json"])) + except json.JSONDecodeError as error: + raise LocalCommandError("摩拉账户状态无效") from error + if not isinstance(account, dict): + raise LocalCommandError("摩拉账户状态无效") + normalize_player_account(account) + stats = account["blackjack_stats"] + hands = int(stats["hands"]) + if hands <= 0: + continue + wins = int(stats["wins"]) + losses = int(stats["losses"]) + ties = int(stats["ties"]) + stake = int(stats["stake"]) + payout = int(stats["payout"]) + total_wins += wins + total_losses += losses + total_ties += ties + total_stake += stake + total_payout += payout + players.append( + { + "display_name": labels.get( + person_key, + f"玩家 {person_key[:8]}", + ), + "hands": hands, + "wins": wins, + "losses": losses, + "ties": ties, + "stake": stake, + "payout": payout, + "net": payout - stake, + } + ) + players.sort( + key=lambda item: ( + -int(item["wins"]) / int(item["hands"]), + -int(item["hands"]), + str(item["display_name"]).casefold(), + ) + ) + hands = total_wins + total_losses + total_ties + return { + "hands": hands, + "taffy_wins": total_losses, + "taffy_losses": total_wins, + "ties": total_ties, + "stake": total_stake, + "payout": total_payout, + "taffy_net": total_stake - total_payout, + "players": players, + } + def player_coin_ranking( self, group: str, @@ -7295,6 +7417,83 @@ class Ledger: self.connection.execute("ROLLBACK") raise + def apply_player_local_game_action( + self, + group: str, + sender: str, + event_id: str, + command: str, + game_key: str, + action: Callable[[dict[str, Any], dict[str, Any]], str], + ) -> str: + """Commit one player account and one group-local game state together.""" + 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 + account = self._load_player_account(group, person_key) + row = self.connection.execute( + """ + SELECT state_json + FROM local_games + WHERE group_fingerprint = ? AND game_key = ? + """, + (group, game_key), + ).fetchone() + if row is None: + game_state: dict[str, Any] = {} + else: + try: + raw_state = json.loads(str(row["state_json"])) + except json.JSONDecodeError as error: + raise LocalCommandError("本地游戏状态无效") from error + if not isinstance(raw_state, dict): + raise LocalCommandError("本地游戏状态无效") + game_state = raw_state + response = action(account, game_state) + timestamp = utc_now() + self._save_player_account( + group, person_key, account, timestamp + ) + self.connection.execute( + """ + INSERT INTO local_games ( + group_fingerprint, game_key, state_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(group_fingerprint, game_key) DO UPDATE + SET state_json = excluded.state_json, + updated_at = excluded.updated_at + """, + ( + group, + game_key, + canonical_json(game_state), + timestamp, + timestamp, + ), + ) + 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 _autochess_action_replay( self, group: str, @@ -7770,25 +7969,63 @@ class HourlyBroadcastEngine: message=message, ) - def trivia_recent_base_ids(self, group: str) -> list[str]: + def trivia_history_state(self, group: str) -> dict[str, object]: state = self.ledger.local_game(group, TRIVIA_HISTORY_KEY) if not state: - return [] + return {"recent_base_ids": [], "ask_counts": {}} raw = state.get("recent_base_ids", []) - if not isinstance(raw, list): - return [] - return [bounded_text(item, limit=80) for item in raw if item][-TRIVIA_RECENT_BASE_LIMIT:] + recent = ( + [bounded_text(item, limit=80) for item in raw if item] + if isinstance(raw, list) + else [] + ) + raw_counts = state.get("ask_counts", {}) + counts: dict[str, int] = {} + if isinstance(raw_counts, Mapping): + for key, value in raw_counts.items(): + base_id = bounded_text(key, limit=80) + if not base_id: + continue + try: + count = int(value) + except (TypeError, ValueError): + continue + counts[base_id] = max(0, count) + return { + "recent_base_ids": recent[-TRIVIA_RECENT_BASE_LIMIT:], + "ask_counts": counts, + } + + def trivia_recent_base_ids(self, group: str) -> list[str]: + state = self.trivia_history_state(group) + raw = state.get("recent_base_ids", []) + return list(raw) if isinstance(raw, list) else [] + + def trivia_ask_counts(self, group: str) -> dict[str, int]: + state = self.trivia_history_state(group) + raw = state.get("ask_counts", {}) + return dict(raw) if isinstance(raw, dict) else {} def remember_trivia_question(self, group: str, question: dict[str, object]) -> None: base_id = bounded_text(question.get("base_id") or question.get("id"), limit=80) if not base_id: return - recent = [item for item in self.trivia_recent_base_ids(group) if item != base_id] + recent = [ + item + for item in self.trivia_recent_base_ids(group) + if item != base_id + ] recent.append(base_id) + counts = self.trivia_ask_counts(group) + counts[base_id] = counts.get(base_id, 0) + 1 self.ledger.save_local_game( group, TRIVIA_HISTORY_KEY, - {"recent_base_ids": recent[-TRIVIA_RECENT_BASE_LIMIT:]}, + { + "recent_base_ids": recent[-TRIVIA_RECENT_BASE_LIMIT:], + "ask_counts": counts, + "updated_at": utc_now(), + }, ) def select_trivia_question( @@ -7806,17 +8043,33 @@ class HourlyBroadcastEngine: if not bank: raise LocalCommandError("这个主题还没有题喵\n" + supported_trivia_topics_message()) recent = set(self.trivia_recent_base_ids(group)) + ask_counts = self.trivia_ask_counts(group) seed = ( f"{group}|{local_now.date().isoformat()}|" f"{local_now.hour:02d}|trivia|{topic or 'all'}" ) - start = deterministic_int(seed, 0, len(bank) - 1) - for offset in range(len(bank)): - candidate = bank[(start + offset) % len(bank)] - base_id = str(candidate.get("base_id") or candidate.get("id")) - if base_id not in recent: - return candidate - return bank[start] + min_asked = min( + ask_counts.get(str(question.get("base_id") or question.get("id")), 0) + for question in bank + ) + least_asked = tuple( + question + for question in bank + if ask_counts.get( + str(question.get("base_id") or question.get("id")), + 0, + ) + == min_asked + ) + pool = tuple( + question + for question in least_asked + if str(question.get("base_id") or question.get("id")) not in recent + ) or least_asked + selected = dict(pool[deterministic_int(seed, 0, len(pool) - 1)]) + base_id = str(selected.get("base_id") or selected.get("id")) + selected["ask_count"] = ask_counts.get(base_id, 0) + 1 + return selected @staticmethod def render_trivia_message(question: dict[str, object]) -> str: @@ -7826,11 +8079,18 @@ class HourlyBroadcastEngine: rarity = max(1, min(5, int(question.get("rarity", 3)))) reward = max(1, min(500, int(question.get("reward", trivia_reward(rarity))))) topic = normalize_text(question.get("topic") or question.get("domain")) - heading = f"冷门知识问答(冷门度 {rarity}/5,限时5分钟,答对 +{reward} 摩拉)" + try: + ask_count = max(1, int(question.get("ask_count", 1))) + except (TypeError, ValueError): + ask_count = 1 + heading = ( + f"冷门知识问答(冷门度 {rarity}/5," + f"出现次数:{ask_count},限时5分钟,答对 +{reward} 摩拉)" + ) if topic: heading = ( f"冷门知识问答(主题:{topic},冷门度 {rarity}/5," - f"限时5分钟,答对 +{reward} 摩拉)" + f"出现次数:{ask_count},限时5分钟,答对 +{reward} 摩拉)" ) lines = [ heading, @@ -7872,6 +8132,7 @@ class HourlyBroadcastEngine: "explanation": str(question["explanation"]), "rarity": int(question.get("rarity", 3)), "reward": int(question.get("reward", 50)), + "ask_count": int(question.get("ask_count", 1)), "created_at": local_now.timestamp(), "expires_at": expires_at, }, diff --git a/packages/jwebot-official-extensions/pyproject.toml b/packages/jwebot-official-extensions/pyproject.toml index ddbfeda..4ba8a13 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 = "2.0.2b1+1021" +version = "2.0.4b1+1023" description = "Reviewed local command adapters and broker implementation for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==2.0.2b1+1021", + "jwebot-sdk==2.0.4b1+1023", ] [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 21ec58c..327fc21 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py @@ -90,4 +90,4 @@ __all__ = [ "render_feature_list", ] -__version__ = "2.0.2b1+1021" +__version__ = "2.0.4b1+1023" 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 6fafc53..10a9c87 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py @@ -6,8 +6,8 @@ from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'single-player-games' AUTHOR = 'jwebot' DESCRIPTION = '摩拉游戏' -HELP_ENTRIES = ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',) -COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games') +HELP_ENTRIES = ('/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助',) +COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/许愿池', '/许愿', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games') ADAPTER = ServiceCommandAdapter( PROFILE, metadata={"local_extension": PROFILE, "author": AUTHOR}, 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 d43eda6..b384664 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py @@ -74,6 +74,8 @@ LOCAL_COMMAND_ALIASES = { "borrow": "loan", "还款": "repay", "repay": "repay", + "许愿池": "prize_draw", + "许愿": "prize_draw", "抽奖": "prize_draw", "draw": "prize_draw", "彩票": "lottery", @@ -177,7 +179,7 @@ LOCAL_EXTENSIONS = ( author=JWEBOT_AUTHOR, description="摩拉游戏", help_entries=( - "/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助", + "/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助", ), command_kinds=( "wallet", 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 e137b9d..a020b86 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,15 +9,21 @@ from typing import Any INITIAL_COINS = 1000 -MIN_DRAW_BET = 2 -MAX_DRAW_BET = 100 +WISH_POOL_GAME_KEY = "wish_pool" +WISH_POOL_UNIT = 1000 +MIN_WISH_POOL_AMOUNT = 1000 +MAX_WISH_POOL_AMOUNT = 10_000 +WISH_POOL_CONTRIBUTION = 200 +WISH_POOL_PITY_LIMIT = 8 +WISH_POOL_JACKPOT_PERCENT = 20 +WISH_POOL_JACKPOT_CAP = 20_000 MIN_LOTTERY_BET = 1 MAX_LOTTERY_BET = 500 DEFAULT_LOTTERY_BET = 10 MIN_BLACKJACK_BET = 1 -MAX_BLACKJACK_BET = 500 +MAX_BLACKJACK_BET = 2_000 MIN_BLACKJACK_TABLE_BET = 1 -MAX_BLACKJACK_TABLE_BET = 10_000 +MAX_BLACKJACK_TABLE_BET = 2_000 MAX_BLACKJACK_TABLE_PLAYERS = 8 MIN_TRANSFER_AMOUNT = 1 MAX_TRANSFER_AMOUNT = 100_000 @@ -51,7 +57,7 @@ WISH_MORA_TABLE = ( ) WISH_SCAN_TICKET_PERCENT = 10 GAME_LABELS = { - "prize_draw": "抽奖", + "prize_draw": "许愿池", "lottery": "彩票", "blackjack": "21点", } @@ -66,14 +72,14 @@ BLACKJACK_ACTIONS = { "end": "end", } BLACKJACK_START = re.compile( - r"^(?:开始|start)(?P\d{1,3})?$" + r"^(?:开始|start)(?P\d{1,4})?$" ) BLACKJACK_COMBINED_ACTION = re.compile( - r"^(?:开始|start)(?P\d{1,3})?" + r"^(?:开始|start)(?P\d{1,4})?" r"(?:要牌|hit|停牌|stand)$" ) BLACKJACK_TABLE_BET = re.compile( - r"^(?:放|下注|bet)(?P\d{1,5})$", + r"^(?:放|下注|bet)(?P\d{1,4})$", re.IGNORECASE, ) TRANSFER_MARKED = re.compile( @@ -232,6 +238,19 @@ def recent_primogem_event_lines( return lines +def blackjack_outcome_stat(outcome: object) -> str | None: + text = str(outcome or "") + if not text or "取消" in text: + return None + if "平" in text: + return "ties" + if any(token in text for token in ("输", "认输", "失败", "爆")): + return "losses" + if "赢" in text or "获胜" in text: + return "wins" + return None + + def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]: account.setdefault("balance", INITIAL_COINS) account.setdefault("game", None) @@ -245,6 +264,12 @@ def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]: account["scan_boost_tickets"] = ( scan_tickets if type(scan_tickets) is int and scan_tickets >= 0 else 0 ) + wish_pool_pity = account.get("wish_pool_pity", 0) + account["wish_pool_pity"] = ( + wish_pool_pity + if type(wish_pool_pity) is int and wish_pool_pity >= 0 + else 0 + ) blackjack_streak = account.get("blackjack_taffy_win_streak", 0) account["blackjack_taffy_win_streak"] = ( blackjack_streak @@ -289,6 +314,45 @@ def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]: account["game_history"] = [ entry for entry in history if isinstance(entry, dict) ][-GAME_HISTORY_LIMIT:] + blackjack_stats = account.get("blackjack_stats") + if not isinstance(blackjack_stats, dict): + blackjack_stats = { + "wins": 0, + "losses": 0, + "ties": 0, + "stake": 0, + "payout": 0, + } + for entry in account["game_history"]: + if str(entry.get("kind", "")) != "blackjack": + continue + stat = blackjack_outcome_stat(entry.get("outcome")) + if stat is None: + continue + try: + stake = int(entry.get("stake", 0)) + payout = int(entry.get("payout", 0)) + except (TypeError, ValueError): + continue + blackjack_stats[stat] += 1 + blackjack_stats["stake"] += max(0, stake) + blackjack_stats["payout"] += max(0, payout) + else: + blackjack_stats = { + key: ( + int(blackjack_stats.get(key, 0)) + if type(blackjack_stats.get(key, 0)) is int + and int(blackjack_stats.get(key, 0)) >= 0 + else 0 + ) + for key in ("wins", "losses", "ties", "stake", "payout") + } + blackjack_stats["hands"] = ( + blackjack_stats["wins"] + + blackjack_stats["losses"] + + blackjack_stats["ties"] + ) + account["blackjack_stats"] = blackjack_stats events = account.get("coin_events") if not isinstance(events, list): events = [] @@ -364,6 +428,24 @@ def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]: return account +def record_blackjack_result( + account: dict[str, Any], + *, + outcome: str, + stake: int, + payout: int, +) -> None: + stat = blackjack_outcome_stat(outcome) + if stat is None: + return + normalize_player_account(account) + stats = account["blackjack_stats"] + stats[stat] += 1 + stats["hands"] += 1 + stats["stake"] += max(0, int(stake)) + stats["payout"] += max(0, int(payout)) + + def next_game_index(account: dict[str, Any], kind: str) -> int: normalize_player_account(account) counts = account["game_counts"] @@ -525,6 +607,39 @@ class PlayerGames: self.display_label = display_label self.rng = random.SystemRandom() + @staticmethod + def wish_pool_usage() -> str: + return ( + "用法:/许愿池 1000~10000(兼容 /许愿、/抽奖、/draw);" + "金额必须按1000递增,每1000摩拉算1愿" + ) + + @staticmethod + def normalize_wish_pool_state(state: dict[str, Any]) -> dict[str, Any]: + balance = state.get("balance", 0) + state["balance"] = balance if type(balance) is int and balance >= 0 else 0 + total_wishes = state.get("total_wishes", 0) + state["total_wishes"] = ( + total_wishes + if type(total_wishes) is int and total_wishes >= 0 + else 0 + ) + return state + + @staticmethod + def parse_wish_pool_amount(arguments: str) -> int | None: + value = arguments.strip() + if not value: + return 0 + match = re.fullmatch( + r"(?P\d{1,5})(?:\s*(?:摩拉|金币|coin|coins))?", + value, + re.IGNORECASE, + ) + if match is None: + return None + return int(match["amount"]) + @staticmethod def rules() -> str: return ( @@ -538,17 +653,20 @@ class PlayerGames: "(理论期望3600摩拉)并可能获得扫码概率券;" "/扫码加权 人名 在24小时内给TA整点扫码+5权重;" "/Taffy发话 内容 或 /Taffy发话 定时 HH:MM 内容。\n" - "抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n" + "许愿池:/许愿池 1000~10000(兼容 /许愿、/抽奖、/draw)," + "每1000摩拉1愿,可能获得摩拉回响、扫码概率券、" + "Taffy发话额度或原石,8愿无道具后触发保底。\n" "多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];" "/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;" "命中1/2/3个返下注的50%/120%/300%。\n" - "21点先用 /21点 开始 [1~500] 进场,金额是本局下注,默认50;空格可省略。\n" + "21点先用 /21点 开始 [1~2000] 进场,金额是本局下注,默认50;空格可省略。\n" "看到牌后再单独发送 /21点 要牌|停牌|状态|结束" "(/blackjack hit|stand|status|end);" + "/21点 胜率 查看本群所有玩家及Taffy的21点胜率和净收支;" "每人最多一局,10分钟无操作视为认输,结束不退下注。\n" "多人21点:/21点 多人 启动 创建牌桌;/21点 加入;" "主持人 /21点 多人 开始 后按提示 /21点 放200" - "(1~10000);轮到自己 /21点 要牌|停牌。\n" + "(1~2000);轮到自己 /21点 要牌|停牌。\n" "21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。" "以上均含本金;虚拟摩拉不能充值或兑换。" ) @@ -582,10 +700,11 @@ class PlayerGames: 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 == "blackjack": + return self.wish_pool(row, arguments) + if kind == "blackjack": + compacted = re.sub(r"\s+", "", arguments.casefold()) + if compacted in {"胜率", "战绩", "stats", "rate", "rates"}: + return self.blackjack_win_rates(row) table_command = self.parse_blackjack_table_command(arguments) if table_command is not None: table_action, table_bet = table_command @@ -595,7 +714,7 @@ class PlayerGames: table_bet, arguments, ) - compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态" + compacted = compacted or "状态" start = BLACKJACK_START.fullmatch(compacted) if start is not None: action = "start" @@ -613,8 +732,9 @@ class PlayerGames: "/21点 要牌 或 /21点 停牌" ) return ( - "用法:先 /21点 开始 [1~500](金额是本局下注,默认50);" - "开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略" + "用法:先 /21点 开始 [1~2000](金额是本局下注,默认50);" + "开局后再单独发送 /21点 要牌|停牌|状态|结束;" + "/21点 胜率 查看群内战绩;空格可省略" ) group = str(row["group_fingerprint"]) if ( @@ -640,14 +760,12 @@ class PlayerGames: ) else: bet = 0 - 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 的整数摩拉" + return "21点下注必须为 1~2000 的整数摩拉" person = self.ledger.resolve_person( str(row["group_fingerprint"]), str(row["sender"]), @@ -684,6 +802,12 @@ class PlayerGames: stake=int(game["bet"]), payout=0, ) + record_blackjack_result( + account, + outcome="超时认输", + stake=int(game["bet"]), + payout=0, + ) account["game"] = None account["losses"] += 1 account["blackjack_taffy_win_streak"] = 0 @@ -743,39 +867,210 @@ class PlayerGames: index, ) 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 - append_game_record( - account, - kind="prize_draw", - index=index, - outcome=result, - stake=bet, - payout=payout, - summary=f"抽奖结果 {result}", - ) - append_game_coin_event( - account, - kind="prize_draw", - outcome=result, - stake=bet, - payout=payout, - ) - return player_reply( - expired - + f"{result};下注 {bet},返还 {payout}," - f"净变化 {payout - bet:+d};摩拉 {account['balance']}", - index, - ) + raise LocalCommandError("未知本地游戏指令") return self.ledger.apply_player_action( str(row["group_fingerprint"]), str(row["sender"]), str(row["event_id"]), kind + ":" + arguments, execute) + def wish_pool(self, row: dict[str, Any], arguments: str) -> str: + amount = self.parse_wish_pool_amount(arguments) + if amount is None: + return self.wish_pool_usage() + if ( + amount != 0 + and ( + amount < MIN_WISH_POOL_AMOUNT + or amount > MAX_WISH_POOL_AMOUNT + or amount % WISH_POOL_UNIT != 0 + ) + ): + return ( + "许愿池投入必须为 1000~10000 的整数摩拉," + "且按 1000 递增" + ) + group = str(row["group_fingerprint"]) + sender = str(row["sender"]) + event_id = str(row["event_id"]) + person = self.ledger.resolve_person(group, sender) + player_label = self.display_label(person["display_name"]) + + 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 action( + account: dict[str, Any], + pool_state: dict[str, Any], + ) -> str: + normalize_player_account(account) + self.normalize_wish_pool_state(pool_state) + if amount == 0: + return ( + f"{self.wish_pool_usage()};当前池水 {pool_state['balance']};" + f"你的保底 {account['wish_pool_pity']}/{WISH_POOL_PITY_LIMIT}" + ) + if account.get("game"): + return "你已有一局21点,请先 /21点 停牌 或 /21点 结束" + if account["balance"] < amount: + return f"摩拉不足:余额 {account['balance']},本次需要 {amount}" + + wishes = amount // WISH_POOL_UNIT + index = next_game_index(account, "prize_draw") + account["balance"] -= amount + pool_gain = 0 + mora_payout = 0 + jackpot_payout = 0 + scan_tickets = 0 + taffy_credits = 0 + primogems = 0 + dry_wishes = 0 + small_returns = 0 + medium_returns = 0 + big_returns = 0 + pity_hits = 0 + jackpot_hits = 0 + + for _ in range(wishes): + pool_state["balance"] += WISH_POOL_CONTRIBUTION + pool_state["total_wishes"] += 1 + pool_gain += WISH_POOL_CONTRIBUTION + force_pity = account["wish_pool_pity"] >= WISH_POOL_PITY_LIMIT + item_awarded = False + if force_pity: + pity_hits += 1 + gift_roll = self.rng.randrange(100) + if gift_roll < 70: + scan_tickets += 1 + elif gift_roll < 95: + taffy_credits += 1 + else: + primogems += 1 + item_awarded = True + else: + roll = self.rng.randrange(1000) + if roll < 440: + dry_wishes += 1 + elif roll < 660: + small_returns += 1 + mora_payout += self.rng.randrange(200, 601) + elif roll < 780: + medium_returns += 1 + mora_payout += self.rng.randrange(800, 1401) + elif roll < 860: + scan_tickets += 1 + item_awarded = True + elif roll < 920: + taffy_credits += 1 + item_awarded = True + elif roll < 950: + primogems += 1 + item_awarded = True + elif roll < 980: + big_returns += 1 + mora_payout += self.rng.randrange(2000, 3501) + elif roll < 995: + scan_tickets += 1 + taffy_credits += 1 + item_awarded = True + elif roll < 999: + primogems += 2 + item_awarded = True + else: + primogems += 2 + jackpot = min( + pool_state["balance"] * WISH_POOL_JACKPOT_PERCENT + // 100, + WISH_POOL_JACKPOT_CAP, + ) + pool_state["balance"] -= jackpot + jackpot_payout += jackpot + jackpot_hits += 1 + item_awarded = True + if item_awarded: + account["wish_pool_pity"] = 0 + else: + account["wish_pool_pity"] += 1 + + total_mora_payout = mora_payout + jackpot_payout + account["balance"] += total_mora_payout + account["scan_boost_tickets"] += scan_tickets + account["taffy_say_credits"] += taffy_credits + if primogems: + account["primogems"] += primogems + append_primogem_event(account, primogems, "许愿池") + outcome_parts: list[str] = [] + if jackpot_hits: + outcome_parts.append("池底大愿") + if scan_tickets or taffy_credits or primogems or pity_hits: + outcome_parts.append("祝福") + if big_returns: + outcome_parts.append("大回响") + elif medium_returns or small_returns: + outcome_parts.append("回响") + if not outcome_parts: + outcome_parts.append("沉底") + outcome = "、".join(dict.fromkeys(outcome_parts)) + append_game_record( + account, + kind="prize_draw", + index=index, + outcome=outcome, + stake=amount, + payout=total_mora_payout, + summary=( + f"许愿池 {wishes}愿;池水+{pool_gain};" + f"摩拉+{total_mora_payout};扫码券+{scan_tickets};" + f"发话+{taffy_credits};原石+{primogems}" + ), + ) + append_game_coin_event( + account, + kind="prize_draw", + outcome=outcome, + stake=amount, + payout=total_mora_payout, + ) + + result_parts: list[str] = [] + if total_mora_payout: + mora_text = f"摩拉 +{total_mora_payout}" + if jackpot_payout: + mora_text += f"(含池底 {jackpot_payout})" + result_parts.append(mora_text) + if scan_tickets: + result_parts.append(f"扫码概率券 +{scan_tickets} 张") + if taffy_credits: + result_parts.append(f"Taffy发话额度 +{taffy_credits} 次") + if primogems: + result_parts.append(f"原石 +{primogems} 个") + if not result_parts: + result_parts.append("池水起了点波纹,啥也没捞到") + if pity_hits: + result_parts.append(f"触发保底 {pity_hits} 次") + return player_reply( + ( + f"投入 {amount} 摩拉,向许愿池许了 {wishes} 愿;" + f"结果:{','.join(result_parts)};" + f"当前摩拉 {account['balance']}," + f"原石 {account['primogems']} 个," + f"扫码概率券 {account['scan_boost_tickets']} 张," + f"Taffy发话额度 {account['taffy_say_credits']} 次;" + f"池水 {pool_state['balance']};" + f"保底 {account['wish_pool_pity']}/{WISH_POOL_PITY_LIMIT}" + ), + index, + ) + + return self.ledger.apply_player_local_game_action( + group, + sender, + event_id, + "prize_draw:" + str(amount or ""), + WISH_POOL_GAME_KEY, + action, + ) + def primogem_command( self, row: dict[str, Any], @@ -1055,6 +1350,45 @@ class PlayerGames: ) return "\n".join(lines) + @staticmethod + def blackjack_net_text(net: int) -> str: + if net > 0: + return f"净赚 {net} 摩拉" + if net < 0: + return f"净亏 {-net} 摩拉" + return "净赚净亏 0 摩拉" + + def blackjack_win_rates(self, row: dict[str, Any]) -> str: + stats = self.ledger.player_blackjack_stats( + str(row["group_fingerprint"]) + ) + hands = int(stats["hands"]) + if hands == 0: + return "当前群还没有已结算的21点记录" + taffy_wins = int(stats["taffy_wins"]) + taffy_losses = int(stats["taffy_losses"]) + ties = int(stats["ties"]) + taffy_rate = taffy_wins / hands * 100 + lines = [ + f"21点胜率:本群已结算 {hands} 手", + ( + f"Taffy:胜 {taffy_wins} / 负 {taffy_losses} / 平 {ties}," + f"胜率 {taffy_rate:.2f}%;" + f"{self.blackjack_net_text(int(stats['taffy_net']))}" + ), + "玩家:", + ] + for index, player in enumerate(stats["players"], start=1): + player_hands = int(player["hands"]) + win_rate = int(player["wins"]) / player_hands * 100 + lines.append( + f"{index}. {player['display_name']}:" + f"胜 {player['wins']} / 负 {player['losses']} / " + f"平 {player['ties']},胜率 {win_rate:.2f}%;" + f"{self.blackjack_net_text(int(player['net']))}" + ) + return "\n".join(lines) + @staticmethod def parse_blackjack_table_command( arguments: str, @@ -1117,7 +1451,7 @@ class PlayerGames: def blackjack_prompt(player: dict[str, Any]) -> str: return ( f"请{player['display_name']} 放自己的筹码\n" - "输入/21点 放200(1~10000)" + "输入/21点 放200(1~2000)" ) def group_blackjack( @@ -1135,7 +1469,7 @@ class PlayerGames: return ( "多人21点用法:/21点 多人 启动;/21点 加入;" "主持人 /21点 多人 开始;按提示 /21点 放200" - "(1~10000);轮到自己 /21点 要牌 或 /21点 停牌" + "(1~2000);轮到自己 /21点 要牌 或 /21点 停牌" ) if action == "start": return self.ledger.start_group_blackjack( @@ -1181,7 +1515,7 @@ class PlayerGames: ) if action == "bet": if not MIN_BLACKJACK_TABLE_BET <= amount <= MAX_BLACKJACK_TABLE_BET: - return "21点多人牌桌筹码必须为 1~10000 的整数摩拉" + return "21点多人牌桌筹码必须为 1~2000 的整数摩拉" deck = list(range(52)) self.rng.shuffle(deck) return self.ledger.place_group_blackjack_bet( @@ -1868,6 +2202,12 @@ class PlayerGames: stake=bet, payout=0, ) + record_blackjack_result( + account, + outcome="主动认输", + stake=bet, + payout=0, + ) account["game"] = None account["losses"] += 1 account["blackjack_taffy_win_streak"] = 0 @@ -1922,6 +2262,12 @@ class PlayerGames: stake=int(bet), payout=int(payout), ) + record_blackjack_result( + account, + outcome=outcome, + stake=int(bet), + payout=int(payout), + ) achievements = blackjack_achievement_lines( account, outcome=outcome, diff --git a/packages/jwebot-sdk/pyproject.toml b/packages/jwebot-sdk/pyproject.toml index 5138e3e..3c91add 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 = "2.0.2b1+1021" +version = "2.0.4b1+1023" 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 f386c49..55976b0 100644 --- a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py +++ b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py @@ -40,4 +40,4 @@ __all__ = [ "SingleGroupContext", ] -__version__ = "2.0.2b1+1021" +__version__ = "2.0.4b1+1023" diff --git a/pyproject.toml b/pyproject.toml index 98b51d1..683dd43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.jwebot] -release = "beta 2.0.2.1021" +release = "beta 2.0.4.1023" production_deployments_enabled_by_default = false [tool.jwebot.workspace] diff --git a/tests/test_deployment_scripts.py b/tests/test_deployment_scripts.py index f19e04f..010026d 100644 --- a/tests/test_deployment_scripts.py +++ b/tests/test_deployment_scripts.py @@ -62,6 +62,8 @@ class DeploymentScriptTests(unittest.TestCase): "jwebot_config", "jwebot_sdk", "jwebot_kernel", + "jwebot_kernel/data", + "kernel_data_source_dir", "jwebot_official_extensions", "data/cs_autochess", "official_autochess_data_source_dir", diff --git a/tests/test_jwebot_edge.py b/tests/test_jwebot_edge.py index ab8fbc2..f17436b 100644 --- a/tests/test_jwebot_edge.py +++ b/tests/test_jwebot_edge.py @@ -1291,9 +1291,9 @@ class LedgerAndApplicationTests(unittest.TestCase): self.assertEqual(version["route"], "local") self.assertIn("JWeBot 版本", version["text"]) self.assertIn("kernel", version["text"]) - self.assertIn("最新版本:beta 2.0.2.1021", version["text"]) - self.assertIn("更新时间:2026-10-21 18:11:35", version["text"]) - self.assertIn("更新摘要:上下文追问小版本更新完成", version["text"]) + self.assertIn("最新版本:beta 2.0.4.1023", version["text"]) + self.assertIn("更新时间:2026-10-23 16:56:33", version["text"]) + self.assertIn("更新摘要:许愿池和 21 点小版本更新完成", version["text"]) self.assertEqual(debug["route"], "local") self.assertIn("调试埋点", debug["text"]) self.assertEqual(balance["route"], "local") @@ -1574,7 +1574,7 @@ class LedgerAndApplicationTests(unittest.TestCase): def test_trivia_quiz_broadcast_and_answer_rewards_mora(self): bank = MODULE.trivia_question_bank() - self.assertGreater(len(bank), 200) + self.assertEqual(len(bank), 500) display_text = "\n".join( "\n".join( [ @@ -1640,10 +1640,12 @@ class LedgerAndApplicationTests(unittest.TestCase): delivery = self.application.deliver.call_args.args[0] self.assertIn("冷门知识问答", delivery["result_text"]) self.assertIn("限时5分钟", delivery["result_text"]) + self.assertIn("出现次数:1", delivery["result_text"]) self.assertIn("/回答 A", delivery["result_text"]) state = ledger.local_game("group-a", MODULE.TRIVIA_GAME_KEY) self.assertIsNotNone(state) assert state is not None + self.assertEqual(state["ask_count"], 1) state["expires_at"] = time.time() + 300 ledger.save_local_game("group-a", MODULE.TRIVIA_GAME_KEY, state) answer = str(state["answer"]) @@ -1664,6 +1666,49 @@ class LedgerAndApplicationTests(unittest.TestCase): account = ledger._load_player_account("group-a", str(person["person_key"])) self.assertEqual(account["balance"], 1000 + reward) + def test_trivia_quiz_prefers_least_asked_base_id(self): + group = "group-trivia-balance" + topic = "原神" + topic_base_ids = sorted( + { + str(question["base_id"]) + for question in MODULE.trivia_question_bank() + if question["topic"] == topic + } + ) + self.assertGreater(len(topic_base_ids), 3) + least_asked = topic_base_ids[2] + ask_counts = {base_id: 4 for base_id in topic_base_ids} + ask_counts[least_asked] = 1 + self.application.ledger.save_local_game( + group, + MODULE.TRIVIA_HISTORY_KEY, + { + "recent_base_ids": topic_base_ids[: MODULE.TRIVIA_RECENT_BASE_LIMIT], + "ask_counts": ask_counts, + }, + ) + + selected = self.application.clock.select_trivia_question( + group=group, + local_now=MODULE.datetime( + 2026, + 10, + 23, + 13, + 0, + tzinfo=MODULE.BEIJING_TIMEZONE, + ), + topic=topic, + ) + + self.assertEqual(selected["base_id"], least_asked) + self.assertEqual(selected["ask_count"], 2) + self.assertIn( + "出现次数:2", + self.application.clock.render_trivia_message(selected), + ) + def test_trivia_quiz_can_be_started_by_local_command(self): self.application.deepseek.complete = mock.Mock(side_effect=AssertionError) diff --git a/tests/test_release_061.py b/tests/test_release_061.py index 2eec520..eca5917 100644 --- a/tests/test_release_061.py +++ b/tests/test_release_061.py @@ -40,6 +40,7 @@ class Release061Tests(unittest.TestCase): def test_aliases_are_corresponding_and_case_insensitive(self): pairs = [("上下文", "Context"), ("摩拉", "wallet"), ("抽奖", "draw"), + ("许愿池", "draw"), ("许愿", "draw"), ("买彩票", "lottery"), ("21点", "blackjack"), ("人物列表", "people"), ("人物", "person"), ("游戏帮助", "games")] for chinese, english in pairs: @@ -84,46 +85,46 @@ class Release061Tests(unittest.TestCase): 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") + first = self.command("/draw 1000", event_id="draw-1") + replay = self.command("/draw 1000", sender="a1", event_id="draw-1") self.assertEqual(first, replay) - self.assertEqual(self.state("a1")["balance"], 990) + self.assertIn("许愿池", first) + self.assertEqual(self.state("a1")["balance"], 0) 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")) + self.assertIn("摩拉 0", 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)] + self.command("/draw 1000", 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) + self.assertEqual(self.state()["balance"], 0) 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) + self.assertEqual(self.state()["balance"], 0) with self.assertRaises(MODULE.LocalCommandError): - self.command("/draw 100", sender="Bob", event_id="concurrent") + self.command("/draw 1000", 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), "用法|下注") + 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.command("/draw 1000") + self.assertIn("摩拉不足", self.command("/draw 1000")) self.assertEqual(self.state()["balance"], 0) def test_blackjack_is_private_persistent_and_settles_exactly_once(self): @@ -136,7 +137,7 @@ class Release061Tests(unittest.TestCase): self.assertIn("暗牌", start) self.assertEqual(self.state()["balance"], 950) self.assertIn("没有进行中", self.command("/blackjack status", sender="Bob")) - self.assertIn("已有一局", self.command("/draw")) + self.assertIn("已有一局", self.command("/draw 1000")) self.engine.ledger.connection.close() self.engine = MODULE.EdgeApplication(config(self.root)) self.local = self.engine.local diff --git a/tests/test_release_063.py b/tests/test_release_063.py index c6583f2..d420a38 100644 --- a/tests/test_release_063.py +++ b/tests/test_release_063.py @@ -154,7 +154,20 @@ class Release063GameTests(unittest.TestCase): self.assertEqual(self.state()["balance"], 500) self.assertIn("已报名 1 人", self.command("/彩票状态")) - def test_blackjack_uses_1_to_500_and_indexed_history(self): + def test_blackjack_uses_1_to_2000_and_indexed_history(self): + self.command("/摩拉") + account = self.state() + account["balance"] = 2500 + person_key = self.engine.ledger.resolve_person( + "group-a", + "Alice", + )["person_key"] + self.engine.ledger._save_player_account( + "group-a", + person_key, + account, + MODULE.utc_now(), + ) def tied_deck(cards): first = [9, 6, 8, 7] cards[:] = first + [card for card in cards if card not in first] @@ -164,17 +177,23 @@ class Release063GameTests(unittest.TestCase): "shuffle", side_effect=tied_deck, ): - started = self.command("/21点开始500") + started = self.command("/21点开始2000") 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) + self.assertEqual(self.state()["balance"], 2500) history = self.command("/游戏记录") self.assertIn("alice #1:21点 平局", history) - self.assertIn("下注 500,返还 500", history) - self.assertIn("1~500", self.command("/21点开始501")) + self.assertIn("下注 2000,返还 2000", history) + rates = self.command("/21点胜率") + self.assertIn("Taffy:胜 0 / 负 0 / 平 1,胜率 0.00%", rates) + self.assertIn( + "alice:胜 0 / 负 0 / 平 1,胜率 0.00%;净赚净亏 0 摩拉", + rates, + ) + self.assertIn("1~2000", self.command("/21点开始2001")) def test_multiplayer_blackjack_table_guides_turns_and_settles(self): self.assertIn("黑杰克Blackjack 21点牌桌", self.command("/21点 多人 启动")) @@ -225,26 +244,54 @@ class Release063GameTests(unittest.TestCase): "bob #1:21点 你输了", self.command("/游戏记录", sender="Bob"), ) + rates = self.command("/21点 胜率") + self.assertIn("本群已结算 2 手", rates) + self.assertIn( + "Taffy:胜 1 / 负 1 / 平 0,胜率 50.00%;净亏 100 摩拉", + rates, + ) + self.assertIn( + "alice:胜 1 / 负 0 / 平 0,胜率 100.00%;净赚 200 摩拉", + rates, + ) + self.assertIn( + "bob:胜 0 / 负 1 / 平 0,胜率 0.00%;净亏 100 摩拉", + rates, + ) self.assertIn("+200:21点 获胜", self.command("/金币")) self.assertIn( "-100:21点 失败", self.command("/金币", sender="Bob"), ) - def test_prize_draw_has_indexed_history(self): + def test_wish_pool_has_indexed_history(self): + self.command("/摩拉") + account = self.state() + account["balance"] = 2000 + person_key = self.engine.ledger.resolve_person( + "group-a", + "Alice", + )["person_key"] + self.engine.ledger._save_player_account( + "group-a", + person_key, + account, + MODULE.utc_now(), + ) + self.engine.ledger.connection.commit() with mock.patch.object( self.engine.local.players.rng, "randrange", return_value=99, ): - first = self.command("/抽奖10") - second = self.command("/抽奖10") + first = self.command("/许愿1000") + second = self.command("/抽奖1000") 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) + self.assertIn("alice #2:许愿池 沉底", history) + self.assertIn("alice #1:许愿池 沉底", history) def test_transfer_requires_initialized_target_and_replays_once(self): self.assertIn("摩拉 1000", self.command("/摩拉")) @@ -345,21 +392,21 @@ class Release063GameTests(unittest.TestCase): "randrange", return_value=99, ): - self.command("/抽奖10") + self.command("/抽奖1000") with mock.patch.object( self.engine.local.players.rng, "randrange", - return_value=0, + side_effect=[950, 3000], ): - self.command("/抽奖10", sender="Bob") + self.command("/抽奖1000", sender="Bob") stats = self.command("/Taffy摩拉") - self.assertIn("玩家总下注 20,总返还 50", stats) - self.assertIn("Taffy累计倒贴 30 摩拉", stats) + self.assertIn("玩家总下注 2000,总返还 3000", stats) + self.assertIn("Taffy累计倒贴 1000 摩拉", stats) self.assertIn("最大上贡者排行(含负数):", stats) - self.assertIn("alice:上贡 10", stats) - self.assertIn("bob:从Taffy赢走 40", stats) + self.assertIn("alice:上贡 1000", stats) + self.assertIn("bob:从Taffy赢走 2000", stats) self.assertIn("Taffy摩拉人数必须为 1~20", self.command("/Taffy摩拉 0")) def test_open_round_and_history_survive_restart(self): diff --git a/tests/test_release_update.py b/tests/test_release_update.py index d7d7567..86feadd 100644 --- a/tests/test_release_update.py +++ b/tests/test_release_update.py @@ -20,7 +20,7 @@ SPEC.loader.exec_module(MODULE) class ReleaseUpdateTests(unittest.TestCase): - release = "beta 2.0.2.1021" + release = "beta 2.0.4.1023" major_release = "beta 2.0.0.1021" def setUp(self):