Release beta 1.0.5 primogem economy

This commit is contained in:
JWeBot Contributors
2026-10-16 16:46:44 +08:00
parent 127fea8cd0
commit b1d8e4e8e7
50 changed files with 1615 additions and 88 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## beta 1.0.5.1016 - 2026-10-16 16:30:44
- 新增原石系统:成就可获得原石,并开放祈愿、扫码加权、Taffy发话和原石榜。
- Add primogem account state, primogem movement history, wish records, scan
boost coupons, Taffy speech credits, scheduled speech delivery, and
primogem-source ranking without a SQLite schema migration.
- Award primogems from blackjack natural 21, blackjack 20-to-21 hit challenges,
Taffy blackjack win streaks, and personal consecutive lottery high-hit pairs.
- Extend post-release settlement so this version grants each existing account
both 200 Mora and 1 primogem, and show `/version` update time down to seconds.
## beta 1.0.4.1016 - 2026-10-16 ## beta 1.0.4.1016 - 2026-10-16
- 新增版本更新补偿流程:每次生产更新后向所有既有账户发放 200 摩拉,并在目标群发布更新公告。 - 新增版本更新补偿流程:每次生产更新后向所有既有账户发放 200 摩拉,并在目标群发布更新公告。
+8 -4
View File
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 GUI 发送链路回复。
当前版本:`beta 1.0.4.1016`,更新日期:2026-10-16。 当前版本:`beta 1.0.5.1016`,更新时间:2026-10-16 16:30:44。
1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、 1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、
Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major
@@ -89,6 +89,8 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/彩票 开始|报名|当前状态|开奖|取消(/lottery) /彩票 开始|报名|当前状态|开奖|取消(/lottery)
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 /21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
/21点 多人 启动;/21点 加入;/21点 多人 开始;/21点 放200 /21点 多人 启动;/21点 加入;/21点 多人 开始;/21点 放200
/摩拉(兼 /原石);/原石祈愿;/原石榜;/扫码加权 人名;/Taffy发话 内容
/游戏记录 [页码](/gamehistory)
/游戏记录 [页码](/gamehistory) /游戏记录 [页码](/gamehistory)
/游戏帮助(/games) /游戏帮助(/games)
/自走棋开始|报名|开赛|状态|档案 /自走棋开始|报名|开赛|状态|档案
@@ -190,7 +192,8 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应 部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户 录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户
发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告。 发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告;
本次 `beta 1.0.5.1016` 额外发放 1 原石。
旧 OCR 服务保留安装,但保持禁用。 旧 OCR 服务保留安装,但保持禁用。
### 仓库结构 ### 仓库结构
@@ -265,10 +268,10 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```text
beta 1.0.4.1016 beta 1.0.5.1016
``` ```
Updated: 2026-10-16. Updated: 2026-10-16 16:30:44.
The 1.0 Beta baseline consolidates the stable local command set, person and The 1.0 Beta baseline consolidates the stable local command set, person and
context indexes, Steam presence/playtime queries, virtual coin games, context indexes, Steam presence/playtime queries, virtual coin games,
@@ -457,6 +460,7 @@ SQLite integrity, and adaptive restart-stability gates pass. Legacy OCR units
remain installed but disabled. After cutover, version-idempotent release remain installed but disabled. After cutover, version-idempotent release
settlement grants 200 Mora to every existing account, records the movement, settlement grants 200 Mora to every existing account, records the movement,
and sends the first changelog bullet as the guarded group announcement. and sends the first changelog bullet as the guarded group announcement.
For `beta 1.0.5.1016`, the settlement also grants 1 primogem once.
## Repository Map ## Repository Map
+1 -1
View File
@@ -1 +1 @@
beta 1.0.4.1016 beta 1.0.5.1016
+8 -3
View File
@@ -1,6 +1,6 @@
# JWeBot Command Catalog # JWeBot Command Catalog
Current baseline: `beta 1.0.1.1016`. Current baseline: `beta 1.0.5.1016`.
This catalog records the reviewed commands shipped with the built-in local This catalog records the reviewed commands shipped with the built-in local
extensions. Runtime ownership is frozen at startup from the generated extensions. Runtime ownership is frozen at startup from the generated
@@ -12,7 +12,7 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
|---|---|---| |---|---|---|
| `/功能列表` | `/help` | Show the command list. | | `/功能列表` | `/help` | Show the command list. |
| `/状态` | `/status` | Show local runtime status. | | `/状态` | `/status` | Show local runtime status. |
| `/version` | - | Show installed package versions plus the latest changelog date and summary. | | `/version` | `/版本` | Show installed package versions plus the latest changelog time and summary. |
| `/debug` | - | Show privacy-safe telemetry diagnostics. | | `/debug` | - | Show privacy-safe telemetry diagnostics. |
| `/余额` | - | Show configured direct-provider balance status. | | `/余额` | - | Show configured direct-provider balance status. |
@@ -41,11 +41,16 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
| Command | Alias | Purpose | | Command | Alias | Purpose |
|---|---|---| |---|---|---|
| `/摩拉` | `/金币`, `/wallet` | Initialize or show the current person's Mora account. | | `/摩拉` | `/金币`, `/原石`, `/wallet` | Initialize or show the current person's Mora and primogem account. |
| `/大富翁榜 [人数]` | `/摩拉排行`, `/摩拉排行榜`, `/金币排行`, `/金币排行榜`, `/richlist` | Show the group-scoped Mora leaderboard. | | `/大富翁榜 [人数]` | `/摩拉排行`, `/摩拉排行榜`, `/金币排行`, `/金币排行榜`, `/richlist` | Show the group-scoped Mora leaderboard. |
| `/转账 对方 金额` | `/transfer name amount` | Transfer virtual coins to an initialized group account. | | `/转账 对方 金额` | `/transfer name amount` | Transfer virtual coins to an initialized group account. |
| `/贷款 [金额]` | `/loan amount`, `/借款` | Borrow up to 500 virtual coins. | | `/贷款 [金额]` | `/loan amount`, `/借款` | Borrow up to 500 virtual coins. |
| `/还款 [金额|全部]` | `/repay [amount|all]` | Repay an active virtual coin loan. | | `/还款 [金额|全部]` | `/repay [amount|all]` | Repay an active virtual coin loan. |
| `/原石祈愿` | `/祈愿`, `/wish` | Spend 1 primogem to draw 20-10000 Mora with expected value 3600 Mora, with a chance to gain a scan-boost ticket. |
| `/原石榜 [人数]` | `/primogem榜` | Show the group primogem leaderboard with source details. |
| `/扫码加权 人名` | `/扫码概率 人名` | Spend a scan-boost ticket or 1 primogem to raise that person's hourly scan weight. |
| `/Taffy发话 内容` | `/taffysay content` | Spend Taffy speech credit; 1 primogem converts to 2 credits when needed. |
| `/Taffy发话 定时 HH:MM 内容` | - | Schedule a Taffy speech through the guarded clock delivery queue. |
| `/抽奖 [金额]` | `/draw` | Play a single-player prize draw. | | `/抽奖 [金额]` | `/draw` | Play a single-player prize draw. |
| `/彩票 开始` | `/lottery start` | Open a multiplayer lottery round. | | `/彩票 开始` | `/lottery start` | Open a multiplayer lottery round. |
| `/彩票 报名 数字 金额` | `/lottery join` | Join or update a lottery entry. | | `/彩票 报名 数字 金额` | `/lottery join` | Join or update a lottery entry. |
+19
View File
@@ -0,0 +1,19 @@
# game-history
Author: `jwebot`
游戏记录
Help:
- `/游戏记录 /个人记录 /gamehistory`
Registered commands:
- `/游戏记录`
- `/个人记录`
- `/gamehistory`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `game-history` Kernel service profile
and returns a structured `ReplyPlan`.
@@ -0,0 +1,14 @@
id = "game-history"
author = "jwebot"
description = "游戏记录"
help = ["/游戏记录 /个人记录 /gamehistory"]
version = "1.0.0"
api_version = "1"
entrypoint = "game_history:plugin"
type = "command"
[capabilities]
commands = ["/游戏记录", "/个人记录", "/gamehistory"]
storage = false
network_profiles = []
service_profiles = ["game-history"]
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for game-history."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'game-history'
AUTHOR = 'jwebot'
DESCRIPTION = '游戏记录'
HELP_ENTRIES = ('/游戏记录 /个人记录 /gamehistory',)
COMMANDS = ('/游戏记录', '/个人记录', '/gamehistory')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from game_history import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '游戏记录')
self.assertEqual(HELP_ENTRIES, ('/游戏记录 /个人记录 /gamehistory',))
self.assertIn('/游戏记录', registered)
self.assertEqual(len(registered), 3)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,25 @@
# primogem-economy
Author: `jwebot`
原石系统
Help:
- `/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容`
Registered commands:
- `/原石祈愿`
- `/祈愿`
- `/wish`
- `/原石榜`
- `/primogem榜`
- `/扫码加权`
- `/扫码概率`
- `/Taffy发话`
- `/taffysay`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `primogem-economy` Kernel service profile
and returns a structured `ReplyPlan`.
@@ -0,0 +1,14 @@
id = "primogem-economy"
author = "jwebot"
description = "原石系统"
help = ["/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容"]
version = "1.0.0"
api_version = "1"
entrypoint = "primogem_economy:plugin"
type = "command"
[capabilities]
commands = ["/原石祈愿", "/祈愿", "/wish", "/原石榜", "/primogem榜", "/扫码加权", "/扫码概率", "/Taffy发话", "/taffysay"]
storage = false
network_profiles = []
service_profiles = ["primogem-economy"]
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for primogem-economy."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'primogem-economy'
AUTHOR = 'jwebot'
DESCRIPTION = '原石系统'
HELP_ENTRIES = ('/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容',)
COMMANDS = ('/原石祈愿', '/祈愿', '/wish', '/原石榜', '/primogem榜', '/扫码加权', '/扫码概率', '/Taffy发话', '/taffysay')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from primogem_economy import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '原石系统')
self.assertEqual(HELP_ENTRIES, ('/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容',))
self.assertIn('/原石祈愿', registered)
self.assertEqual(len(registered), 9)
if __name__ == "__main__":
unittest.main()
@@ -6,17 +6,14 @@ Author: `jwebot`
Help: Help:
- `/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory` - `/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助`
- `/抽奖 [金额];/draw`
- `/彩票 开始|报名|状态|开奖|取消;/lottery`
- `/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200`
- `/游戏帮助;/games`
Registered commands: Registered commands:
- `/摩拉` - `/摩拉`
- `/金币` - `/金币`
- `/wallet` - `/wallet`
- `/原石`
- `/抽奖` - `/抽奖`
- `/draw` - `/draw`
- `/彩票` - `/彩票`
@@ -25,9 +22,6 @@ Registered commands:
- `/21点` - `/21点`
- `/二十一点` - `/二十一点`
- `/blackjack` - `/blackjack`
- `/游戏记录`
- `/个人记录`
- `/gamehistory`
- `/游戏帮助` - `/游戏帮助`
- `/games` - `/games`
@@ -1,14 +1,14 @@
id = "single-player-games" id = "single-player-games"
author = "jwebot" author = "jwebot"
description = "摩拉游戏" description = "摩拉游戏"
help = ["/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200", "/游戏帮助;/games"] help = ["/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
entrypoint = "single_player_games:plugin" entrypoint = "single_player_games:plugin"
type = "command" type = "command"
[capabilities] [capabilities]
commands = ["/摩拉", "/金币", "/wallet", "/抽奖", "/draw", "/彩票", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏记录", "/个人记录", "/gamehistory", "/游戏帮助", "/games"] commands = ["/摩拉", "/金币", "/wallet", "/原石", "/抽奖", "/draw", "/彩票", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏帮助", "/games"]
storage = false storage = false
network_profiles = [] network_profiles = []
service_profiles = ["single-player-games"] service_profiles = ["single-player-games"]
@@ -6,8 +6,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games' PROFILE = 'single-player-games'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '摩拉游戏' DESCRIPTION = '摩拉游戏'
HELP_ENTRIES = ('/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games') HELP_ENTRIES = ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',)
COMMANDS = ('/摩拉', '/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
def _handler(context: SingleGroupContext, command: str): def _handler(context: SingleGroupContext, command: str):
@@ -20,9 +20,9 @@ class PluginTests(unittest.TestCase):
] ]
self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '摩拉游戏') self.assertEqual(DESCRIPTION, '摩拉游戏')
self.assertEqual(HELP_ENTRIES, ('/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games')) self.assertEqual(HELP_ENTRIES, ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',))
self.assertIn('/摩拉', registered) self.assertIn('/摩拉', registered)
self.assertEqual(len(registered), 16) self.assertEqual(len(registered), 14)
if __name__ == "__main__": if __name__ == "__main__":
+2 -1
View File
@@ -6,10 +6,11 @@ JWeBot 版本
Help: Help:
- `/version` - `/version /版本`
Registered commands: Registered commands:
- `/版本`
- `/version` - `/version`
The adapter has no direct Ledger, GUI, environment, shell, or network access. The adapter has no direct Ledger, GUI, environment, shell, or network access.
@@ -1,14 +1,14 @@
id = "jwebot-version" id = "jwebot-version"
author = "official" author = "official"
description = "JWeBot 版本" description = "JWeBot 版本"
help = ["/version"] help = ["/version /版本"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
entrypoint = "jwebot_version:plugin" entrypoint = "jwebot_version:plugin"
type = "command" type = "command"
[capabilities] [capabilities]
commands = ["/version"] commands = ["/版本", "/version"]
storage = false storage = false
network_profiles = [] network_profiles = []
service_profiles = ["jwebot-version"] service_profiles = ["jwebot-version"]
@@ -6,8 +6,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'jwebot-version' PROFILE = 'jwebot-version'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = 'JWeBot 版本' DESCRIPTION = 'JWeBot 版本'
HELP_ENTRIES = ('/version',) HELP_ENTRIES = ('/version /版本',)
COMMANDS = ('/version',) COMMANDS = ('/版本', '/version')
def _handler(context: SingleGroupContext, command: str): def _handler(context: SingleGroupContext, command: str):
@@ -20,9 +20,9 @@ class PluginTests(unittest.TestCase):
] ]
self.assertEqual(AUTHOR, 'official') self.assertEqual(AUTHOR, 'official')
self.assertEqual(DESCRIPTION, 'JWeBot 版本') self.assertEqual(DESCRIPTION, 'JWeBot 版本')
self.assertEqual(HELP_ENTRIES, ('/version',)) self.assertEqual(HELP_ENTRIES, ('/version /版本',))
self.assertIn('/version', registered) self.assertIn('/版本', registered)
self.assertEqual(len(registered), 1) self.assertEqual(len(registered), 2)
if __name__ == "__main__": if __name__ == "__main__":
@@ -2,7 +2,7 @@
Author: `official` Author: `official`
查询当前群已配置人物、基本属性和别名 人物索引
Help: Help:
@@ -1,6 +1,6 @@
id = "person-directory" id = "person-directory"
author = "official" author = "official"
description = "查询当前群已配置人物、基本属性和别名" description = "人物索引"
help = ["/人物列表 [页码] /people;/人物 [名称或别名] /person"] help = ["/人物列表 [页码] /people;/人物 [名称或别名] /person"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'person-directory' PROFILE = 'person-directory'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = '查询当前群已配置人物、基本属性和别名' DESCRIPTION = '人物索引'
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',) HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person') COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
@@ -19,7 +19,7 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list for call in context.commands.register.call_args_list
] ]
self.assertEqual(AUTHOR, 'official') self.assertEqual(AUTHOR, 'official')
self.assertEqual(DESCRIPTION, '查询当前群已配置人物、基本属性和别名') self.assertEqual(DESCRIPTION, '人物索引')
self.assertEqual(HELP_ENTRIES, ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)) self.assertEqual(HELP_ENTRIES, ('/人物列表 [页码] /people;/人物 [名称或别名] /person',))
self.assertIn('/人物列表', registered) self.assertIn('/人物列表', registered)
self.assertEqual(len(registered), 6) self.assertEqual(len(registered), 6)
+37 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Apply the mandatory post-release Mora grant and group announcement.""" """Apply the mandatory post-release grant and group announcement."""
from __future__ import annotations from __future__ import annotations
@@ -26,11 +26,15 @@ sys.path[:0] = [
from jwebot_official_extensions import __version__ as OFFICIAL_VERSION # noqa: E402 from jwebot_official_extensions import __version__ as OFFICIAL_VERSION # noqa: E402
from jwebot_official_extensions.player_games import ( # noqa: E402 from jwebot_official_extensions.player_games import ( # noqa: E402
append_coin_event, append_coin_event,
append_primogem_event,
normalize_player_account, normalize_player_account,
) )
COMPENSATION_AMOUNT = 200 COMPENSATION_AMOUNT = 200
PRIMOGEM_COMPENSATION_RELEASES = {
"beta 1.0.5.1016": 1,
}
MAX_BALANCE = 1_000_000_000 MAX_BALANCE = 1_000_000_000
MAX_SUMMARY_CHARS = 300 MAX_SUMMARY_CHARS = 300
MAX_ANNOUNCEMENT_CHARS = 900 MAX_ANNOUNCEMENT_CHARS = 900
@@ -39,7 +43,7 @@ RELEASE_PATTERN = re.compile(
) )
CHANGELOG_HEADING = re.compile( CHANGELOG_HEADING = re.compile(
r"^##\s+(?P<release>beta \d+\.\d+\.\d+\.\d{4})" r"^##\s+(?P<release>beta \d+\.\d+\.\d+\.\d{4})"
r"(?:\s+-\s+\d{4}-\d{2}-\d{2})?\s*$" r"(?:\s+-\s+\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}:\d{2})?)?\s*$"
) )
@@ -119,11 +123,13 @@ def read_changelog_summary(path: Path, release: str) -> str:
def build_announcement(release: str, summary: str) -> str: def build_announcement(release: str, summary: str) -> str:
primogems = primogem_compensation_amount(release)
extra = f",原石 x{primogems}" if primogems else ""
announcement = ( announcement = (
"亲爱的群友:\n" "亲爱的群友:\n"
f"{release} 更新现已完成。\n" f"{release} 更新现已完成。\n"
f"更新内容:{summary}\n" f"更新内容:{summary}\n"
f"更新补偿:摩拉 x{COMPENSATION_AMOUNT}\n" f"更新补偿:摩拉 x{COMPENSATION_AMOUNT}{extra}\n"
"补偿范围:截至本公告发布前已创建摩拉账户的玩家\n" "补偿范围:截至本公告发布前已创建摩拉账户的玩家\n"
"发放方式:补偿已直接到账,无需领取\n" "发放方式:补偿已直接到账,无需领取\n"
"感谢大家对 Taffy 的支持与理解,祝大家游戏愉快!" "感谢大家对 Taffy 的支持与理解,祝大家游戏愉快!"
@@ -137,6 +143,10 @@ def compensation_key(release: str) -> str:
return f"release_compensation:{release}" return f"release_compensation:{release}"
def primogem_compensation_amount(release: str) -> int:
return int(PRIMOGEM_COMPENSATION_RELEASES.get(release, 0))
def announcement_key(release: str, group_fingerprint: str) -> str: def announcement_key(release: str, group_fingerprint: str) -> str:
group_hash = hashlib.sha256(group_fingerprint.encode()).hexdigest() group_hash = hashlib.sha256(group_fingerprint.encode()).hexdigest()
return f"release_announcement:{release}:{group_hash}" return f"release_announcement:{release}:{group_hash}"
@@ -147,6 +157,7 @@ def release_slug(release: str) -> str:
def checked_marker(raw: str, release: str) -> dict[str, Any]: def checked_marker(raw: str, release: str) -> dict[str, Any]:
expected_primogems = primogem_compensation_amount(release)
try: try:
marker = json.loads(raw) marker = json.loads(raw)
except json.JSONDecodeError as error: except json.JSONDecodeError as error:
@@ -155,6 +166,8 @@ def checked_marker(raw: str, release: str) -> dict[str, Any]:
not isinstance(marker, dict) not isinstance(marker, dict)
or marker.get("release") != release or marker.get("release") != release
or marker.get("amount") != COMPENSATION_AMOUNT or marker.get("amount") != COMPENSATION_AMOUNT
or marker.get("mora_amount") not in {None, COMPENSATION_AMOUNT}
or marker.get("primogem_amount") not in {None, expected_primogems}
or not isinstance(marker.get("group_fingerprints"), list) or not isinstance(marker.get("group_fingerprints"), list)
): ):
raise ReleaseUpdateError("release compensation marker does not match") raise ReleaseUpdateError("release compensation marker does not match")
@@ -234,8 +247,11 @@ def apply_compensation(
).fetchall() ).fetchall()
groups = sorted({str(row["group_fingerprint"]) for row in rows}) groups = sorted({str(row["group_fingerprint"]) for row in rows})
old_balance_sum = 0 old_balance_sum = 0
old_primogem_sum = 0
primogem_amount = primogem_compensation_amount(release)
timestamp = isoformat_utc(applied_at) timestamp = isoformat_utc(applied_at)
event_reason = f"{release} 版本更新补偿" event_reason = f"{release} 版本更新补偿"
primogem_reason = f"{release} 版本更新补偿"
for row in rows: for row in rows:
try: try:
state = json.loads(str(row["state_json"])) state = json.loads(str(row["state_json"]))
@@ -251,9 +267,20 @@ def apply_compensation(
): ):
raise ReleaseUpdateError("Mora account balance is invalid") raise ReleaseUpdateError("Mora account balance is invalid")
old_balance_sum += balance old_balance_sum += balance
old_primogem_sum += int(state["primogems"])
state["balance"] = balance + COMPENSATION_AMOUNT state["balance"] = balance + COMPENSATION_AMOUNT
state["primogems"] += primogem_amount
append_coin_event(state, COMPENSATION_AMOUNT, event_reason) append_coin_event(state, COMPENSATION_AMOUNT, event_reason)
state["coin_events"][-1]["created_at"] = applied_at.timestamp() state["coin_events"][-1]["created_at"] = applied_at.timestamp()
if primogem_amount:
append_primogem_event(
state,
primogem_amount,
primogem_reason,
)
state["primogem_events"][-1]["created_at"] = (
applied_at.timestamp()
)
connection.execute( connection.execute(
""" """
UPDATE player_accounts UPDATE player_accounts
@@ -270,12 +297,18 @@ def apply_compensation(
marker = { marker = {
"release": release, "release": release,
"amount": COMPENSATION_AMOUNT, "amount": COMPENSATION_AMOUNT,
"mora_amount": COMPENSATION_AMOUNT,
"primogem_amount": primogem_amount,
"accounts": len(rows), "accounts": len(rows),
"group_fingerprints": groups, "group_fingerprints": groups,
"old_balance_sum": old_balance_sum, "old_balance_sum": old_balance_sum,
"new_balance_sum": ( "new_balance_sum": (
old_balance_sum + COMPENSATION_AMOUNT * len(rows) old_balance_sum + COMPENSATION_AMOUNT * len(rows)
), ),
"old_primogem_sum": old_primogem_sum,
"new_primogem_sum": (
old_primogem_sum + primogem_amount * len(rows)
),
"applied_at": timestamp, "applied_at": timestamp,
"backup": backup_path.name, "backup": backup_path.name,
} }
@@ -523,6 +556,7 @@ def run_release_update(
"release": release, "release": release,
"compensation": compensation_state, "compensation": compensation_state,
"amount_each": COMPENSATION_AMOUNT, "amount_each": COMPENSATION_AMOUNT,
"primogems_each": primogem_compensation_amount(release),
"accounts": int(marker["accounts"]), "accounts": int(marker["accounts"]),
"groups": len(groups), "groups": len(groups),
"announcements_delivered": announcement_result["delivered"], "announcements_delivered": announcement_result["delivered"],
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-config" name = "jwebot-config"
version = "1.0.4b1+1016" version = "1.0.5b1+1016"
description = "Profile-based configuration compiler for JWeBot" description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile", "write_default_profile",
] ]
__version__ = "1.0.4b1+1016" __version__ = "1.0.5b1+1016"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-extension-host" name = "jwebot-extension-host"
version = "1.0.4b1+1016" version = "1.0.5b1+1016"
description = "Single-release, single-invocation process host for JWeBot extensions" description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-sdk==1.0.4b1+1016", "jwebot-sdk==1.0.5b1+1016",
"jwebot-kernel==1.0.4b1+1016", "jwebot-kernel==1.0.5b1+1016",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "1.0.4b1+1016" __version__ = "1.0.5b1+1016"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-kernel" name = "jwebot-kernel"
version = "1.0.4b1+1016" version = "1.0.5b1+1016"
description = "Stable routing and extension authority for JWeBot" description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-config==1.0.4b1+1016", "jwebot-config==1.0.5b1+1016",
"jwebot-sdk==1.0.4b1+1016", "jwebot-sdk==1.0.5b1+1016",
"jwebot-official-extensions==1.0.4b1+1016", "jwebot-official-extensions==1.0.5b1+1016",
"tomli>=2; python_version < '3.11'", "tomli>=2; python_version < '3.11'",
] ]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature", "verify_installed_release_signature",
] ]
__version__ = "1.0.4b1+1016" __version__ = "1.0.5b1+1016"
@@ -74,9 +74,12 @@ from jwebot_official_extensions.player_games import (
append_coin_event, append_coin_event,
append_game_coin_event, append_game_coin_event,
append_game_record, append_game_record,
blackjack_achievement_lines,
hand_value, hand_value,
lottery_achievement_lines,
next_game_index, next_game_index,
normalize_player_account, normalize_player_account,
spend_primogems,
) )
from jwebot_sdk import Event, PlanKind from jwebot_sdk import Event, PlanKind
@@ -2847,13 +2850,54 @@ class Ledger:
timestamp, timestamp,
), ),
) )
if cursor.rowcount == 0 and kind == "taffy_speech":
cursor = self.connection.execute(
"""
UPDATE clock_broadcasts
SET state = 'pending', updated_at = ?
WHERE group_fingerprint = ? AND slot_key = ?
AND kind = 'taffy_speech' AND state = 'scheduled'
""",
(
timestamp,
group_fingerprint,
slot_key,
),
)
return cursor.rowcount == 1 return cursor.rowcount == 1
def due_scheduled_taffy_speech(
self,
group_fingerprint: str,
now: datetime,
) -> dict[str, Any] | None:
group = bounded_text(group_fingerprint, limit=256)
if not group:
return None
local_now = now.astimezone(BEIJING_TIMEZONE)
due_prefix = local_now.strftime("taffy-speech:%Y-%m-%dT%H:%M")
with self.lock:
row = self.connection.execute(
"""
SELECT group_fingerprint, slot_key, local_date, kind, message
FROM clock_broadcasts
WHERE group_fingerprint = ?
AND kind = 'taffy_speech'
AND state = 'scheduled'
AND slot_key <= ?
ORDER BY slot_key
LIMIT 1
""",
(group, due_prefix + ":~"),
).fetchone()
return dict(row) if row else None
def complete_clock_broadcast( def complete_clock_broadcast(
self, self,
group_fingerprint: str, group_fingerprint: str,
slot_key: str, slot_key: str,
) -> bool: ) -> bool:
consume_boost_key = ""
with self.lock, self.connection: with self.lock, self.connection:
row = self.connection.execute( row = self.connection.execute(
""" """
@@ -2894,6 +2938,9 @@ class Ledger:
utc_now(), utc_now(),
), ),
) )
consume_boost_key = person_key
if consume_boost_key:
self.consume_scan_boost(group_fingerprint, consume_boost_key)
return True return True
def fail_clock_broadcast( def fail_clock_broadcast(
@@ -3609,6 +3656,10 @@ class Ledger:
stake=stake, stake=stake,
payout=payout, payout=payout,
) )
achievements = lottery_achievement_lines(
account,
matches=matches,
)
self._save_player_account( self._save_player_account(
group, key, account, timestamp group, key, account, timestamp
) )
@@ -3634,6 +3685,7 @@ class Ledger:
"matches": matches, "matches": matches,
"payout": payout, "payout": payout,
"balance": int(account["balance"]), "balance": int(account["balance"]),
"achievements": achievements,
} }
) )
settled.append(result) settled.append(result)
@@ -3895,6 +3947,18 @@ class Ledger:
stake=bet, stake=bet,
payout=payout, payout=payout,
) )
achievements = blackjack_achievement_lines(
account,
outcome=outcome,
natural_player_21=(
len(player["hand"]) == 2 and player_value == 21
),
extreme_21=(
len(player["hand"]) > 2
and hand_value(player["hand"][:-1]) == 20
and player_value == 21
),
)
self._save_player_account(group, key, account, timestamp) self._save_player_account(group, key, account, timestamp)
self.connection.execute( self.connection.execute(
""" """
@@ -3922,6 +3986,7 @@ class Ledger:
"balance": int(account["balance"]), "balance": int(account["balance"]),
"player_value": player_value, "player_value": player_value,
"dealer_value": dealer_value, "dealer_value": dealer_value,
"achievements": achievements,
} }
) )
settled.append(result) settled.append(result)
@@ -4927,6 +4992,216 @@ class Ledger:
) )
return records[: max(1, min(int(limit), 20))] return records[: max(1, min(int(limit), 20))]
def player_primogem_ranking(
self,
group: str,
*,
limit: int = 10,
) -> list[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, updated_at
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:
person_key = str(row["person_key"])
labels.setdefault(
person_key,
person_display_label(row["display_name"]),
)
for row in observation_rows:
person_key = str(row["person_key"])
labels.setdefault(
person_key,
person_display_label(row["display_name"]),
)
records: list[dict[str, Any]] = []
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)
events = [
event
for event in account["primogem_events"]
if int(event.get("delta", 0)) > 0
]
source_counts: dict[str, int] = {}
for event in events:
reason = bounded_text(event.get("reason"), limit=40) or "原石来源"
source_counts[reason] = source_counts.get(reason, 0) + int(
event["delta"]
)
sources = "、".join(
f"{reason}+{amount}"
for reason, amount in sorted(
source_counts.items(),
key=lambda item: (-item[1], item[0]),
)[:4]
)
if not sources:
sources = "暂无正向来源"
records.append(
{
"display_name": labels.get(
person_key,
f"玩家 {person_key[:8]}",
),
"primogems": int(account["primogems"]),
"sources": sources,
"updated_at": str(row["updated_at"]),
}
)
records = [
record
for record in records
if int(record["primogems"]) > 0 or record["sources"] != "暂无正向来源"
]
records.sort(
key=lambda item: (
-int(item["primogems"]),
str(item["display_name"]).casefold(),
)
)
return records[: max(1, min(int(limit), 20))]
def active_scan_boost_targets(
self,
group: str,
) -> dict[str, dict[str, Any]]:
group = bounded_text(group, limit=256)
boosts: dict[str, dict[str, Any]] = {}
with self.lock:
rows = self.connection.execute(
"""
SELECT state_json
FROM player_accounts
WHERE group_fingerprint = ?
""",
(group,),
).fetchall()
for row in rows:
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)
for boost in account["scan_boosts"]:
target_key = str(boost["target_person_key"])
current = boosts.setdefault(
target_key,
{
"person_key": target_key,
"display_name": str(boost["target_display_name"]),
"extra_weight": 0,
},
)
current["extra_weight"] += int(boost["weight"])
return boosts
def consume_scan_boost(
self,
group: str,
target_person_key: str,
) -> None:
group = bounded_text(group, limit=256)
target_key = bounded_text(target_person_key, limit=128)
if not group or not target_key:
return
timestamp = utc_now()
with self.lock, self.connection:
rows = self.connection.execute(
"""
SELECT person_key, state_json
FROM player_accounts
WHERE group_fingerprint = ?
""",
(group,),
).fetchall()
for row in rows:
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)
changed = False
for boost in account["scan_boosts"]:
if str(boost["target_person_key"]) == target_key:
boost["remaining"] = int(boost["remaining"]) - 1
changed = True
break
if changed:
account["scan_boosts"] = [
boost
for boost in account["scan_boosts"]
if int(boost["remaining"]) > 0
]
self._save_player_account(
group,
str(row["person_key"]),
account,
timestamp,
)
return
def player_taffy_coin_stats( def player_taffy_coin_stats(
self, self,
group: str, group: str,
@@ -5156,6 +5431,97 @@ class Ledger:
self.connection.execute("ROLLBACK") self.connection.execute("ROLLBACK")
raise raise
def player_taffy_say(
self,
group: str,
sender: str,
event_id: str,
command: str,
content: str,
scheduled_at: float | None,
render: Callable[[dict[str, Any]], str],
) -> str:
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
message = bounded_text(f"Taffy:{content}", limit=MAX_REPLY_CHARS)
if not message or message == "Taffy:":
raise LocalCommandError("Taffy发话内容不能为空")
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)
if int(account["taffy_say_credits"]) <= 0:
if int(account["primogems"]) <= 0:
data = {"status": "insufficient"}
else:
spend_primogems(account, 1, "兑换Taffy发话2次")
account["taffy_say_credits"] += 2
if int(account["taffy_say_credits"]) > 0:
account["taffy_say_credits"] -= 1
timestamp = utc_now()
if scheduled_at is None:
data = {
"status": "sent",
"content": content,
"primogems": int(account["primogems"]),
"taffy_say_credits": int(
account["taffy_say_credits"]
),
}
else:
scheduled = datetime.fromtimestamp(
scheduled_at,
BEIJING_TIMEZONE,
)
scheduled_key = scheduled.strftime("%Y-%m-%dT%H:%M")
slot_key = f"taffy-speech:{scheduled_key}:{event_id}"
self.connection.execute(
"""
INSERT OR IGNORE INTO clock_broadcasts (
group_fingerprint, slot_key, local_date, kind,
message, scan_person_key, scan_label, state,
created_at, updated_at
) VALUES (?, ?, ?, 'taffy_speech', ?, '', '',
'scheduled', ?, ?)
""",
(
group,
slot_key,
scheduled.date().isoformat(),
message,
timestamp,
timestamp,
),
)
data = {
"status": "scheduled",
"content": content,
"scheduled_text": scheduled.strftime(
"%m月%d日 %H:%M"
),
"primogems": int(account["primogems"]),
"taffy_say_credits": int(
account["taffy_say_credits"]
),
}
response = render(data)
timestamp = utc_now()
self._save_player_account(group, person_key, account, timestamp)
self._store_player_action(
group, event_id, person_key, command, response, timestamp
)
self.connection.execute("COMMIT")
return response
except BaseException:
self.connection.execute("ROLLBACK")
raise
def apply_player_action( def apply_player_action(
self, group: str, sender: str, event_id: str, command: str, self, group: str, sender: str, event_id: str, command: str,
action: Callable[[dict[str, Any]], str], action: Callable[[dict[str, Any]], str],
@@ -5570,7 +5936,18 @@ class HourlyBroadcastEngine:
if local_now.minute >= CLOCK_GRACE_MINUTES: if local_now.minute >= CLOCK_GRACE_MINUTES:
return None return None
group = group_fingerprint or self.active_group_fingerprint() group = group_fingerprint or self.active_group_fingerprint()
if not group or not self.ledger.is_clock_broadcast_group(group): if not group:
return None
scheduled = self.ledger.due_scheduled_taffy_speech(group, local_now)
if scheduled is not None:
return ClockBroadcast(
group_fingerprint=str(scheduled["group_fingerprint"]),
slot_key=str(scheduled["slot_key"]),
local_date=str(scheduled["local_date"]),
kind=str(scheduled["kind"]),
message=str(scheduled["message"]),
)
if not self.ledger.is_clock_broadcast_group(group):
return None return None
if local_now.hour == 0: if local_now.hour == 0:
previous_day = (local_now - timedelta(days=1)).date() previous_day = (local_now - timedelta(days=1)).date()
@@ -5597,14 +5974,33 @@ class HourlyBroadcastEngine:
) )
local_date = local_now.date().isoformat() local_date = local_now.date().isoformat()
if not CLOCK_SCAN_TARGETS: weighted_targets: list[dict[str, str]] = [
{
"person_key": person_scope_key(group, canonical_name),
"label": scan_label,
}
for canonical_name, scan_label in CLOCK_SCAN_TARGETS
]
boosts = self.ledger.active_scan_boost_targets(group)
for target_key, boost in boosts.items():
weighted_targets.extend(
[
{
"person_key": target_key,
"label": str(boost["display_name"]),
}
]
* max(0, int(boost.get("extra_weight", 0)))
)
if not weighted_targets:
return None return None
target_index = deterministic_int( target_index = deterministic_int(
f"{group}|{local_date}|{local_now.hour:02d}|clock-scan", f"{group}|{local_date}|{local_now.hour:02d}|clock-scan",
0, 0,
len(CLOCK_SCAN_TARGETS) - 1, len(weighted_targets) - 1,
) )
canonical_name, scan_label = CLOCK_SCAN_TARGETS[target_index] target = weighted_targets[target_index]
scan_label = target["label"]
return ClockBroadcast( return ClockBroadcast(
group_fingerprint=group, group_fingerprint=group,
slot_key=f"hourly:{local_date}T{local_now.hour:02d}", slot_key=f"hourly:{local_date}T{local_now.hour:02d}",
@@ -5614,7 +6010,7 @@ class HourlyBroadcastEngine:
f"现在是北京时间 {local_now.hour}:00," f"现在是北京时间 {local_now.hour}:00,"
f"{scan_label} 扫码了。" f"{scan_label} 扫码了。"
), ),
scan_person_key=person_scope_key(group, canonical_name), scan_person_key=target["person_key"],
scan_label=scan_label, scan_label=scan_label,
) )
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-official-extensions" name = "jwebot-official-extensions"
version = "1.0.4b1+1016" version = "1.0.5b1+1016"
description = "Reviewed local command adapters and broker implementation for JWeBot" description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-sdk==1.0.4b1+1016", "jwebot-sdk==1.0.5b1+1016",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -90,4 +90,4 @@ __all__ = [
"render_feature_list", "render_feature_list",
] ]
__version__ = "1.0.4b1+1016" __version__ = "1.0.5b1+1016"
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for game-history."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'game-history'
AUTHOR = 'jwebot'
DESCRIPTION = '游戏记录'
HELP_ENTRIES = ('/游戏记录 /个人记录 /gamehistory',)
COMMANDS = ('/游戏记录', '/个人记录', '/gamehistory')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -6,8 +6,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'jwebot-version' PROFILE = 'jwebot-version'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = 'JWeBot 版本' DESCRIPTION = 'JWeBot 版本'
HELP_ENTRIES = ('/version',) HELP_ENTRIES = ('/version /版本',)
COMMANDS = ('/version',) COMMANDS = ('/版本', '/version')
def _handler(context: SingleGroupContext, command: str): def _handler(context: SingleGroupContext, command: str):
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'person-directory' PROFILE = 'person-directory'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = '查询当前群已配置人物、基本属性和别名' DESCRIPTION = '人物索引'
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',) HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person') COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for primogem-economy."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'primogem-economy'
AUTHOR = 'jwebot'
DESCRIPTION = '原石系统'
HELP_ENTRIES = ('/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容',)
COMMANDS = ('/原石祈愿', '/祈愿', '/wish', '/原石榜', '/primogem榜', '/扫码加权', '/扫码概率', '/Taffy发话', '/taffysay')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -6,8 +6,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games' PROFILE = 'single-player-games'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '摩拉游戏' DESCRIPTION = '摩拉游戏'
HELP_ENTRIES = ('/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games') HELP_ENTRIES = ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',)
COMMANDS = ('/摩拉', '/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
def _handler(context: SingleGroupContext, command: str): def _handler(context: SingleGroupContext, command: str):
@@ -34,6 +34,7 @@ LOCAL_COMMAND_ALIASES = {
"历史搜索": "history_search", "历史搜索": "history_search",
"状态": "local_status", "状态": "local_status",
"status": "server_status", "status": "server_status",
"版本": "jwebot_version",
"version": "jwebot_version", "version": "jwebot_version",
"debug": "telemetry_debug", "debug": "telemetry_debug",
"余额": "deepseek_balance", "余额": "deepseek_balance",
@@ -52,6 +53,16 @@ LOCAL_COMMAND_ALIASES = {
"Taffy摩拉": "taffy_coins", "Taffy摩拉": "taffy_coins",
"Taffy金币": "taffy_coins", "Taffy金币": "taffy_coins",
"taffycoins": "taffy_coins", "taffycoins": "taffy_coins",
"原石": "wallet",
"原石祈愿": "primogem_wish",
"祈愿": "primogem_wish",
"wish": "primogem_wish",
"原石榜": "primogem_ranking",
"primogem榜": "primogem_ranking",
"扫码加权": "scan_boost",
"扫码概率": "scan_boost",
"Taffy发话": "taffy_say",
"taffysay": "taffy_say",
"转账": "transfer", "转账": "transfer",
"transfer": "transfer", "transfer": "transfer",
"贷款": "loan", "贷款": "loan",
@@ -158,21 +169,23 @@ LOCAL_EXTENSIONS = (
author=JWEBOT_AUTHOR, author=JWEBOT_AUTHOR,
description="摩拉游戏", description="摩拉游戏",
help_entries=( help_entries=(
"/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory", "/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助",
"/抽奖 [金额];/draw",
"/彩票 开始|报名|状态|开奖|取消;/lottery",
"/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200",
"/游戏帮助;/games",
), ),
command_kinds=( command_kinds=(
"wallet", "wallet",
"prize_draw", "prize_draw",
"lottery", "lottery",
"blackjack", "blackjack",
"game_history",
"game_rules", "game_rules",
), ),
), ),
LocalExtensionDescriptor(
extension_id="game-history",
author=JWEBOT_AUTHOR,
description="游戏记录",
help_entries=("/游戏记录 /个人记录 /gamehistory",),
command_kinds=("game_history",),
),
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="coin-transfer", extension_id="coin-transfer",
author=JWEBOT_AUTHOR, author=JWEBOT_AUTHOR,
@@ -194,6 +207,20 @@ LOCAL_EXTENSIONS = (
help_entries=("/Taffy摩拉(兼容 /Taffy金币)",), help_entries=("/Taffy摩拉(兼容 /Taffy金币)",),
command_kinds=("taffy_coins",), command_kinds=("taffy_coins",),
), ),
LocalExtensionDescriptor(
extension_id="primogem-economy",
author=JWEBOT_AUTHOR,
description="原石系统",
help_entries=(
"/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容",
),
command_kinds=(
"primogem_wish",
"primogem_ranking",
"scan_boost",
"taffy_say",
),
),
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="coin-loan", extension_id="coin-loan",
author=JWEBOT_AUTHOR, author=JWEBOT_AUTHOR,
@@ -213,7 +240,7 @@ LOCAL_EXTENSIONS = (
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="person-directory", extension_id="person-directory",
author=OFFICIAL_AUTHOR, author=OFFICIAL_AUTHOR,
description="查询当前群已配置人物、基本属性和别名", description="人物索引",
help_entries=("/人物列表 [页码] /people;/人物 [名称或别名] /person",), help_entries=("/人物列表 [页码] /people;/人物 [名称或别名] /person",),
command_kinds=("person_directory", "person_info"), command_kinds=("person_directory", "person_info"),
), ),
@@ -244,7 +271,7 @@ LOCAL_EXTENSIONS = (
extension_id="jwebot-version", extension_id="jwebot-version",
author=OFFICIAL_AUTHOR, author=OFFICIAL_AUTHOR,
description="JWeBot 版本", description="JWeBot 版本",
help_entries=("/version",), help_entries=("/version /版本",),
command_kinds=("jwebot_version",), command_kinds=("jwebot_version",),
), ),
LocalExtensionDescriptor( LocalExtensionDescriptor(
@@ -27,7 +27,9 @@ BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai")
DICE_PATTERN = re.compile(r"^(?:(\d{1,2})[dD](\d{1,3}))?$") DICE_PATTERN = re.compile(r"^(?:(\d{1,2})[dD](\d{1,3}))?$")
RANDOM_NUMBER_PATTERN = re.compile(r"^(-?\d{1,6})\s+(-?\d{1,6})$") RANDOM_NUMBER_PATTERN = re.compile(r"^(-?\d{1,6})\s+(-?\d{1,6})$")
MULTI_REPLY_MARKER = "\x1eJWEBOT_MULTI_REPLY_V1\x1e" MULTI_REPLY_MARKER = "\x1eJWEBOT_MULTI_REPLY_V1\x1e"
CHANGELOG_HEADING = re.compile(r"^##\s+(.+?)(?:\s+-\s+(\d{4}-\d{2}-\d{2}))?\s*$") CHANGELOG_HEADING = re.compile(
r"^##\s+(.+?)(?:\s+-\s+(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}:\d{2})?))?\s*$"
)
class LocalCommandError(RuntimeError): class LocalCommandError(RuntimeError):
@@ -166,6 +168,10 @@ class LocalCommandEngine:
"blackjack", "blackjack",
"game_history", "game_history",
"game_rules", "game_rules",
"primogem_wish",
"primogem_ranking",
"scan_boost",
"taffy_say",
}: }:
return self.reply_cleaner(self.players.handle(row, kind, arguments)) return self.reply_cleaner(self.players.handle(row, kind, arguments))
if kind == "cs_autochess": if kind == "cs_autochess":
@@ -433,6 +439,8 @@ class LocalCommandEngine:
continue continue
version = match.group(1).strip() version = match.group(1).strip()
updated_at = (match.group(2) or "").strip() updated_at = (match.group(2) or "").strip()
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", updated_at):
updated_at = f"{updated_at} 00:00:00"
bullet_parts: list[str] = [] bullet_parts: list[str] = []
collecting = False collecting = False
for item in lines[index + 1 :]: for item in lines[index + 1 :]:
@@ -28,6 +28,26 @@ GAME_HISTORY_LIMIT = 50
GAME_HISTORY_PAGE_SIZE = 5 GAME_HISTORY_PAGE_SIZE = 5
COIN_EVENT_LIMIT = 50 COIN_EVENT_LIMIT = 50
COIN_EVENT_DISPLAY_LIMIT = 10 COIN_EVENT_DISPLAY_LIMIT = 10
PRIMOGEM_MORA_VALUE = 3600
PRIMOGEM_EVENT_LIMIT = 50
PRIMOGEM_EVENT_DISPLAY_LIMIT = 5
WISH_HISTORY_LIMIT = 30
SCAN_BOOST_USES = 24
SCAN_BOOST_WEIGHT = 4
TAFFY_SAY_CREDITS_PER_PRIMOGEM = 2
TAFFY_SAY_MAX_CHARS = 160
WISH_MORA_TABLE = (
(20, 5),
(100, 12),
(500, 11),
(1000, 16),
(1800, 8),
(3600, 13),
(6000, 12),
(8000, 13),
(10000, 10),
)
WISH_SCAN_TICKET_PERCENT = 10
GAME_LABELS = { GAME_LABELS = {
"prize_draw": "抽奖", "prize_draw": "抽奖",
"lottery": "彩票", "lottery": "彩票",
@@ -59,6 +79,12 @@ TRANSFER_MARKED = re.compile(
r"(?:金额|摩拉|金币|coin|coins|:|:)\s*(?P<amount>\d{1,6})$", r"(?:金额|摩拉|金币|coin|coins|:|:)\s*(?P<amount>\d{1,6})$",
re.IGNORECASE, re.IGNORECASE,
) )
TAFFY_SAY_SCHEDULE = re.compile(
r"^(?:定时|预约|schedule)\s+"
r"(?P<time>(?:今天|明天)?\s*\d{1,2}:\d{2})\s+"
r"(?P<content>.+)$",
re.IGNORECASE,
)
def coin_event_reason(kind: str, outcome: str) -> str: def coin_event_reason(kind: str, outcome: str) -> str:
@@ -98,9 +124,141 @@ def append_coin_event(
account["coin_events"] = events[-COIN_EVENT_LIMIT:] account["coin_events"] = events[-COIN_EVENT_LIMIT:]
def append_primogem_event(
account: dict[str, Any],
delta: int,
reason: str,
) -> None:
if delta == 0:
return
events = account.get("primogem_events")
if not isinstance(events, list):
events = []
events = [
event for event in events
if isinstance(event, dict) and type(event.get("delta")) is int
]
events.append(
{
"delta": delta,
"reason": str(reason)[:120],
"balance": int(account.get("primogems", 0)),
"created_at": time.time(),
}
)
account["primogem_events"] = events[-PRIMOGEM_EVENT_LIMIT:]
def grant_primogems(
account: dict[str, Any],
amount: int,
reason: str,
) -> str:
if amount <= 0:
return ""
normalize_player_account(account)
account["primogems"] += amount
append_primogem_event(account, amount, reason)
return (
f"达成成就:{reason}!+{amount} 原石"
f"(价值 {amount * PRIMOGEM_MORA_VALUE} 摩拉);"
f"当前原石 {account['primogems']} 个。"
)
def spend_primogems(
account: dict[str, Any],
amount: int,
reason: str,
) -> None:
normalize_player_account(account)
if amount <= 0:
raise ValueError("原石消耗必须为正数")
if int(account["primogems"]) < amount:
raise ValueError("原石不足")
account["primogems"] -= amount
append_primogem_event(account, -amount, reason)
def append_wish_record(
account: dict[str, Any],
*,
mora: int,
scan_ticket: bool,
) -> None:
records = account.get("wish_history")
if not isinstance(records, list):
records = []
records = [record for record in records if isinstance(record, dict)]
records.append(
{
"mora": mora,
"scan_ticket": scan_ticket,
"created_at": time.time(),
}
)
account["wish_history"] = records[-WISH_HISTORY_LIMIT:]
def weighted_wish_mora(rng: random.Random) -> int:
total = sum(weight for _, weight in WISH_MORA_TABLE)
roll = rng.randrange(total)
cursor = 0
for value, weight in WISH_MORA_TABLE:
cursor += weight
if roll < cursor:
return value
return WISH_MORA_TABLE[-1][0]
def recent_primogem_event_lines(
account: dict[str, Any],
*,
limit: int = PRIMOGEM_EVENT_DISPLAY_LIMIT,
) -> list[str]:
normalize_player_account(account)
events = sorted(
account["primogem_events"],
key=lambda event: float(event.get("created_at", 0) or 0),
reverse=True,
)
lines: list[str] = []
for event in events[:limit]:
delta = int(event["delta"])
reason = str(event.get("reason", "原石变动")).strip() or "原石变动"
lines.append(f"{delta:+d}:{reason}")
return lines
def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]: def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
account.setdefault("balance", INITIAL_COINS) account.setdefault("balance", INITIAL_COINS)
account.setdefault("game", None) account.setdefault("game", None)
primogems = account.get("primogems", 0)
account["primogems"] = primogems if type(primogems) is int and primogems >= 0 else 0
taffy_credits = account.get("taffy_say_credits", 0)
account["taffy_say_credits"] = (
taffy_credits if type(taffy_credits) is int and taffy_credits >= 0 else 0
)
scan_tickets = account.get("scan_boost_tickets", 0)
account["scan_boost_tickets"] = (
scan_tickets if type(scan_tickets) is int and scan_tickets >= 0 else 0
)
blackjack_streak = account.get("blackjack_taffy_win_streak", 0)
account["blackjack_taffy_win_streak"] = (
blackjack_streak
if type(blackjack_streak) is int and blackjack_streak >= 0
else 0
)
lottery_streak = account.get("lottery_three_hit_streak", 0)
account["lottery_three_hit_streak"] = (
lottery_streak if type(lottery_streak) is int and lottery_streak >= 0 else 0
)
previous_lottery_matches = account.get("lottery_previous_matches", -1)
account["lottery_previous_matches"] = (
previous_lottery_matches
if type(previous_lottery_matches) is int and -1 <= previous_lottery_matches <= 3
else -1
)
loan = account.get("loan") loan = account.get("loan")
try: try:
loan_principal = int(loan.get("principal", 0) or 0) if isinstance(loan, dict) else 0 loan_principal = int(loan.get("principal", 0) or 0) if isinstance(loan, dict) else 0
@@ -156,6 +314,48 @@ def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
event for event in events event for event in events
if isinstance(event, dict) and type(event.get("delta")) is int if isinstance(event, dict) and type(event.get("delta")) is int
][-COIN_EVENT_LIMIT:] ][-COIN_EVENT_LIMIT:]
primogem_events = account.get("primogem_events")
if not isinstance(primogem_events, list):
primogem_events = []
account["primogem_events"] = [
event for event in primogem_events
if isinstance(event, dict) and type(event.get("delta")) is int
][-PRIMOGEM_EVENT_LIMIT:]
wish_history = account.get("wish_history")
if not isinstance(wish_history, list):
wish_history = []
account["wish_history"] = [
record for record in wish_history if isinstance(record, dict)
][-WISH_HISTORY_LIMIT:]
boosts = account.get("scan_boosts")
if not isinstance(boosts, list):
boosts = []
normalized_boosts = []
for boost in boosts:
if not isinstance(boost, dict):
continue
target_key = str(boost.get("target_person_key", "")).strip()
target_name = str(boost.get("target_display_name", "")).strip()
remaining = boost.get("remaining", 0)
weight = boost.get("weight", 0)
if (
target_key
and target_name
and type(remaining) is int
and remaining > 0
and type(weight) is int
and weight > 0
):
normalized_boosts.append(
{
"target_person_key": target_key[:128],
"target_display_name": target_name[:120],
"remaining": min(remaining, 10_000),
"weight": min(weight, 100),
"created_at": float(boost.get("created_at", 0) or 0),
}
)
account["scan_boosts"] = normalized_boosts[-20:]
return account return account
@@ -233,6 +433,74 @@ def recent_coin_event_lines(
return lines return lines
def recent_account_event_lines(
account: dict[str, Any],
*,
limit: int = COIN_EVENT_DISPLAY_LIMIT,
) -> list[str]:
normalize_player_account(account)
events: list[dict[str, Any]] = []
for event in account["coin_events"]:
events.append({**event, "unit": "mora"})
for event in account["primogem_events"]:
events.append({**event, "unit": "primogem"})
events.sort(
key=lambda event: float(event.get("created_at", 0) or 0),
reverse=True,
)
lines: list[str] = []
for event in events[:limit]:
delta = int(event["delta"])
reason = str(event.get("reason", "账户变动")).strip() or "账户变动"
if event.get("unit") == "primogem":
lines.append(f"{delta:+d}原石:{reason}")
else:
lines.append(f"{delta:+d}:{reason}")
return lines
def blackjack_achievement_lines(
account: dict[str, Any],
*,
outcome: str,
natural_player_21: bool = False,
extreme_21: bool = False,
) -> list[str]:
normalize_player_account(account)
lines: list[str] = []
if natural_player_21:
lines.append(grant_primogems(account, 1, "21点自然抽到21"))
if extreme_21:
lines.append(grant_primogems(account, 2, "21点极限挑战"))
if "赢" in outcome or "获胜" in outcome:
account["blackjack_taffy_win_streak"] += 1
streak = int(account["blackjack_taffy_win_streak"])
if streak == 3:
lines.append(grant_primogems(account, 1, "对战Taffy 21点3连胜"))
elif streak == 5:
lines.append(grant_primogems(account, 2, "对战Taffy 21点5连胜"))
else:
account["blackjack_taffy_win_streak"] = 0
return [line for line in lines if line]
def lottery_achievement_lines(
account: dict[str, Any],
*,
matches: int,
) -> list[str]:
normalize_player_account(account)
previous = int(account.get("lottery_previous_matches", -1))
lines: list[str] = []
if previous >= 0 and previous + matches >= 5:
account["lottery_previous_matches"] = -1
account["lottery_three_hit_streak"] += 1
lines.append(grant_primogems(account, 2, "彩票三中挑战"))
else:
account["lottery_previous_matches"] = matches
return [line for line in lines if line]
def card_label(card: int) -> str: def card_label(card: int) -> str:
return ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")[card % 13] return ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")[card % 13]
@@ -256,11 +524,15 @@ class PlayerGames:
def rules() -> str: def rules() -> str:
return ( return (
"每人首次使用获 1000 虚拟摩拉;/摩拉 查看余额(兼容 /金币);" "每人首次使用获 1000 虚拟摩拉;/摩拉 查看余额(兼容 /金币);"
"/游戏记录 查看本人记录。\n" "/原石 查看稀有资源;/游戏记录 查看本人记录。\n"
"转账:/转账 对方 金额,或 /transfer name amount;" "转账:/转账 对方 金额,或 /transfer name amount;"
"对方需先用 /摩拉 或 /金币 初始化账户。\n" "对方需先用 /摩拉 或 /金币 初始化账户。\n"
"贷款:/贷款 [1~500] 借入摩拉;/还款 [金额|全部] 归还," "贷款:/贷款 [1~500] 借入摩拉;/还款 [金额|全部] 归还,"
"同一时间最多一笔未还贷款。\n" "同一时间最多一笔未还贷款。\n"
"原石:达成特定成就获得;/原石祈愿 消耗1原石抽20~10000摩拉"
"(理论期望3600摩拉)并可能获得扫码概率券;"
"/扫码加权 人名 提升TA的整点扫码权重;"
"/Taffy发话 内容 或 /Taffy发话 定时 HH:MM 内容。\n"
"抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n" "抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n"
"多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];" "多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];"
"/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;" "/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;"
@@ -284,6 +556,14 @@ class PlayerGames:
return self.rules() return self.rules()
if kind == "game_history": if kind == "game_history":
return self.game_history(row, arguments) return self.game_history(row, arguments)
if kind in {
"primogem",
"primogem_wish",
"primogem_ranking",
"scan_boost",
"taffy_say",
}:
return self.primogem_command(row, kind, arguments)
if kind == "lottery": if kind == "lottery":
return self.group_lottery(row, arguments) return self.group_lottery(row, arguments)
if kind == "coin_ranking": if kind == "coin_ranking":
@@ -401,6 +681,7 @@ class PlayerGames:
) )
account["game"] = None account["game"] = None
account["losses"] += 1 account["losses"] += 1
account["blackjack_taffy_win_streak"] = 0
expired = "上一局已超时,按认输结束;" expired = "上一局已超时,按认输结束;"
if kind == "wallet": if kind == "wallet":
state = "进行中" if account.get("game") else "无进行中的牌局" state = "进行中" if account.get("game") else "无进行中的牌局"
@@ -409,7 +690,10 @@ class PlayerGames:
total = account["wins"] + account["losses"] + account["ties"] total = account["wins"] + account["losses"] + account["ties"]
win_rate = (account["wins"] / total * 100) if total else 0.0 win_rate = (account["wins"] / total * 100) if total else 0.0
lines = [ lines = [
f"{player_label}:{expired}当前摩拉 {account['balance']};{loan_text}", (
f"{player_label}:{expired}当前摩拉 {account['balance']};"
f"原石 {account['primogems']} 个;{loan_text}"
),
( (
f"21点 {state};已结算胜 {account['wins']} / " f"21点 {state};已结算胜 {account['wins']} / "
f"负 {account['losses']} / 平 {account['ties']}," f"负 {account['losses']} / 平 {account['ties']},"
@@ -417,7 +701,7 @@ class PlayerGames:
), ),
"最近10次账户变动:", "最近10次账户变动:",
] ]
lines.extend(recent_coin_event_lines(account) or ["暂无"]) lines.extend(recent_account_event_lines(account) or ["暂无"])
lines.append("/游戏帮助 查看规则") lines.append("/游戏帮助 查看规则")
return "\n".join(lines) return "\n".join(lines)
if kind == "blackjack" and action in {"hit", "stand", "status", "end"}: if kind == "blackjack" and action in {"hit", "stand", "status", "end"}:
@@ -485,6 +769,224 @@ class PlayerGames:
str(row["group_fingerprint"]), str(row["sender"]), str(row["group_fingerprint"]), str(row["sender"]),
str(row["event_id"]), kind + ":" + arguments, execute) str(row["event_id"]), kind + ":" + arguments, execute)
def primogem_command(
self,
row: dict[str, Any],
kind: str,
arguments: str,
) -> str:
group = str(row["group_fingerprint"])
sender = str(row["sender"])
event_id = str(row["event_id"])
arguments = arguments.strip()
person = self.ledger.resolve_person(group, sender)
player_label = self.display_label(person["display_name"])
if kind == "primogem_ranking":
if arguments and not re.fullmatch(r"\d{1,2}", arguments):
return "用法:/原石榜 [人数]"
limit = int(arguments or 10)
if not 1 <= limit <= 20:
return "原石榜人数必须为 1~20"
records = self.ledger.player_primogem_ranking(group, limit=limit)
if not records:
return "当前群还没有原石记录"
lines = ["原石榜:"]
for index, record in enumerate(records, start=1):
lines.append(
f"{index}. {record['display_name']} {record['primogems']} 个;"
f"来源:{record['sources']}"
)
return "\n".join(lines)
if kind == "primogem":
if arguments:
return (
"用法:/原石;/原石祈愿;/原石榜;"
"/扫码加权 人名;/Taffy发话 内容"
)
def show(account: dict[str, Any]) -> str:
normalize_player_account(account)
lines = [
(
f"{player_label}:原石 {account['primogems']} 个"
f"(折合 {account['primogems'] * PRIMOGEM_MORA_VALUE} 摩拉);"
f"扫码概率券 {account['scan_boost_tickets']} 张;"
f"Taffy发话额度 {account['taffy_say_credits']} 次。"
),
"最近原石变动:",
]
lines.extend(recent_primogem_event_lines(account) or ["暂无"])
if account["wish_history"]:
recent = account["wish_history"][-3:]
wish_text = ";".join(
(
f"+{int(record.get('mora', 0))}摩拉"
+ (",扫码券+1" if record.get("scan_ticket") else "")
)
for record in reversed(recent)
)
lines.append(f"最近祈愿:{wish_text}")
return "\n".join(lines)
return self.ledger.apply_player_action(
group, sender, event_id, "primogem:" + arguments, show
)
if kind == "primogem_wish":
if arguments:
return "用法:/原石祈愿,消耗1原石抽20~10000摩拉,可能获得扫码概率券"
def wish(account: dict[str, Any]) -> str:
normalize_player_account(account)
if account["primogems"] < 1:
return f"{player_label}:原石不足;当前原石 {account['primogems']} 个"
spend_primogems(account, 1, "原石祈愿")
mora = weighted_wish_mora(self.rng)
scan_ticket = self.rng.randrange(100) < WISH_SCAN_TICKET_PERCENT
account["balance"] += mora
if scan_ticket:
account["scan_boost_tickets"] += 1
append_coin_event(account, mora, "原石祈愿")
append_wish_record(account, mora=mora, scan_ticket=scan_ticket)
ticket_text = ";额外获得扫码概率券 1 张" if scan_ticket else ""
return (
f"{player_label}:原石祈愿消耗 1 原石,抽到 {mora} 摩拉"
f"{ticket_text};当前摩拉 {account['balance']},"
f"原石 {account['primogems']} 个,"
f"扫码概率券 {account['scan_boost_tickets']} 张"
)
return self.ledger.apply_player_action(
group, sender, event_id, "primogem_wish:" + arguments, wish
)
if kind == "scan_boost":
if not arguments:
return "用法:/扫码加权 人名;优先消耗扫码概率券,没有券时消耗1原石"
target = self.ledger.resolve_person(group, arguments)
target_label = self.display_label(target["display_name"])
def boost(account: dict[str, Any]) -> str:
normalize_player_account(account)
if account["scan_boost_tickets"] > 0:
account["scan_boost_tickets"] -= 1
cost_text = "消耗扫码概率券 1 张"
elif account["primogems"] > 0:
spend_primogems(account, 1, f"扫码加权给 {target_label}")
cost_text = "消耗原石 1 个"
else:
return (
f"{player_label}:没有扫码概率券,原石也不足;"
f"当前原石 {account['primogems']} 个"
)
boosts = [
item
for item in account["scan_boosts"]
if item["target_person_key"] != target["person_key"]
]
boosts.append(
{
"target_person_key": str(target["person_key"]),
"target_display_name": target_label,
"remaining": SCAN_BOOST_USES,
"weight": SCAN_BOOST_WEIGHT,
"created_at": time.time(),
}
)
account["scan_boosts"] = boosts[-20:]
return (
f"{player_label}:已给 {target_label} 增加整点扫码权重;"
f"{cost_text};未来 {SCAN_BOOST_USES} 次送达扫码播报中,"
f"{target_label} 额外 +{SCAN_BOOST_WEIGHT} 权重"
)
return self.ledger.apply_player_action(
group,
sender,
event_id,
"scan_boost:" + arguments,
boost,
)
if kind == "taffy_say":
parsed = self.parse_taffy_say(arguments)
if parsed is None:
return (
"用法:/Taffy发话 内容;"
"/Taffy发话 定时 HH:MM 内容。1原石兑换2次发话额度"
)
content, scheduled_at = parsed
return self.ledger.player_taffy_say(
group,
sender,
event_id,
"taffy_say:" + arguments,
content,
scheduled_at,
lambda data: self.render_taffy_say(data, player_label),
)
raise ValueError(f"unsupported primogem command: {kind}")
@staticmethod
def parse_taffy_say(arguments: str) -> tuple[str, float | None] | None:
value = re.sub(r"\s+", " ", arguments).strip()
if not value:
return None
scheduled_at: float | None = None
match = TAFFY_SAY_SCHEDULE.fullmatch(value)
if match is not None:
content = match["content"].strip()
try:
scheduled_at = PlayerGames.parse_taffy_time(match["time"])
except ValueError:
return None
else:
content = value
if not content or len(content) > TAFFY_SAY_MAX_CHARS:
return None
return content, scheduled_at
@staticmethod
def parse_taffy_time(value: str) -> float:
cleaned = re.sub(r"\s+", "", value)
day_offset = 0
if cleaned.startswith("明天"):
day_offset = 1
cleaned = cleaned[2:]
elif cleaned.startswith("今天"):
cleaned = cleaned[2:]
hour_text, minute_text = cleaned.split(":", 1)
hour = int(hour_text)
minute = int(minute_text)
if not 0 <= hour <= 23 or not 0 <= minute <= 59:
raise ValueError("Taffy发话定时必须是 HH:MM")
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
timezone = ZoneInfo("Asia/Shanghai")
now = datetime.now(timezone)
scheduled = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if day_offset:
scheduled += timedelta(days=day_offset)
elif scheduled <= now:
scheduled += timedelta(days=1)
return scheduled.timestamp()
@staticmethod
def render_taffy_say(data: dict[str, Any], player_label: str) -> str:
if data["status"] == "insufficient":
return f"{player_label}:原石不足,无法兑换Taffy发话额度"
if data["status"] == "scheduled":
return (
f"{player_label}:已安排Taffy在 {data['scheduled_text']} 发话;"
f"剩余额度 {data['taffy_say_credits']} 次,"
f"原石 {data['primogems']} 个"
)
return f"Taffy:{data['content']}"
def coin_ranking(self, row: dict[str, Any], arguments: str) -> str: def coin_ranking(self, row: dict[str, Any], arguments: str) -> str:
value = arguments.strip() value = arguments.strip()
if value and not re.fullmatch(r"\d{1,2}", value): if value and not re.fullmatch(r"\d{1,2}", value):
@@ -940,6 +1442,7 @@ class PlayerGames:
f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};" f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};"
f"摩拉 {player['balance']}" f"摩拉 {player['balance']}"
) )
lines.extend(player.get("achievements") or [])
return "\n".join(lines) return "\n".join(lines)
def render_blackjack_table_cancel(self, data: dict[str, Any]) -> str: def render_blackjack_table_cancel(self, data: dict[str, Any]) -> str:
@@ -1234,6 +1737,7 @@ class PlayerGames:
f"{entry['payout'] - entry['stake']:+d};" f"{entry['payout'] - entry['stake']:+d};"
f"摩拉 {entry['balance']}" f"摩拉 {entry['balance']}"
) )
lines.extend(entry.get("achievements") or [])
return "\n".join(lines) return "\n".join(lines)
return self.ledger.draw_group_lottery( return self.ledger.draw_group_lottery(
@@ -1362,10 +1866,15 @@ class PlayerGames:
) )
account["game"] = None account["game"] = None
account["losses"] += 1 account["losses"] += 1
account["blackjack_taffy_win_streak"] = 0
return f"本人的21点已认输结束,不退下注;摩拉 {account['balance']}" return f"本人的21点已认输结束,不退下注;摩拉 {account['balance']}"
game["updated_at"] = time.time() game["updated_at"] = time.time()
if action == "hit": if action == "hit":
game["player"].append(game["deck"].pop()) before = hand_value(game["player"])
drawn = game["deck"].pop()
game["player"].append(drawn)
if before == 20 and hand_value(game["player"]) == 21:
game["extreme_21"] = True
if hand_value(game["player"]) > 21: if hand_value(game["player"]) > 21:
return self.finish_blackjack(account) return self.finish_blackjack(account)
return self.show_hand(game) return self.show_hand(game)
@@ -1409,7 +1918,19 @@ class PlayerGames:
stake=int(bet), stake=int(bet),
payout=int(payout), payout=int(payout),
) )
achievements = blackjack_achievement_lines(
account,
outcome=outcome,
natural_player_21=bool(natural and player == 21),
extreme_21=bool(game.get("extreme_21") and player == 21),
)
account["game"] = None account["game"] = None
return (f"21点结算:{outcome};你的牌 {' '.join(map(card_label, game['player']))}({player}点)," result = (
f"庄家 {' '.join(map(card_label, game['dealer']))}({dealer}点);" f"21点结算:{outcome};你的牌 {' '.join(map(card_label, game['player']))}"
f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};摩拉 {account['balance']}") f"({player}点),庄家 {' '.join(map(card_label, game['dealer']))}"
f"({dealer}点);下注 {bet},返还 {payout},"
f"净变化 {payout - bet:+d};摩拉 {account['balance']}"
)
if achievements:
result += "\n" + "\n".join(achievements)
return result
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-sdk" name = "jwebot-sdk"
version = "1.0.4b1+1016" version = "1.0.5b1+1016"
description = "Typed, capability-limited extension API for JWeBot plugins" description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [] dependencies = []
@@ -38,4 +38,4 @@ __all__ = [
"SingleGroupContext", "SingleGroupContext",
] ]
__version__ = "1.0.4b1+1016" __version__ = "1.0.5b1+1016"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot] [tool.jwebot]
release = "beta 1.0.4.1016" release = "beta 1.0.5.1016"
production_deployments_enabled_by_default = false production_deployments_enabled_by_default = false
[tool.jwebot.workspace] [tool.jwebot.workspace]
+7 -3
View File
@@ -179,6 +179,10 @@ class RoutingTests(unittest.TestCase):
MODULE.classify_question("/version"), MODULE.classify_question("/version"),
("local", "local_jwebot_version"), ("local", "local_jwebot_version"),
) )
self.assertEqual(
MODULE.classify_question("/版本"),
("local", "local_jwebot_version"),
)
def test_unknown_slash_command_stays_local(self): def test_unknown_slash_command_stays_local(self):
self.assertEqual( self.assertEqual(
@@ -884,9 +888,9 @@ class LedgerAndApplicationTests(unittest.TestCase):
self.assertEqual(version["route"], "local") self.assertEqual(version["route"], "local")
self.assertIn("JWeBot 版本", version["text"]) self.assertIn("JWeBot 版本", version["text"])
self.assertIn("kernel", version["text"]) self.assertIn("kernel", version["text"])
self.assertIn("最新版本:beta 1.0.4.1016", version["text"]) self.assertIn("最新版本:beta 1.0.5.1016", version["text"])
self.assertIn("更新时间:2026-10-16", version["text"]) self.assertIn("更新时间:2026-10-16 16:30:44", version["text"])
self.assertIn("更新摘要:新增版本更新补偿流程", version["text"]) self.assertIn("更新摘要:新增原石系统", version["text"])
self.assertEqual(debug["route"], "local") self.assertEqual(debug["route"], "local")
self.assertIn("调试埋点", debug["text"]) self.assertIn("调试埋点", debug["text"])
self.assertEqual(balance["route"], "local") self.assertEqual(balance["route"], "local")
+8 -1
View File
@@ -83,6 +83,7 @@ class LocalExtensionInventoryTests(unittest.TestCase):
"context-summary": OFFICIAL_AUTHOR, "context-summary": OFFICIAL_AUTHOR,
"person-directory": OFFICIAL_AUTHOR, "person-directory": OFFICIAL_AUTHOR,
"single-player-games": JWEBOT_AUTHOR, "single-player-games": JWEBOT_AUTHOR,
"game-history": JWEBOT_AUTHOR,
"cs-major-autochess": JWEBOT_AUTHOR, "cs-major-autochess": JWEBOT_AUTHOR,
"person-memory": OFFICIAL_AUTHOR, "person-memory": OFFICIAL_AUTHOR,
"server-status": OFFICIAL_AUTHOR, "server-status": OFFICIAL_AUTHOR,
@@ -93,6 +94,7 @@ class LocalExtensionInventoryTests(unittest.TestCase):
"coin-transfer": JWEBOT_AUTHOR, "coin-transfer": JWEBOT_AUTHOR,
"coin-ranking": JWEBOT_AUTHOR, "coin-ranking": JWEBOT_AUTHOR,
"coin-taffy": JWEBOT_AUTHOR, "coin-taffy": JWEBOT_AUTHOR,
"primogem-economy": JWEBOT_AUTHOR,
"coin-loan": JWEBOT_AUTHOR, "coin-loan": JWEBOT_AUTHOR,
"random-number": JWEBOT_AUTHOR, "random-number": JWEBOT_AUTHOR,
"draw-lot": JWEBOT_AUTHOR, "draw-lot": JWEBOT_AUTHOR,
@@ -120,9 +122,14 @@ class LocalExtensionInventoryTests(unittest.TestCase):
"prize_draw", "prize_draw",
"lottery", "lottery",
"blackjack", "blackjack",
"game_history",
"game_rules", "game_rules",
), ),
"primogem-economy": (
"primogem_wish",
"primogem_ranking",
"scan_boost",
"taffy_say",
),
"person-directory": ("person_directory", "person_info"), "person-directory": ("person_directory", "person_info"),
"person-memory": ( "person-memory": (
"remember", "remember",
+2 -2
View File
@@ -270,11 +270,11 @@ class Release063GameTests(unittest.TestCase):
sender="Bob", sender="Bob",
event_id="wallet-after-transfer-b", event_id="wallet-after-transfer-b",
) )
self.assertIn("alice:当前摩拉 750;无贷款;", alice_wallet) self.assertIn("alice:当前摩拉 750;原石 0 个;无贷款;", alice_wallet)
self.assertIn("最近10次账户变动:", alice_wallet) self.assertIn("最近10次账户变动:", alice_wallet)
self.assertIn("-250:转账给 bob", alice_wallet) self.assertIn("-250:转账给 bob", alice_wallet)
self.assertIn("+1000:首次初始化", alice_wallet) self.assertIn("+1000:首次初始化", alice_wallet)
self.assertIn("bob:当前摩拉 1250;无贷款;", bob_wallet) self.assertIn("bob:当前摩拉 1250;原石 0 个;无贷款;", bob_wallet)
self.assertIn("+250:转账 来自 alice", bob_wallet) self.assertIn("+250:转账 来自 alice", bob_wallet)
def test_transfer_rejects_invalid_self_and_insufficient_cases(self): def test_transfer_rejects_invalid_self_and_insufficient_cases(self):
+184
View File
@@ -0,0 +1,184 @@
"""Behavioral coverage for beta 1.0.5 primogem economy."""
from __future__ import annotations
import json
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from unittest import mock
from test_jwebot_edge import MODULE, config
class Release105PrimogemTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.engine = MODULE.EdgeApplication(config(Path(self.temp.name)))
self.counter = 0
def tearDown(self):
self.engine.close()
self.temp.cleanup()
def command(self, question, *, sender="Alice", event_id=None):
self.counter += 1
row = {
"question": question,
"sender": sender,
"group_fingerprint": "group-a",
"event_id": event_id or f"primogem-{self.counter}",
}
return self.engine.local.handle(row, {})
def state(self, sender="Alice"):
key = self.engine.ledger.resolve_person("group-a", sender)["person_key"]
row = self.engine.ledger.connection.execute(
"""
SELECT state_json FROM player_accounts
WHERE group_fingerprint = ? AND person_key = ?
""",
("group-a", key),
).fetchone()
self.assertIsNotNone(row)
return json.loads(row[0])
@staticmethod
def blackjack_deck(*prefix):
used = set(prefix)
return list(prefix) + [card for card in range(52) if card not in used]
def test_natural_blackjack_grants_primogem_and_wish_can_award_scan_ticket(self):
deck = self.blackjack_deck(0, 12, 1, 2)
def shuffle(cards):
cards[:] = deck
with mock.patch.object(self.engine.local.players.rng, "shuffle", shuffle):
result = self.command("/21点 开始 10")
self.assertIn("+1 原石", result)
self.assertEqual(self.state()["primogems"], 1)
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
side_effect=[0, 0],
):
wish = self.command("/原石祈愿")
self.assertIn("抽到 20 摩拉", wish)
self.assertIn("扫码概率券 1 张", wish)
state = self.state()
self.assertEqual(state["primogems"], 0)
self.assertEqual(state["scan_boost_tickets"], 1)
def test_extreme_blackjack_hit_from_20_to_21_grants_two_primogems(self):
prefix = [9, 12, 1, 2]
deck = self.blackjack_deck(*prefix, 0)
deck = deck[:4] + [card for card in deck[4:] if card != 0] + [0]
def shuffle(cards):
cards[:] = deck
with mock.patch.object(self.engine.local.players.rng, "shuffle", shuffle):
self.command("/21点 开始 10")
self.command("/21点 要牌")
result = self.command("/21点 停牌")
self.assertIn("21点极限挑战", result)
self.assertIn("+2 原石", result)
self.assertEqual(self.state()["primogems"], 2)
def test_lottery_two_consecutive_personal_draws_with_five_total_hits(self):
self.command("/彩票开始")
self.command("/彩票34710")
with mock.patch.object(
self.engine.local.players.rng,
"sample",
return_value=[3, 7, 9],
):
self.command("/彩票开奖")
self.command("/彩票开始", event_id="lottery-start-two")
self.command("/彩票34710", event_id="lottery-entry-two")
with mock.patch.object(
self.engine.local.players.rng,
"sample",
return_value=[3, 4, 7],
):
result = self.command("/彩票开奖", event_id="lottery-draw-two")
self.assertIn("彩票三中挑战", result)
self.assertEqual(self.state()["primogems"], 2)
def test_scan_boost_and_taffy_say_spend_primogem_resources(self):
self.command("/原石")
account = self.state()
account["primogems"] = 2
self.engine.ledger._save_player_account(
"group-a",
self.engine.ledger.resolve_person("group-a", "Alice")["person_key"],
account,
MODULE.utc_now(),
)
self.engine.ledger.connection.commit()
boost = self.command("/扫码加权 Bob")
self.assertIn("已给 bob 增加整点扫码权重", boost)
boost_targets = self.engine.ledger.active_scan_boost_targets("group-a")
bob_key = self.engine.ledger.resolve_person("group-a", "Bob")["person_key"]
self.assertIn(bob_key, boost_targets)
speech = self.command("/Taffy发话 今天也要加班吗")
self.assertEqual(speech, "Taffy:今天也要加班吗喵")
state = self.state()
self.assertEqual(state["primogems"], 0)
self.assertEqual(state["taffy_say_credits"], 1)
def test_scheduled_taffy_speech_uses_clock_delivery_queue(self):
self.command("/原石")
account = self.state()
account["primogems"] = 1
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()
scheduled_at = datetime(
2026,
10,
16,
8,
0,
tzinfo=MODULE.BEIJING_TIMEZONE,
)
response = self.engine.ledger.player_taffy_say(
"group-a",
"Alice",
"scheduled-taffy",
"taffy_say:test",
"准点提醒",
scheduled_at.timestamp(),
lambda data: self.engine.local.players.render_taffy_say(data, "alice"),
)
self.assertIn("已安排Taffy", response)
delivered = []
self.engine.deliver = lambda row: delivered.append(row["result_text"])
self.assertTrue(
self.engine.process_clock_once(
now=scheduled_at,
group_fingerprint="group-a",
)
)
self.assertEqual(delivered, ["Taffy:准点提醒"])
if __name__ == "__main__":
unittest.main()
+23 -2
View File
@@ -20,7 +20,7 @@ SPEC.loader.exec_module(MODULE)
class ReleaseUpdateTests(unittest.TestCase): class ReleaseUpdateTests(unittest.TestCase):
release = "beta 1.0.4.1016" release = "beta 1.0.5.1016"
def setUp(self): def setUp(self):
self.temporary = tempfile.TemporaryDirectory() self.temporary = tempfile.TemporaryDirectory()
@@ -111,9 +111,16 @@ class ReleaseUpdateTests(unittest.TestCase):
self.assertEqual(summary, "第一行更新摘要, 同一条摘要可以换行。") self.assertEqual(summary, "第一行更新摘要, 同一条摘要可以换行。")
announcement = MODULE.build_announcement(self.release, summary) announcement = MODULE.build_announcement(self.release, summary)
self.assertIn(f"{self.release} 更新现已完成", announcement) self.assertIn(f"{self.release} 更新现已完成", announcement)
self.assertIn("更新补偿:摩拉 x200", announcement) self.assertIn("更新补偿:摩拉 x200,原石 x1", announcement)
self.assertNotIn("第二条", announcement) self.assertNotIn("第二条", announcement)
def test_patch_release_without_policy_does_not_announce_primogem(self):
release = "beta 1.0.6.1016"
self.assertEqual(MODULE.primogem_compensation_amount(release), 0)
announcement = MODULE.build_announcement(release, "普通小版本更新。")
self.assertIn("更新补偿:摩拉 x200", announcement)
self.assertNotIn("原石 x1", announcement)
def test_release_update_grants_once_and_announces_once(self): def test_release_update_grants_once_and_announces_once(self):
delivered = [] delivered = []
@@ -148,6 +155,7 @@ class ReleaseUpdateTests(unittest.TestCase):
self.assertIn("更新摩拉账户并修复重复回复问题", delivered[0][2]) self.assertIn("更新摩拉账户并修复重复回复问题", delivered[0][2])
self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250]) self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250])
for state in self.account_states(): for state in self.account_states():
self.assertEqual(state["primogems"], 1)
matches = [ matches = [
event event
for event in state["coin_events"] for event in state["coin_events"]
@@ -156,6 +164,17 @@ class ReleaseUpdateTests(unittest.TestCase):
] ]
self.assertEqual(len(matches), 1) self.assertEqual(len(matches), 1)
self.assertEqual(matches[0]["created_at"], applied_at.timestamp()) self.assertEqual(matches[0]["created_at"], applied_at.timestamp())
primogem_matches = [
event
for event in state["primogem_events"]
if event["delta"] == 1
and event["reason"] == f"{self.release} 版本更新补偿"
]
self.assertEqual(len(primogem_matches), 1)
self.assertEqual(
primogem_matches[0]["created_at"],
applied_at.timestamp(),
)
self.assertEqual(self.announcement_marker()["state"], "delivered") self.assertEqual(self.announcement_marker()["state"], "delivered")
self.assertEqual(len(list(self.backups.glob("*.sqlite3"))), 1) self.assertEqual(len(list(self.backups.glob("*.sqlite3"))), 1)
@@ -173,6 +192,7 @@ class ReleaseUpdateTests(unittest.TestCase):
deliverer=uncertain, deliverer=uncertain,
) )
self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250]) self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250])
self.assertEqual([state["primogems"] for state in self.account_states()], [1, 1])
self.assertEqual(self.announcement_marker()["state"], "uncertain") self.assertEqual(self.announcement_marker()["state"], "uncertain")
called = [] called = []
@@ -203,6 +223,7 @@ class ReleaseUpdateTests(unittest.TestCase):
self.assertEqual(result["compensation"], "already_applied") self.assertEqual(result["compensation"], "already_applied")
self.assertEqual(len(called), 1) self.assertEqual(len(called), 1)
self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250]) self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250])
self.assertEqual([state["primogems"] for state in self.account_states()], [1, 1])
self.assertEqual(self.announcement_marker()["state"], "delivered") self.assertEqual(self.announcement_marker()["state"], "delivered")