Release beta 2.0.4.1023
check / local-gate (push) Failing after 1m37s

This commit is contained in:
JWeBot Contributors
2026-10-23 17:07:32 +08:00
committed by trae-solo-agent
parent 22f5240fa7
commit 08682045d4
32 changed files with 2286 additions and 233 deletions
+16
View File
@@ -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 点或主动发言。
+11 -9
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
beta 2.0.2.1021
beta 2.0.4.1023
@@ -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
+4 -3
View File
@@ -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. |
+2 -2
View File
@@ -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点 停牌`。
## 持久化与并发
+13 -7
View File
@@ -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__":
+1 -1
View File
@@ -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"
+7 -4
View File
@@ -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"
@@ -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",
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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]
+2
View File
@@ -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",
+49 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+1 -1
View File
@@ -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):