Release beta 1.0.5 primogem economy

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