Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddebe075d7 | ||
|
|
522e349491 | ||
|
|
35ef82f14f | ||
|
|
b1d8e4e8e7 |
@@ -1,5 +1,50 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## beta 1.1.2.1019 - 2026-10-19 16:38:10
|
||||||
|
|
||||||
|
- 新增低频主动插话、随机冷门知识问答和更新生命周期提示:Taffy 会偶尔自然冒泡,抛出限时答题奖励摩拉,并在停机、上线和更新就绪时主动说明状态。
|
||||||
|
- Use recent safe group context to replace some hourly scan slots with an
|
||||||
|
ambient chat message during active daytime windows.
|
||||||
|
- Add a 200+ question CS, Apex, Overwatch, and LoL trivia bank with
|
||||||
|
five-minute `/回答 A/B/C/D` answers, recent-question avoidance, and
|
||||||
|
rarity-based Mora rewards.
|
||||||
|
- Limit ambient chat with deterministic probability, multi-hour cooldown, and
|
||||||
|
minimum recent-speaker activity so proactive replies feel occasional.
|
||||||
|
- Send guarded shutdown and startup lifecycle notices during production
|
||||||
|
cutover before the normal ready-stage update announcement and compensation.
|
||||||
|
|
||||||
|
## beta 1.1.1.1016 - 2026-10-16 17:19:45
|
||||||
|
|
||||||
|
- 修复扫码加权重复购买只刷新不叠加的问题。
|
||||||
|
- Change scan boost semantics to stack each successful purchase as an
|
||||||
|
independent 24-hour +5 weight entry.
|
||||||
|
- Preserve legacy boost entries by deriving their 24-hour expiry from their
|
||||||
|
creation time.
|
||||||
|
|
||||||
|
## beta 1.1.0.1016 - 2026-10-16 17:04:25
|
||||||
|
|
||||||
|
- 修复 OCR 偶发重复识别消耗型命令导致重复回复的问题,并完善摩拉/扫码榜展示。
|
||||||
|
- Extend OCR semantic duplicate suppression for resource-mutating local
|
||||||
|
commands so a visible command bubble re-detected after the short 8 second
|
||||||
|
window is still ignored.
|
||||||
|
- Keep ordinary local commands on the short duplicate window to avoid blocking
|
||||||
|
intentional repeated game/query commands.
|
||||||
|
- Show scan-boost coupon count in `/摩拉`, and make `/扫码榜` list every
|
||||||
|
configured scan candidate plus next-hour scan weights.
|
||||||
|
- This major settlement grants each existing account both 200 Mora and
|
||||||
|
1 primogem.
|
||||||
|
|
||||||
|
## 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,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
|
|||||||
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
||||||
并通过带视觉确认的 GUI 发送链路回复。
|
并通过带视觉确认的 GUI 发送链路回复。
|
||||||
|
|
||||||
当前版本:`beta 1.0.4.1016`,更新日期:2026-10-16。
|
当前版本:`beta 1.1.2.1019`,更新时间:2026-10-19 16:38:10。
|
||||||
|
|
||||||
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
|
||||||
|
/摩拉(兼 /原石);/原石祈愿;/原石榜;/扫码加权 人名(24小时+5);/Taffy发话 内容
|
||||||
|
/游戏记录 [页码](/gamehistory)
|
||||||
/游戏记录 [页码](/gamehistory)
|
/游戏记录 [页码](/gamehistory)
|
||||||
/游戏帮助(/games)
|
/游戏帮助(/games)
|
||||||
/自走棋开始|报名|开赛|状态|档案
|
/自走棋开始|报名|开赛|状态|档案
|
||||||
@@ -190,7 +192,9 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot
|
|||||||
|
|
||||||
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
|
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
|
||||||
录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户
|
录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户
|
||||||
发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告。
|
发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告;
|
||||||
|
`beta 1.0.5.1016` 与 `beta 1.1.0.1016` 额外发放 1 原石;
|
||||||
|
后续普通小版本不额外发放原石。
|
||||||
旧 OCR 服务保留安装,但保持禁用。
|
旧 OCR 服务保留安装,但保持禁用。
|
||||||
|
|
||||||
### 仓库结构
|
### 仓库结构
|
||||||
@@ -265,10 +269,10 @@ visually confirmed GUI bridge.
|
|||||||
Current version:
|
Current version:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
beta 1.0.4.1016
|
beta 1.1.2.1019
|
||||||
```
|
```
|
||||||
|
|
||||||
Updated: 2026-10-16.
|
Updated: 2026-10-19 16:38:10.
|
||||||
|
|
||||||
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 +461,8 @@ 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` and `beta 1.1.0.1016`, the settlement also grants
|
||||||
|
1 primogem once; ordinary later patch releases do not grant extra primogems.
|
||||||
|
|
||||||
## Repository Map
|
## Repository Map
|
||||||
|
|
||||||
|
|||||||
@@ -341,6 +341,19 @@ verify_edge_health() {
|
|||||||
'import json,sys; value=json.load(sys.stdin); raise SystemExit(0 if value.get("status") == "ok" else 1)'
|
'import json,sys; value=json.load(sys.stdin); raise SystemExit(0 if value.get("status") == "ok" else 1)'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
send_release_lifecycle_notice() {
|
||||||
|
local stage=$1
|
||||||
|
/usr/sbin/runuser -u "${target_user}" -- /usr/bin/env \
|
||||||
|
HOME="${target_home}" \
|
||||||
|
XDG_RUNTIME_DIR="${user_runtime}" \
|
||||||
|
DBUS_SESSION_BUS_ADDRESS="unix:path=${user_runtime}/bus" \
|
||||||
|
"${runtime_python}" "${source_dir}/jwebot_release_update.py" \
|
||||||
|
--release-file "${source_dir}/VERSION" \
|
||||||
|
--database "${target_edge_state}/edge.sqlite3" \
|
||||||
|
--delivery-helper "${target_runtime}/jwebot_edge_delivery.py" \
|
||||||
|
--lifecycle-stage "${stage}"
|
||||||
|
}
|
||||||
|
|
||||||
render_root_configuration() {
|
render_root_configuration() {
|
||||||
local output_dir="$1"
|
local output_dir="$1"
|
||||||
sed \
|
sed \
|
||||||
@@ -471,6 +484,9 @@ jwebot_log "backup ready path=${backup_dir}"
|
|||||||
armed=1
|
armed=1
|
||||||
jwebot_progress 3 8 "stop services"
|
jwebot_progress 3 8 "stop services"
|
||||||
jwebot_log "cutover starting"
|
jwebot_log "cutover starting"
|
||||||
|
jwebot_log "release lifecycle shutdown notice starting"
|
||||||
|
send_release_lifecycle_notice shutdown
|
||||||
|
jwebot_log "release lifecycle shutdown notice ready"
|
||||||
user_systemctl stop \
|
user_systemctl stop \
|
||||||
wechat-ocr-adaptive-recorder.service \
|
wechat-ocr-adaptive-recorder.service \
|
||||||
jwebot-edge.service \
|
jwebot-edge.service \
|
||||||
@@ -609,6 +625,10 @@ if [[ -f "${target_edge_state}/edge.sqlite3" ]]; then
|
|||||||
fi
|
fi
|
||||||
jwebot_log "database ready quick_check=${db_check:-absent}"
|
jwebot_log "database ready quick_check=${db_check:-absent}"
|
||||||
|
|
||||||
|
jwebot_log "release lifecycle startup notice starting"
|
||||||
|
send_release_lifecycle_notice startup
|
||||||
|
jwebot_log "release lifecycle startup notice ready"
|
||||||
|
|
||||||
jwebot_progress 7 8 "verify backup"
|
jwebot_progress 7 8 "verify backup"
|
||||||
(
|
(
|
||||||
cd "${backup_dir}"
|
cd "${backup_dir}"
|
||||||
|
|||||||
+10
-4
@@ -1,6 +1,6 @@
|
|||||||
# JWeBot Command Catalog
|
# JWeBot Command Catalog
|
||||||
|
|
||||||
Current baseline: `beta 1.0.1.1016`.
|
Current baseline: `beta 1.1.2.1019`.
|
||||||
|
|
||||||
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. |
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
|
|||||||
|
|
||||||
| Command | Alias | Purpose |
|
| Command | Alias | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/扫码排行` | - | Show scan ranking for the current group. |
|
| `/扫码排行` | `/扫码榜`, `/今日扫码` | Show all scan candidates, today's counts, and next-hour scan weights for the current group. |
|
||||||
| `/复读榜` | - | Show repeat ranking for the current group. |
|
| `/复读榜` | - | Show repeat ranking for the current group. |
|
||||||
| `/查记录 关键词` | - | Search local group history by keyword. |
|
| `/查记录 关键词` | - | Search local group history by keyword. |
|
||||||
|
|
||||||
@@ -41,11 +41,17 @@ 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 give that person +5 hourly scan weight for 24 hours. |
|
||||||
|
| `/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. |
|
||||||
|
| `/回答 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` | 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. |
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# trivia-quiz
|
||||||
|
|
||||||
|
Author: `jwebot`
|
||||||
|
|
||||||
|
冷门知识问答
|
||||||
|
|
||||||
|
Help:
|
||||||
|
|
||||||
|
- `/回答 A;/answer A`
|
||||||
|
|
||||||
|
Registered commands:
|
||||||
|
|
||||||
|
- `/回答`
|
||||||
|
- `/answer`
|
||||||
|
|
||||||
|
The adapter has no direct Ledger, GUI, environment, shell, or network access.
|
||||||
|
It delegates to the reviewed `trivia-quiz` Kernel service profile
|
||||||
|
and returns a structured `ReplyPlan`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
id = "trivia-quiz"
|
||||||
|
author = "jwebot"
|
||||||
|
description = "冷门知识问答"
|
||||||
|
help = ["/回答 A;/answer A"]
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1"
|
||||||
|
entrypoint = "trivia_quiz:plugin"
|
||||||
|
type = "command"
|
||||||
|
|
||||||
|
[capabilities]
|
||||||
|
commands = ["/回答", "/answer"]
|
||||||
|
storage = false
|
||||||
|
network_profiles = []
|
||||||
|
service_profiles = ["trivia-quiz"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Generated reviewed adapter for trivia-quiz."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'trivia-quiz'
|
||||||
|
AUTHOR = 'jwebot'
|
||||||
|
DESCRIPTION = '冷门知识问答'
|
||||||
|
HELP_ENTRIES = ('/回答 A;/answer A',)
|
||||||
|
COMMANDS = ('/回答', '/answer')
|
||||||
|
|
||||||
|
|
||||||
|
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 trivia_quiz 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, ('/回答 A;/answer A',))
|
||||||
|
self.assertIn('/回答', registered)
|
||||||
|
self.assertEqual(len(registered), 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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)
|
||||||
|
|||||||
+124
-11
@@ -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 release lifecycle notices, grant, and announcement."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -26,20 +26,27 @@ 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,
|
||||||
|
"beta 1.1.0.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
|
||||||
|
MAX_LIFECYCLE_CHARS = 300
|
||||||
|
LIFECYCLE_STAGES = frozenset({"shutdown", "startup"})
|
||||||
RELEASE_PATTERN = re.compile(
|
RELEASE_PATTERN = re.compile(
|
||||||
r"^beta (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<date>\d{4})$"
|
r"^beta (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<date>\d{4})$"
|
||||||
)
|
)
|
||||||
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 +126,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 的支持与理解,祝大家游戏愉快!"
|
||||||
@@ -133,10 +142,32 @@ def build_announcement(release: str, summary: str) -> str:
|
|||||||
return announcement
|
return announcement
|
||||||
|
|
||||||
|
|
||||||
|
def build_lifecycle_message(release: str, stage: str) -> str:
|
||||||
|
if stage == "shutdown":
|
||||||
|
message = (
|
||||||
|
f"Taffy 准备停机更新到 {release},会短暂离线一下;"
|
||||||
|
"别慌,我很快回来喵"
|
||||||
|
)
|
||||||
|
elif stage == "startup":
|
||||||
|
message = (
|
||||||
|
f"Taffy 刚上线,正在做 {release} 的最后自检;"
|
||||||
|
"确认就绪后会发送更新内容和补偿说明喵"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ReleaseUpdateError(f"unsupported lifecycle stage: {stage}")
|
||||||
|
if len(message) > MAX_LIFECYCLE_CHARS:
|
||||||
|
raise ReleaseUpdateError("release lifecycle message is too long")
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
def compensation_key(release: str) -> str:
|
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}"
|
||||||
@@ -146,7 +177,25 @@ def release_slug(release: str) -> str:
|
|||||||
return re.sub(r"[^a-zA-Z0-9]+", "-", release).strip("-").lower()
|
return re.sub(r"[^a-zA-Z0-9]+", "-", release).strip("-").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def release_groups(database_path: Path) -> list[str]:
|
||||||
|
connection = sqlite3.connect(f"file:{database_path}?mode=ro", uri=True)
|
||||||
|
try:
|
||||||
|
if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok":
|
||||||
|
raise ReleaseUpdateError("Edge SQLite integrity check failed")
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT group_fingerprint
|
||||||
|
FROM player_accounts
|
||||||
|
ORDER BY group_fingerprint
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
return [str(row[0]) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
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 +204,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 +285,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 +305,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 +335,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 +594,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"],
|
||||||
@@ -531,6 +603,34 @@ def run_release_update(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_release_lifecycle(
|
||||||
|
*,
|
||||||
|
release_file: Path,
|
||||||
|
database_path: Path,
|
||||||
|
delivery_helper: Path,
|
||||||
|
stage: str,
|
||||||
|
deliverer: Callable[[Path, str, str, str], None] = run_delivery_helper,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if stage not in LIFECYCLE_STAGES:
|
||||||
|
raise ReleaseUpdateError(f"unsupported lifecycle stage: {stage}")
|
||||||
|
release = read_release(release_file)
|
||||||
|
message = build_lifecycle_message(release, stage)
|
||||||
|
groups = release_groups(database_path)
|
||||||
|
delivered = 0
|
||||||
|
for group in groups:
|
||||||
|
group_hash = hashlib.sha256(group.encode()).hexdigest()[:12]
|
||||||
|
event_id = f"release-{release_slug(release)}-{stage}-{group_hash}"
|
||||||
|
deliverer(delivery_helper, group, message, event_id)
|
||||||
|
delivered += 1
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"release": release,
|
||||||
|
"stage": stage,
|
||||||
|
"groups": len(groups),
|
||||||
|
"delivered": delivered,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
home = Path.home()
|
home = Path.home()
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
@@ -564,20 +664,33 @@ def parse_args() -> argparse.Namespace:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="retry only after visually confirming that the prior send did not land",
|
help="retry only after visually confirming that the prior send did not land",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--lifecycle-stage",
|
||||||
|
choices=sorted(LIFECYCLE_STAGES),
|
||||||
|
help="send a guarded lifecycle notice instead of release settlement",
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
try:
|
try:
|
||||||
result = run_release_update(
|
if args.lifecycle_stage:
|
||||||
release_file=args.release_file,
|
result = run_release_lifecycle(
|
||||||
changelog=args.changelog,
|
release_file=args.release_file,
|
||||||
database_path=args.database,
|
database_path=args.database,
|
||||||
backup_dir=args.backup_dir,
|
delivery_helper=args.delivery_helper,
|
||||||
delivery_helper=args.delivery_helper,
|
stage=args.lifecycle_stage,
|
||||||
retry_uncertain=args.retry_uncertain_announcement,
|
)
|
||||||
)
|
else:
|
||||||
|
result = run_release_update(
|
||||||
|
release_file=args.release_file,
|
||||||
|
changelog=args.changelog,
|
||||||
|
database_path=args.database,
|
||||||
|
backup_dir=args.backup_dir,
|
||||||
|
delivery_helper=args.delivery_helper,
|
||||||
|
retry_uncertain=args.retry_uncertain_announcement,
|
||||||
|
)
|
||||||
except ReleaseUpdateError as error:
|
except ReleaseUpdateError as error:
|
||||||
print(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-config"
|
name = "jwebot-config"
|
||||||
version = "1.0.4b1+1016"
|
version = "1.1.2b1+1019"
|
||||||
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.1.2b1+1019"
|
||||||
|
|||||||
@@ -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.1.2b1+1019"
|
||||||
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.1.2b1+1019",
|
||||||
"jwebot-kernel==1.0.4b1+1016",
|
"jwebot-kernel==1.1.2b1+1019",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ __all__ = [
|
|||||||
"process_request",
|
"process_request",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "1.0.4b1+1016"
|
__version__ = "1.1.2b1+1019"
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-kernel"
|
name = "jwebot-kernel"
|
||||||
version = "1.0.4b1+1016"
|
version = "1.1.2b1+1019"
|
||||||
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.1.2b1+1019",
|
||||||
"jwebot-sdk==1.0.4b1+1016",
|
"jwebot-sdk==1.1.2b1+1019",
|
||||||
"jwebot-official-extensions==1.0.4b1+1016",
|
"jwebot-official-extensions==1.1.2b1+1019",
|
||||||
"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.1.2b1+1019"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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.1.2b1+1019"
|
||||||
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.1.2b1+1019",
|
||||||
]
|
]
|
||||||
|
|
||||||
[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.1.2b1+1019"
|
||||||
|
|||||||
+45
@@ -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))
|
||||||
+2
-2
@@ -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):
|
||||||
|
|||||||
+1
-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')
|
||||||
|
|
||||||
|
|||||||
+45
@@ -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))
|
||||||
+2
-2
@@ -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):
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
"""Generated reviewed adapter for trivia-quiz."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'trivia-quiz'
|
||||||
|
AUTHOR = 'jwebot'
|
||||||
|
DESCRIPTION = '冷门知识问答'
|
||||||
|
HELP_ENTRIES = ('/回答 A;/answer A',)
|
||||||
|
COMMANDS = ('/回答', '/answer')
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
@@ -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,18 @@ 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",
|
||||||
|
"回答": "trivia_answer",
|
||||||
|
"answer": "trivia_answer",
|
||||||
"转账": "transfer",
|
"转账": "transfer",
|
||||||
"transfer": "transfer",
|
"transfer": "transfer",
|
||||||
"贷款": "loan",
|
"贷款": "loan",
|
||||||
@@ -158,21 +171,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 +209,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,
|
||||||
@@ -201,6 +230,13 @@ LOCAL_EXTENSIONS = (
|
|||||||
help_entries=("/贷款;/还款",),
|
help_entries=("/贷款;/还款",),
|
||||||
command_kinds=("loan", "repay"),
|
command_kinds=("loan", "repay"),
|
||||||
),
|
),
|
||||||
|
LocalExtensionDescriptor(
|
||||||
|
extension_id="trivia-quiz",
|
||||||
|
author=JWEBOT_AUTHOR,
|
||||||
|
description="冷门知识问答",
|
||||||
|
help_entries=("/回答 A;/answer A",),
|
||||||
|
command_kinds=("trivia_answer",),
|
||||||
|
),
|
||||||
LocalExtensionDescriptor(
|
LocalExtensionDescriptor(
|
||||||
extension_id="cs-major-autochess",
|
extension_id="cs-major-autochess",
|
||||||
author=JWEBOT_AUTHOR,
|
author=JWEBOT_AUTHOR,
|
||||||
@@ -213,7 +249,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 +280,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(
|
||||||
|
|||||||
+37
-5
@@ -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":
|
||||||
@@ -216,6 +222,8 @@ class LocalCommandEngine:
|
|||||||
return self.reply_cleaner(self.deepseek_balance())
|
return self.reply_cleaner(self.deepseek_balance())
|
||||||
if kind == "steam_friends":
|
if kind == "steam_friends":
|
||||||
return self.reply_cleaner(self.steam_friends(arguments))
|
return self.reply_cleaner(self.steam_friends(arguments))
|
||||||
|
if kind == "trivia_answer":
|
||||||
|
return self.reply_cleaner(self.trivia_answer(row, arguments))
|
||||||
raise LocalCommandError("unsupported local command")
|
raise LocalCommandError("unsupported local command")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -240,17 +248,28 @@ class LocalCommandEngine:
|
|||||||
|
|
||||||
def scan_ranking(self, row: dict[str, Any]) -> str:
|
def scan_ranking(self, row: dict[str, Any]) -> str:
|
||||||
local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat()
|
local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat()
|
||||||
ranking = self.ledger.clock_scan_ranking(
|
group = str(row["group_fingerprint"])
|
||||||
str(row["group_fingerprint"]),
|
ranking = self.ledger.clock_scan_ranking_all(
|
||||||
|
group,
|
||||||
local_date,
|
local_date,
|
||||||
)
|
)
|
||||||
|
weights = self.ledger.next_clock_scan_weights(
|
||||||
|
str(row["group_fingerprint"]),
|
||||||
|
)
|
||||||
if not ranking:
|
if not ranking:
|
||||||
return "北京时间今天还没有已确认送达的扫码记录"
|
return "北京时间今天还没有可展示的扫码候选"
|
||||||
listing = ";".join(
|
listing = ";".join(
|
||||||
f"{index}. {entry['display_name']} {entry['scan_count']} 次"
|
f"{index}. {entry['display_name']} {entry['scan_count']} 次"
|
||||||
for index, entry in enumerate(ranking, start=1)
|
for index, entry in enumerate(ranking, start=1)
|
||||||
)
|
)
|
||||||
return f"北京时间今日扫码排行:{listing}"
|
weight_listing = ";".join(
|
||||||
|
f"{entry['display_name']} {entry['weight']}({entry['share']:.1f}%)"
|
||||||
|
for entry in weights
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"北京时间今日扫码排行:{listing}\n"
|
||||||
|
f"下一小时扫码权重:{weight_listing}"
|
||||||
|
)
|
||||||
|
|
||||||
def repeat_ranking(self, row: dict[str, Any]) -> str:
|
def repeat_ranking(self, row: dict[str, Any]) -> str:
|
||||||
self.ledger.bootstrap_repeat_history()
|
self.ledger.bootstrap_repeat_history()
|
||||||
@@ -433,6 +452,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 :]:
|
||||||
@@ -604,6 +625,17 @@ class LocalCommandEngine:
|
|||||||
return "Steam 查询当前不可用"
|
return "Steam 查询当前不可用"
|
||||||
return self.steam.render(arguments)
|
return self.steam.render(arguments)
|
||||||
|
|
||||||
|
def trivia_answer(self, row: dict[str, Any], arguments: str) -> str:
|
||||||
|
answer = normalize_text(arguments)
|
||||||
|
if not answer:
|
||||||
|
return "用法:/回答 A、/回答 B、/回答 C 或 /回答 D"
|
||||||
|
return self.ledger.answer_trivia(
|
||||||
|
str(row["group_fingerprint"]),
|
||||||
|
str(row["sender"]),
|
||||||
|
str(row["event_id"]),
|
||||||
|
answer,
|
||||||
|
)
|
||||||
|
|
||||||
def profile_for_row(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
def profile_for_row(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
person = self.ledger.resolve_person(
|
person = self.ledger.resolve_person(
|
||||||
str(row["group_fingerprint"]),
|
str(row["group_fingerprint"]),
|
||||||
|
|||||||
+532
-7
@@ -28,6 +28,28 @@ 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_HOURS = 24
|
||||||
|
SCAN_BOOST_SECONDS = SCAN_BOOST_HOURS * 60 * 60
|
||||||
|
SCAN_BOOST_WEIGHT = 5
|
||||||
|
SCAN_BOOST_LIMIT = 200
|
||||||
|
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 +81,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 +126,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 +316,51 @@ 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 = []
|
||||||
|
current_time = time.time()
|
||||||
|
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()
|
||||||
|
weight = boost.get("weight", 0)
|
||||||
|
created_at = float(boost.get("created_at", current_time) or current_time)
|
||||||
|
expires_at = boost.get("expires_at")
|
||||||
|
if not isinstance(expires_at, (int, float)):
|
||||||
|
expires_at = created_at + SCAN_BOOST_SECONDS
|
||||||
|
if (
|
||||||
|
target_key
|
||||||
|
and target_name
|
||||||
|
and type(weight) is int
|
||||||
|
and weight > 0
|
||||||
|
and float(expires_at) > current_time
|
||||||
|
):
|
||||||
|
normalized_boosts.append(
|
||||||
|
{
|
||||||
|
"target_person_key": target_key[:128],
|
||||||
|
"target_display_name": target_name[:120],
|
||||||
|
"weight": min(max(weight, SCAN_BOOST_WEIGHT), 100),
|
||||||
|
"created_at": created_at,
|
||||||
|
"expires_at": float(expires_at),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
account["scan_boosts"] = normalized_boosts[-SCAN_BOOST_LIMIT:]
|
||||||
return account
|
return account
|
||||||
|
|
||||||
|
|
||||||
@@ -233,6 +438,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 +529,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摩拉)并可能获得扫码概率券;"
|
||||||
|
"/扫码加权 人名 在24小时内给TA整点扫码+5权重;"
|
||||||
|
"/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 +561,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 +686,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 +695,12 @@ 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']} 个;"
|
||||||
|
f"扫码概率券 {account['scan_boost_tickets']} 张;"
|
||||||
|
f"{loan_text}"
|
||||||
|
),
|
||||||
(
|
(
|
||||||
f"21点 {state};已结算胜 {account['wins']} / "
|
f"21点 {state};已结算胜 {account['wins']} / "
|
||||||
f"负 {account['losses']} / 平 {account['ties']},"
|
f"负 {account['losses']} / 平 {account['ties']},"
|
||||||
@@ -417,7 +708,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 +776,221 @@ 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']} 个"
|
||||||
|
)
|
||||||
|
now = time.time()
|
||||||
|
boosts = list(account["scan_boosts"])
|
||||||
|
boosts.append(
|
||||||
|
{
|
||||||
|
"target_person_key": str(target["person_key"]),
|
||||||
|
"target_display_name": target_label,
|
||||||
|
"weight": SCAN_BOOST_WEIGHT,
|
||||||
|
"created_at": now,
|
||||||
|
"expires_at": now + SCAN_BOOST_SECONDS,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
account["scan_boosts"] = boosts[-SCAN_BOOST_LIMIT:]
|
||||||
|
return (
|
||||||
|
f"{player_label}:已给 {target_label} 增加整点扫码权重;"
|
||||||
|
f"{cost_text};未来 {SCAN_BOOST_HOURS} 小时内,"
|
||||||
|
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 +1446,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 +1741,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 +1870,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 +1922,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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-sdk"
|
name = "jwebot-sdk"
|
||||||
version = "1.0.4b1+1016"
|
version = "1.1.2b1+1019"
|
||||||
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.1.2b1+1019"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[tool.jwebot]
|
[tool.jwebot]
|
||||||
release = "beta 1.0.4.1016"
|
release = "beta 1.1.2.1019"
|
||||||
production_deployments_enabled_by_default = false
|
production_deployments_enabled_by_default = false
|
||||||
|
|
||||||
[tool.jwebot.workspace]
|
[tool.jwebot.workspace]
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ class DeploymentScriptTests(unittest.TestCase):
|
|||||||
'jwebot_log "runtime install ready"',
|
'jwebot_log "runtime install ready"',
|
||||||
'jwebot_log "edge active"',
|
'jwebot_log "edge active"',
|
||||||
'jwebot_log "adaptive active"',
|
'jwebot_log "adaptive active"',
|
||||||
|
'send_release_lifecycle_notice shutdown',
|
||||||
|
'send_release_lifecycle_notice startup',
|
||||||
|
'jwebot_log "release lifecycle shutdown notice ready"',
|
||||||
|
'jwebot_log "release lifecycle startup notice ready"',
|
||||||
'jwebot_log "release settlement starting"',
|
'jwebot_log "release settlement starting"',
|
||||||
'"${target_runtime}/jwebot_release_update.py"',
|
'"${target_runtime}/jwebot_release_update.py"',
|
||||||
'jwebot_log "release settlement ready"',
|
'jwebot_log "release settlement ready"',
|
||||||
@@ -142,10 +146,20 @@ class DeploymentScriptTests(unittest.TestCase):
|
|||||||
source.count("xdotool"),
|
source.count("xdotool"),
|
||||||
source.count("/usr/bin/xdotool"),
|
source.count("/usr/bin/xdotool"),
|
||||||
)
|
)
|
||||||
|
cutover_index = source.index('jwebot_log "cutover starting"')
|
||||||
|
actual_stop_index = source.index("user_systemctl stop", cutover_index)
|
||||||
|
self.assertLess(
|
||||||
|
source.index('jwebot_log "release lifecycle shutdown notice ready"'),
|
||||||
|
actual_stop_index,
|
||||||
|
)
|
||||||
self.assertLess(
|
self.assertLess(
|
||||||
source.index("trap - EXIT HUP INT TERM"),
|
source.index("trap - EXIT HUP INT TERM"),
|
||||||
source.index('jwebot_log "release settlement starting"'),
|
source.index('jwebot_log "release settlement starting"'),
|
||||||
)
|
)
|
||||||
|
self.assertLess(
|
||||||
|
source.index('jwebot_log "release lifecycle startup notice ready"'),
|
||||||
|
source.index('jwebot_log "release settlement starting"'),
|
||||||
|
)
|
||||||
self.assertLess(
|
self.assertLess(
|
||||||
source.index('jwebot_log "release settlement ready"'),
|
source.index('jwebot_log "release settlement ready"'),
|
||||||
source.index('jwebot_log "deployment ready version='),
|
source.index('jwebot_log "deployment ready version='),
|
||||||
|
|||||||
+395
-3
@@ -8,6 +8,7 @@ import sqlite3
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -179,6 +180,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(
|
||||||
@@ -694,6 +699,42 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
|||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
self.assertEqual(count, 1)
|
self.assertEqual(count, 1)
|
||||||
|
|
||||||
|
def test_resource_mutation_semantic_duplicate_uses_longer_window(self):
|
||||||
|
first = self.application.accept(
|
||||||
|
{
|
||||||
|
**event("event-1", "@JWeBot /原石祈愿"),
|
||||||
|
"source": "rapidocr-local",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
old_timestamp = (
|
||||||
|
MODULE.datetime.now(MODULE.timezone.utc)
|
||||||
|
- MODULE.timedelta(seconds=30)
|
||||||
|
).isoformat(timespec="milliseconds")
|
||||||
|
with self.application.ledger.lock, self.application.ledger.connection:
|
||||||
|
self.application.ledger.connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE events
|
||||||
|
SET created_at = ?, updated_at = ?
|
||||||
|
WHERE event_id = ?
|
||||||
|
""",
|
||||||
|
(old_timestamp, old_timestamp, "event-1"),
|
||||||
|
)
|
||||||
|
duplicate = self.application.accept(
|
||||||
|
{
|
||||||
|
**event("event-2", "@JWeBot /原石祈愿"),
|
||||||
|
"source": "rapidocr-local",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(first["status"], "reply")
|
||||||
|
self.assertEqual(duplicate["status"], "ignored")
|
||||||
|
self.assertTrue(duplicate["duplicate"])
|
||||||
|
with self.application.ledger.lock:
|
||||||
|
count = self.application.ledger.connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM events"
|
||||||
|
).fetchone()[0]
|
||||||
|
self.assertEqual(count, 1)
|
||||||
|
|
||||||
def test_failed_fast_ack_never_delivers_the_generated_reply(self):
|
def test_failed_fast_ack_never_delivers_the_generated_reply(self):
|
||||||
self.application.deepseek.complete = mock.Mock(return_value="最终回复喵")
|
self.application.deepseek.complete = mock.Mock(return_value="最终回复喵")
|
||||||
self.application.deliver = mock.Mock()
|
self.application.deliver = mock.Mock()
|
||||||
@@ -884,9 +925,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.1.2.1019", version["text"])
|
||||||
self.assertIn("更新时间:2026-10-16", version["text"])
|
self.assertIn("更新时间:2026-10-19 16:38:10", 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")
|
||||||
@@ -1092,6 +1133,223 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
|||||||
self.assertIn(ranking[0]["display_name"], daily_delivery["result_text"])
|
self.assertIn(ranking[0]["display_name"], daily_delivery["result_text"])
|
||||||
self.application.deepseek.complete.assert_not_called()
|
self.application.deepseek.complete.assert_not_called()
|
||||||
|
|
||||||
|
def test_low_frequency_ambient_chat_uses_recent_context(self):
|
||||||
|
ledger = self.application.ledger
|
||||||
|
observed_at = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
12,
|
||||||
|
42,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
).isoformat()
|
||||||
|
for index, (sender, text) in enumerate(
|
||||||
|
(
|
||||||
|
("Alice", "今天这个加班安排是不是有点离谱"),
|
||||||
|
("Bob", "我也觉得还要继续修 bug"),
|
||||||
|
("Alice", "先把问题处理完再说"),
|
||||||
|
("Bob", "感觉咖啡已经救不了我了"),
|
||||||
|
),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
self.assertTrue(
|
||||||
|
ledger.record_person_observation(
|
||||||
|
event_id=f"ambient-observation-{index}",
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text=text,
|
||||||
|
observed_at=observed_at,
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ledger.record_repeat_history(
|
||||||
|
event_id=f"ambient-context-{index}",
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text=text,
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
self.application.deliver = mock.Mock()
|
||||||
|
now = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
13,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(MODULE, "TRIVIA_QUIZ_PROBABILITY_PERCENT", 0), \
|
||||||
|
mock.patch.object(MODULE, "AMBIENT_CHAT_PROBABILITY_PERCENT", 100):
|
||||||
|
self.assertTrue(
|
||||||
|
self.application.process_clock_once(
|
||||||
|
now=now,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery = self.application.deliver.call_args.args[0]
|
||||||
|
self.assertIn("加班味", delivery["result_text"])
|
||||||
|
row = ledger.connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT kind, state FROM clock_broadcasts
|
||||||
|
WHERE group_fingerprint = ? AND slot_key = ?
|
||||||
|
""",
|
||||||
|
("group-a", "hourly:2026-10-13T13"),
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(row["kind"], "ambient_chat")
|
||||||
|
self.assertEqual(row["state"], "delivered")
|
||||||
|
self.assertFalse(
|
||||||
|
self.application.process_clock_once(
|
||||||
|
now=now,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_trivia_quiz_broadcast_and_answer_rewards_mora(self):
|
||||||
|
self.assertGreater(len(MODULE.trivia_question_bank()), 200)
|
||||||
|
ledger = self.application.ledger
|
||||||
|
observed_at = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
12,
|
||||||
|
42,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
).isoformat()
|
||||||
|
for sender in ("Alice", "Bob"):
|
||||||
|
ledger.record_person_observation(
|
||||||
|
event_id=f"trivia-member-{sender}",
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text="冷门题准备一下",
|
||||||
|
observed_at=observed_at,
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
self.application.deliver = mock.Mock()
|
||||||
|
now = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
13,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(MODULE, "TRIVIA_QUIZ_PROBABILITY_PERCENT", 100):
|
||||||
|
self.assertTrue(
|
||||||
|
self.application.process_clock_once(
|
||||||
|
now=now,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery = self.application.deliver.call_args.args[0]
|
||||||
|
self.assertIn("冷门知识问答", delivery["result_text"])
|
||||||
|
self.assertIn("限时5分钟", 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
|
||||||
|
state["expires_at"] = time.time() + 300
|
||||||
|
ledger.save_local_game("group-a", MODULE.TRIVIA_GAME_KEY, state)
|
||||||
|
answer = str(state["answer"])
|
||||||
|
reward = int(state["reward"])
|
||||||
|
result = self.application.accept(
|
||||||
|
event(
|
||||||
|
"trivia-answer",
|
||||||
|
f"@JWeBot /回答 {answer}",
|
||||||
|
sender="Alice",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["route"], "local")
|
||||||
|
self.assertIn("答对了", result["text"])
|
||||||
|
self.assertIn(f"奖励摩拉 +{reward}", result["text"])
|
||||||
|
self.assertIsNone(ledger.local_game("group-a", MODULE.TRIVIA_GAME_KEY))
|
||||||
|
person = ledger.resolve_person("group-a", "Alice")
|
||||||
|
account = ledger._load_player_account("group-a", str(person["person_key"]))
|
||||||
|
self.assertEqual(account["balance"], 1000 + reward)
|
||||||
|
|
||||||
|
def test_ambient_chat_cooldown_keeps_next_hour_scan_broadcast(self):
|
||||||
|
ledger = self.application.ledger
|
||||||
|
observed_at = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
12,
|
||||||
|
45,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
).isoformat()
|
||||||
|
for index, (sender, text) in enumerate(
|
||||||
|
(
|
||||||
|
("Alice", "今天加班真的有点顶不住"),
|
||||||
|
("Bob", "先把这个线上问题看完"),
|
||||||
|
("Alice", "咖啡续上"),
|
||||||
|
("Bob", "修完再开一把游戏"),
|
||||||
|
),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
ledger.record_person_observation(
|
||||||
|
event_id=f"ambient-cooldown-observation-{index}",
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text=text,
|
||||||
|
observed_at=observed_at,
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
ledger.record_repeat_history(
|
||||||
|
event_id=f"ambient-cooldown-context-{index}",
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text=text,
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
self.application.deliver = mock.Mock()
|
||||||
|
first = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
13,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
second = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
14,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(MODULE, "TRIVIA_QUIZ_PROBABILITY_PERCENT", 0), \
|
||||||
|
mock.patch.object(MODULE, "AMBIENT_CHAT_PROBABILITY_PERCENT", 100):
|
||||||
|
self.assertTrue(
|
||||||
|
self.application.process_clock_once(
|
||||||
|
now=first,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
self.application.process_clock_once(
|
||||||
|
now=second,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
second_delivery = self.application.deliver.call_args.args[0]
|
||||||
|
self.assertIn("扫码了", second_delivery["result_text"])
|
||||||
|
row = ledger.connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT kind, state FROM clock_broadcasts
|
||||||
|
WHERE group_fingerprint = ? AND slot_key = ?
|
||||||
|
""",
|
||||||
|
("group-a", "hourly:2026-10-13T14"),
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(row["kind"], "hourly")
|
||||||
|
self.assertEqual(row["state"], "delivered")
|
||||||
|
|
||||||
def test_failed_hourly_broadcast_does_not_increment_scan_ranking(self):
|
def test_failed_hourly_broadcast_does_not_increment_scan_ranking(self):
|
||||||
ledger = self.application.ledger
|
ledger = self.application.ledger
|
||||||
for event_id, sender in (("clock-c", "Alice"), ("clock-d", "Bob")):
|
for event_id, sender in (("clock-c", "Alice"), ("clock-d", "Bob")):
|
||||||
@@ -1122,6 +1380,134 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
|||||||
self.assertEqual(ledger.clock_scan_ranking("group-a", "2026-10-13"), [])
|
self.assertEqual(ledger.clock_scan_ranking("group-a", "2026-10-13"), [])
|
||||||
self.assertEqual(ledger.status()["clock_broadcasts_pending"], 0)
|
self.assertEqual(ledger.status()["clock_broadcasts_pending"], 0)
|
||||||
|
|
||||||
|
def test_existing_pending_hourly_broadcast_is_delivered(self):
|
||||||
|
ledger = self.application.ledger
|
||||||
|
for event_id, sender in (("clock-pending-a", "Alice"), ("clock-pending-b", "Bob")):
|
||||||
|
ledger.record_person_observation(
|
||||||
|
event_id=event_id,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text="群内聊天记录",
|
||||||
|
observed_at=MODULE.utc_now(),
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
person = ledger.resolve_person("group-a", "Bob")
|
||||||
|
self.assertTrue(
|
||||||
|
ledger.reserve_clock_broadcast(
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
slot_key="hourly:2026-10-13T05",
|
||||||
|
local_date="2026-10-13",
|
||||||
|
kind="hourly",
|
||||||
|
message="现在是北京时间 5:00,bob 扫码了。",
|
||||||
|
scan_person_key=str(person["person_key"]),
|
||||||
|
scan_label="bob",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.application.deliver = mock.Mock()
|
||||||
|
now = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
self.application.process_clock_once(
|
||||||
|
now=now,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery = self.application.deliver.call_args.args[0]
|
||||||
|
self.assertIn("bob 扫码了", delivery["result_text"])
|
||||||
|
self.assertEqual(
|
||||||
|
ledger.clock_scan_ranking("group-a", "2026-10-13")[0]["display_name"],
|
||||||
|
"bob",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_scan_boost_pity_forces_target_after_two_misses(self):
|
||||||
|
ledger = self.application.ledger
|
||||||
|
for event_id, sender in (("pity-member-a", "Alice"), ("pity-member-b", "Bob")):
|
||||||
|
ledger.record_person_observation(
|
||||||
|
event_id=event_id,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text="群内聊天记录",
|
||||||
|
observed_at=MODULE.utc_now(),
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
alice = ledger.resolve_person("group-a", "Alice")
|
||||||
|
bob = ledger.resolve_person("group-a", "Bob")
|
||||||
|
carol = ledger.resolve_person("group-a", "Carol")
|
||||||
|
account = MODULE.normalize_player_account({})
|
||||||
|
account["scan_boosts"] = [
|
||||||
|
{
|
||||||
|
"target_person_key": str(bob["person_key"]),
|
||||||
|
"target_display_name": "bob",
|
||||||
|
"weight": 5,
|
||||||
|
"created_at": MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
3,
|
||||||
|
30,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
).timestamp(),
|
||||||
|
"expires_at": MODULE.datetime(
|
||||||
|
2099,
|
||||||
|
10,
|
||||||
|
14,
|
||||||
|
3,
|
||||||
|
30,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
).timestamp(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
ledger._save_player_account(
|
||||||
|
"group-a",
|
||||||
|
str(alice["person_key"]),
|
||||||
|
account,
|
||||||
|
MODULE.utc_now(),
|
||||||
|
)
|
||||||
|
for hour, person, label in (
|
||||||
|
(4, alice, "alice"),
|
||||||
|
(5, carol, "carol"),
|
||||||
|
):
|
||||||
|
slot_key = f"hourly:2026-10-13T{hour:02d}"
|
||||||
|
self.assertTrue(
|
||||||
|
ledger.reserve_clock_broadcast(
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
slot_key=slot_key,
|
||||||
|
local_date="2026-10-13",
|
||||||
|
kind="hourly",
|
||||||
|
message="test",
|
||||||
|
scan_person_key=str(person["person_key"]),
|
||||||
|
scan_label=label,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(ledger.complete_clock_broadcast("group-a", slot_key))
|
||||||
|
now = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
6,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
status = ledger.scan_boost_pity_status("group-a", local_now=now)
|
||||||
|
self.assertTrue(status[0]["guaranteed"])
|
||||||
|
self.assertEqual(status[0]["display_name"], "bob")
|
||||||
|
broadcast = self.application.clock.due_broadcast(
|
||||||
|
now=now,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(broadcast)
|
||||||
|
self.assertEqual(broadcast.scan_label, "bob")
|
||||||
|
|
||||||
def test_scan_ranking_command_uses_current_beijing_date(self):
|
def test_scan_ranking_command_uses_current_beijing_date(self):
|
||||||
ledger = self.application.ledger
|
ledger = self.application.ledger
|
||||||
local_date = MODULE.datetime.now(MODULE.BEIJING_TIMEZONE).date().isoformat()
|
local_date = MODULE.datetime.now(MODULE.BEIJING_TIMEZONE).date().isoformat()
|
||||||
@@ -1151,6 +1537,12 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
|||||||
self.assertEqual(response["route"], "local")
|
self.assertEqual(response["route"], "local")
|
||||||
self.assertIn("北京时间今日扫码排行", response["text"])
|
self.assertIn("北京时间今日扫码排行", response["text"])
|
||||||
self.assertIn("bob 1 次", response["text"])
|
self.assertIn("bob 1 次", response["text"])
|
||||||
|
self.assertIn("alice 0 次", response["text"])
|
||||||
|
self.assertIn("carol 0 次", response["text"])
|
||||||
|
self.assertIn("下一小时扫码权重:", response["text"])
|
||||||
|
self.assertIn("alice 1", response["text"])
|
||||||
|
self.assertIn("bob 1", response["text"])
|
||||||
|
self.assertIn("carol 1", response["text"])
|
||||||
|
|
||||||
def test_repeat_ranking_tracks_origin_and_later_repeats_by_display_id(self):
|
def test_repeat_ranking_tracks_origin_and_later_repeats_by_display_id(self):
|
||||||
ledger = self.application.ledger
|
ledger = self.application.ledger
|
||||||
|
|||||||
@@ -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,7 +94,9 @@ 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,
|
||||||
|
"trivia-quiz": JWEBOT_AUTHOR,
|
||||||
"random-number": JWEBOT_AUTHOR,
|
"random-number": JWEBOT_AUTHOR,
|
||||||
"draw-lot": JWEBOT_AUTHOR,
|
"draw-lot": JWEBOT_AUTHOR,
|
||||||
"daily-fortune": JWEBOT_AUTHOR,
|
"daily-fortune": JWEBOT_AUTHOR,
|
||||||
@@ -120,9 +123,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",
|
||||||
|
|||||||
@@ -270,11 +270,17 @@ 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 个;扫码概率券 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 个;扫码概率券 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):
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""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"] = 3
|
||||||
|
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)
|
||||||
|
ranking = self.command("/扫码榜", event_id="scan-ranking-after-boost")
|
||||||
|
self.assertIn("下一小时扫码权重:", ranking)
|
||||||
|
self.assertIn("bob 6", ranking)
|
||||||
|
|
||||||
|
second_boost = self.command("/扫码加权 Bob", event_id="scan-boost-second")
|
||||||
|
self.assertIn("已给 bob 增加整点扫码权重", second_boost)
|
||||||
|
stacked = self.command("/扫码榜", event_id="scan-ranking-stacked")
|
||||||
|
self.assertIn("bob 11", stacked)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -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.1.2.1019"
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.temporary = tempfile.TemporaryDirectory()
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
@@ -112,8 +112,44 @@ class ReleaseUpdateTests(unittest.TestCase):
|
|||||||
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", announcement)
|
||||||
|
self.assertNotIn("原石 x1", announcement)
|
||||||
self.assertNotIn("第二条", announcement)
|
self.assertNotIn("第二条", announcement)
|
||||||
|
|
||||||
|
def test_policy_release_announces_primogem(self):
|
||||||
|
for release in ("beta 1.0.5.1016", "beta 1.1.0.1016"):
|
||||||
|
self.assertEqual(MODULE.primogem_compensation_amount(release), 1)
|
||||||
|
announcement = MODULE.build_announcement(release, "原石系统上线。")
|
||||||
|
self.assertIn("更新补偿:摩拉 x200,原石 x1", announcement)
|
||||||
|
|
||||||
|
def test_patch_release_without_policy_does_not_announce_primogem(self):
|
||||||
|
release = "beta 1.1.2.1019"
|
||||||
|
self.assertEqual(MODULE.primogem_compensation_amount(release), 0)
|
||||||
|
announcement = MODULE.build_announcement(release, "普通小版本更新。")
|
||||||
|
self.assertIn("更新补偿:摩拉 x200", announcement)
|
||||||
|
self.assertNotIn("原石 x1", announcement)
|
||||||
|
|
||||||
|
def test_lifecycle_stage_sends_guarded_notice_without_compensation(self):
|
||||||
|
delivered = []
|
||||||
|
|
||||||
|
def deliverer(path, group, message, event_id):
|
||||||
|
delivered.append((path, group, message, event_id))
|
||||||
|
|
||||||
|
result = MODULE.run_release_lifecycle(
|
||||||
|
release_file=self.version,
|
||||||
|
database_path=self.database,
|
||||||
|
delivery_helper=self.delivery_helper,
|
||||||
|
stage="shutdown",
|
||||||
|
deliverer=deliverer,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["stage"], "shutdown")
|
||||||
|
self.assertEqual(result["delivered"], 1)
|
||||||
|
self.assertEqual(delivered[0][1], "group-a")
|
||||||
|
self.assertIn("准备停机更新", delivered[0][2])
|
||||||
|
self.assertIn(self.release, delivered[0][2])
|
||||||
|
self.assertIn("shutdown", delivered[0][3])
|
||||||
|
self.assertEqual([state["balance"] for state in self.account_states()], [1000, 50])
|
||||||
|
|
||||||
def test_release_update_grants_once_and_announces_once(self):
|
def test_release_update_grants_once_and_announces_once(self):
|
||||||
delivered = []
|
delivered = []
|
||||||
|
|
||||||
@@ -148,6 +184,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"], 0)
|
||||||
matches = [
|
matches = [
|
||||||
event
|
event
|
||||||
for event in state["coin_events"]
|
for event in state["coin_events"]
|
||||||
@@ -156,6 +193,12 @@ 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["reason"] == f"{self.release} 版本更新补偿"
|
||||||
|
]
|
||||||
|
self.assertEqual(primogem_matches, [])
|
||||||
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 +216,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()], [0, 0])
|
||||||
self.assertEqual(self.announcement_marker()["state"], "uncertain")
|
self.assertEqual(self.announcement_marker()["state"], "uncertain")
|
||||||
|
|
||||||
called = []
|
called = []
|
||||||
@@ -203,6 +247,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()], [0, 0])
|
||||||
self.assertEqual(self.announcement_marker()["state"], "delivered")
|
self.assertEqual(self.announcement_marker()["state"], "delivered")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user