committed by
trae-solo-agent
parent
22f5240fa7
commit
08682045d4
@@ -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 点或主动发言。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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点 停牌`。
|
||||
|
||||
## 持久化与并发
|
||||
|
||||
@@ -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 的
|
||||
胜/负/平、胜率和累计净赚净亏;主动认输和超时计负,牌桌取消不计入统计。
|
||||
|
||||
十分钟无有效游戏操作的牌局在下一次账户操作时按认输结束;查询状态
|
||||
不延长有效期。主动结束同样按认输处理,不退下注。其他人可以各自
|
||||
|
||||
@@ -6,7 +6,7 @@ Author: `jwebot`
|
||||
|
||||
Help:
|
||||
|
||||
- `/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助`
|
||||
- `/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助`
|
||||
|
||||
Registered commands:
|
||||
|
||||
@@ -14,6 +14,8 @@ Registered commands:
|
||||
- `/金币`
|
||||
- `/wallet`
|
||||
- `/原石`
|
||||
- `/许愿池`
|
||||
- `/许愿`
|
||||
- `/抽奖`
|
||||
- `/draw`
|
||||
- `/彩票`
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -36,4 +36,4 @@ __all__ = [
|
||||
"write_default_profile",
|
||||
]
|
||||
|
||||
__version__ = "2.0.2b1+1021"
|
||||
__version__ = "2.0.4b1+1023"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -16,4 +16,4 @@ __all__ = [
|
||||
"process_request",
|
||||
]
|
||||
|
||||
__version__ = "2.0.2b1+1021"
|
||||
__version__ = "2.0.4b1+1023"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -107,4 +107,4 @@ __all__ = [
|
||||
"verify_installed_release_signature",
|
||||
]
|
||||
|
||||
__version__ = "2.0.2b1+1021"
|
||||
__version__ = "2.0.4b1+1023"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -90,4 +90,4 @@ __all__ = [
|
||||
"render_feature_list",
|
||||
]
|
||||
|
||||
__version__ = "2.0.2b1+1021"
|
||||
__version__ = "2.0.4b1+1023"
|
||||
|
||||
+2
-2
@@ -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},
|
||||
|
||||
@@ -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",
|
||||
|
||||
+398
-52
@@ -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<bet>\d{1,3})?$"
|
||||
r"^(?:开始|start)(?P<bet>\d{1,4})?$"
|
||||
)
|
||||
BLACKJACK_COMBINED_ACTION = re.compile(
|
||||
r"^(?:开始|start)(?P<bet>\d{1,3})?"
|
||||
r"^(?:开始|start)(?P<bet>\d{1,4})?"
|
||||
r"(?:要牌|hit|停牌|stand)$"
|
||||
)
|
||||
BLACKJACK_TABLE_BET = re.compile(
|
||||
r"^(?:放|下注|bet)(?P<bet>\d{1,5})$",
|
||||
r"^(?:放|下注|bet)(?P<bet>\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<amount>\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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -40,4 +40,4 @@ __all__ = [
|
||||
"SingleGroupContext",
|
||||
]
|
||||
|
||||
__version__ = "2.0.2b1+1021"
|
||||
__version__ = "2.0.4b1+1023"
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+14
-13
@@ -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
|
||||
|
||||
+64
-17
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user