Externalize autochess game data
check / local-gate (push) Failing after 58s

This commit is contained in:
JWeBot Contributors
2026-10-15 18:37:41 +08:00
parent 5125b827c0
commit 2bb2effc04
38 changed files with 3562 additions and 524 deletions
+17
View File
@@ -1,5 +1,22 @@
# Changelog # Changelog
## alpha 0.6.9.1015
- Move CS Major autochess public game data out of `cs_autochess.py` and into
package data files under `jwebot_official_extensions/data/cs_autochess/`.
Player cards, coach cards, narrative fragments, shop/economy odds, draft
pacing, synergy thresholds, match formats, MR15/overtime settings, and
special event rewards are now data driven.
- Add startup validation for the externalized data so missing fields,
duplicate card IDs, invalid schema versions, or an undersized narrative
library fail fast before the command engine runs.
- Add regression coverage that verifies the JSON files are packaged, readable,
and consistent with the runtime card, coach, match-format, and narrative
inventories.
- Add virtual coin transfers with `/转账 对方 金额` and `/transfer name amount`.
Transfers are single-transaction ledger updates, require the receiver to
initialize `/金币` first, reject self-transfers, and replay safely by event ID.
## alpha 0.6.8.1015 ## alpha 0.6.8.1015
- Add an atomic compatibility path for old active CS autochess runs. When a - Add an atomic compatibility path for old active CS autochess runs. When a
+11 -4
View File
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 GUI 发送链路回复。
当前版本:`alpha 0.6.8.1015`。 当前版本:`alpha 0.6.9.1015`。
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加 0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见 两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
@@ -36,6 +36,11 @@ D 牌、锁店、调阵容和战术窗口会在轮次之间自然提示。
0.6.8 增加旧版自走棋 active 状态兼容:如果群里残留 0.6.6/0.6.7 0.6.8 增加旧版自走棋 active 状态兼容:如果群里残留 0.6.6/0.6.7
之前创建的未完成赛事,新的 `/自走棋开始` 会原子归档旧档案并开启新版 之前创建的未完成赛事,新的 `/自走棋开始` 会原子归档旧档案并开启新版
赛季,不需要维护人员手工改库。 赛季,不需要维护人员手工改库。
0.6.9 将 CS Major 自走棋的公开游戏数据拆出到包内 JSON:选手卡池、
教练池、叙事素材、商店概率、经济奖励、选秀节奏、羁绊阈值、赛制和
MR15/加时参数都由数据文件驱动,启动时会校验字段、ID 和素材数量。
同版新增虚拟金币转账:`/转账 对方 金额` 会在同一事务内扣款入账,
收款人需先发送 `/金币` 初始化账户。
### 运行流程 ### 运行流程
@@ -102,6 +107,7 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/上下文 [100|200|300](/context) /上下文 [100|200|300](/context)
/人物列表 [页码](/people);/人物 [名称或别名](/person) /人物列表 [页码](/people);/人物 [名称或别名](/person)
/金币(/wallet);/抽奖 [金额](/draw) /金币(/wallet);/抽奖 [金额](/draw)
/转账 对方 金额(/transfer name amount)
/彩票 开始|报名|当前状态|开奖|取消(/lottery) /彩票 开始|报名|当前状态|开奖|取消(/lottery)
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 /21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
/游戏记录 [页码](/gamehistory) /游戏记录 [页码](/gamehistory)
@@ -278,7 +284,7 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```text
alpha 0.6.8.1015 alpha 0.6.9.1015
``` ```
## Runtime Flow ## Runtime Flow
@@ -379,8 +385,9 @@ Version 0.6.1 adds local `/context 100|200|300` extractive summaries,
`/people` and `/person` lookup, and `/wallet`, `/draw`, `/lottery`, `/blackjack` `/people` and `/person` lookup, and `/wallet`, `/draw`, `/lottery`, `/blackjack`
single-player virtual coin games. Chinese aliases share the same implementations. single-player virtual coin games. Chinese aliases share the same implementations.
Accounts receive 1000 virtual coins once; funds, game state and event replay Accounts receive 1000 virtual coins once; funds, game state and event replay
results commit together. OCR display aliases identify group-scoped people; results commit together. `/transfer name amount` moves virtual coins between
native WeChat account IDs are unavailable. See initialized group-scoped accounts in one ledger transaction. OCR display aliases
identify group-scoped people; native WeChat account IDs are unavailable. See
[plugin rules and capture reconciliation](docs/local-plugins-0.6.1.md). [plugin rules and capture reconciliation](docs/local-plugins-0.6.1.md).
Version 0.6.2 adds a 30-second total deadline to direct DeepSeek streams so an Version 0.6.2 adds a 30-second total deadline to direct DeepSeek streams so an
unfinished upstream response cannot occupy both generation workers for about unfinished upstream response cannot occupy both generation workers for about
+1 -1
View File
@@ -1 +1 @@
alpha 0.6.8.1015 alpha 0.6.9.1015
+6
View File
@@ -81,6 +81,12 @@
选手数据不是对真实比赛数据的一比一复刻,而是基于公众认知做游戏化映射。 选手数据不是对真实比赛数据的一比一复刻,而是基于公众认知做游戏化映射。
0.6.7 起本地维护 `100+` 名棋子和 `18` 名教练元数据;运行期不在线抓取 0.6.7 起本地维护 `100+` 名棋子和 `18` 名教练元数据;运行期不在线抓取
或改写数值。首轮教练选秀仍固定展示 10 个候选,保证聊天窗口可读。 或改写数值。首轮教练选秀仍固定展示 10 个候选,保证聊天窗口可读。
0.6.9 起,公开游戏内容不再内联在 `cs_autochess.py` 中,而是维护在
`packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/`:
`players.json` 保存选手卡,`coaches.json` 保存教练卡,
`narratives.json` 保存战报和剧情素材,`rules.json` 保存经济、商店、
选秀、羁绊、赛制、MR15/加时和特殊事件规则。运行时启动会校验 schema、
重复 ID、必要字段和叙事素材数量。
### 4.2 费用层级 ### 4.2 费用层级
+9 -2
View File
@@ -8,6 +8,7 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,
|---|---|---| |---|---|---|
| context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` | | context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` |
| single-player-games | `/金币` | `/wallet` | | single-player-games | `/金币` | `/wallet` |
| coin-transfer | `/转账 对方 金额` | `/transfer name amount` |
| single-player-games | `/抽奖 [金额]` | `/draw [金额]` | | single-player-games | `/抽奖 [金额]` | `/draw [金额]` |
| single-player-games | `/彩票 开始/报名/当前状态/开奖/取消` | `/lottery start/join/status/draw/cancel` | | single-player-games | `/彩票 开始/报名/当前状态/开奖/取消` | `/lottery start/join/status/draw/cancel` |
| single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) | | single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) |
@@ -49,8 +50,9 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使
仓库没有生产人物或映射文件。 仓库没有生产人物或映射文件。
昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域; 昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域;
应优先追加别名而不是重命名。用户不能通过游戏命令指定他人的账户, 应优先追加别名而不是重命名。除 `/转账` 外,用户不能通过游戏命令
不能充值、转账、赠币或兑换真实资产。 指定他人的账户;转账只支持同群虚拟金币账户,收款人需先发送 `/金币`
初始化。虚拟金币不能充值、赠币或兑换真实资产。
## 虚拟金币与游戏管理 ## 虚拟金币与游戏管理
@@ -58,6 +60,11 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使
重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和 重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和
21 点下注为 1~500;余额不足或参数非法不扣款。 21 点下注为 1~500;余额不足或参数非法不扣款。
`/转账 对方 金额` 会把本人的虚拟金币转给同群已初始化账户,英文为
`/transfer name amount`。金额范围 1~100000;不允许给自己转账。
转账、响应记录和重试去重写在同一个 ledger 事务内,同一 event_id
不会重复扣款。
抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。 抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。
alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关, alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关,
下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见 下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见
+18
View File
@@ -0,0 +1,18 @@
# coin-transfer
Author: `jwebot`
金币转账
Help:
- `/转账 对方 金额;/transfer`
Registered commands:
- `/转账`
- `/transfer`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `coin-transfer` Kernel service profile
and returns a structured `ReplyPlan`.
@@ -0,0 +1,14 @@
id = "coin-transfer"
author = "jwebot"
description = "金币转账"
help = ["/转账 对方 金额;/transfer"]
version = "1.0.0"
api_version = "1"
entrypoint = "coin_transfer:plugin"
type = "command"
[capabilities]
commands = ["/转账", "/transfer"]
storage = false
network_profiles = []
service_profiles = ["coin-transfer"]
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-transfer."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-transfer'
AUTHOR = 'jwebot'
DESCRIPTION = '金币转账'
HELP_ENTRIES = ('/转账 对方 金额;/transfer',)
COMMANDS = ('/转账', '/transfer')
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 coin_transfer 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, ('/转账 对方 金额;/transfer',))
self.assertIn('/转账', registered)
self.assertEqual(len(registered), 2)
if __name__ == "__main__":
unittest.main()
@@ -2,14 +2,15 @@
Author: `jwebot` Author: `jwebot`
虚拟金币账户、抽奖、群内多人彩票和单人21点 金币游戏
Help: Help:
- `/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory` - `/金币 /wallet;/游戏记录 /gamehistory`
- `/彩票 开始|报名|当前状态|开奖|取消;/lottery` - `/抽奖 [金额];/draw`
- `/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)` - `/彩票 开始|报名|状态|开奖|取消;/lottery`
- `/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end` - `/21点 开始 [1~500];/blackjack start`
- `/21点 要牌|停牌|状态|结束;/blackjack`
- `/游戏帮助;/games` - `/游戏帮助;/games`
Registered commands: Registered commands:
@@ -1,7 +1,7 @@
id = "single-player-games" id = "single-player-games"
author = "jwebot" author = "jwebot"
description = "虚拟金币账户、抽奖、群内多人彩票和单人21点" description = "金币游戏"
help = ["/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory", "/彩票 开始|报名|当前状态|开奖|取消;/lottery", "/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)", "/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", "/游戏帮助;/games"] help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 开始 [1~500];/blackjack start", "/21点 要牌|停牌|状态|结束;/blackjack", "/游戏帮助;/games"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
entrypoint = "single_player_games:plugin" entrypoint = "single_player_games:plugin"
@@ -5,8 +5,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games' PROFILE = 'single-player-games'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点' DESCRIPTION = '金币游戏'
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games') HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
@@ -19,8 +19,8 @@ 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, 'jwebot') self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '虚拟金币账户、抽奖、群内多人彩票和单人21点') self.assertEqual(DESCRIPTION, '金币游戏')
self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')) self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games'))
self.assertIn('/金币', registered) self.assertIn('/金币', registered)
self.assertEqual(len(registered), 15) self.assertEqual(len(registered), 15)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-config" name = "jwebot-config"
version = "0.6.8a0+1015" version = "0.6.9a0+1015"
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__ = "0.6.8a0+1015" __version__ = "0.6.9a0+1015"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-extension-host" name = "jwebot-extension-host"
version = "0.6.8a0+1015" version = "0.6.9a0+1015"
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==0.6.8a0+1015", "jwebot-sdk==0.6.9a0+1015",
"jwebot-kernel==0.6.8a0+1015", "jwebot-kernel==0.6.9a0+1015",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "0.6.8a0+1015" __version__ = "0.6.9a0+1015"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-kernel" name = "jwebot-kernel"
version = "0.6.8a0+1015" version = "0.6.9a0+1015"
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==0.6.8a0+1015", "jwebot-config==0.6.9a0+1015",
"jwebot-sdk==0.6.8a0+1015", "jwebot-sdk==0.6.9a0+1015",
"jwebot-official-extensions==0.6.8a0+1015", "jwebot-official-extensions==0.6.9a0+1015",
"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__ = "0.6.8a0+1015" __version__ = "0.6.9a0+1015"
@@ -3665,6 +3665,98 @@ class Ledger:
"game_counts": dict(account["game_counts"]), "game_counts": dict(account["game_counts"]),
} }
def transfer_player_coins(
self,
group: str,
sender: str,
event_id: str,
command: str,
target_label: str,
amount: int,
render: Callable[[dict[str, Any]], str],
) -> str:
"""Move virtual coins between two group-scoped player accounts."""
source = self._player_identity(group, sender)
source_key = str(source["person_key"])
target = self.resolve_person(group, target_label)
target_key = str(target["person_key"])
target_display_name = person_display_label(target["display_name"])
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
replay = self._player_action_replay(
group, event_id, source_key, command
)
if replay is not None:
self.connection.execute("COMMIT")
return replay
if source_key == target_key:
data: dict[str, Any] = {
"status": "self",
"source_display_name": source["display_name"],
"target_display_name": target_display_name,
"amount": amount,
}
else:
target_exists = self.connection.execute(
"""
SELECT 1 FROM player_accounts
WHERE group_fingerprint = ? AND person_key = ?
LIMIT 1
""",
(group, target_key),
).fetchone() is not None
if not target_exists:
data = {
"status": "target_missing",
"source_display_name": source["display_name"],
"target_display_name": target_display_name,
"amount": amount,
}
else:
source_account = self._load_player_account(
group, source_key
)
if amount > int(source_account["balance"]):
data = {
"status": "insufficient",
"source_display_name": source["display_name"],
"target_display_name": target_display_name,
"amount": amount,
"source_balance": int(source_account["balance"]),
}
else:
target_account = self._load_player_account(
group, target_key
)
source_account["balance"] -= amount
target_account["balance"] += amount
timestamp = utc_now()
self._save_player_account(
group, source_key, source_account, timestamp
)
self._save_player_account(
group, target_key, target_account, timestamp
)
data = {
"status": "transferred",
"source_display_name": source["display_name"],
"target_display_name": target_display_name,
"amount": amount,
"source_balance": int(source_account["balance"]),
"target_balance": int(target_account["balance"]),
}
response = render(data)
timestamp = utc_now()
self._store_player_action(
group, event_id, source_key, command, response, timestamp
)
self.connection.execute("COMMIT")
return response
except BaseException:
self.connection.execute("ROLLBACK")
raise
def apply_player_action( def apply_player_action(
self, group: str, sender: str, event_id: str, command: str, self, group: str, sender: str, event_id: str, command: str,
action: Callable[[dict[str, Any]], str], action: Callable[[dict[str, Any]], str],
@@ -4,12 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-official-extensions" name = "jwebot-official-extensions"
version = "0.6.8a0+1015" version = "0.6.9a0+1015"
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==0.6.8a0+1015", "jwebot-sdk==0.6.9a0+1015",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.setuptools.package-data]
jwebot_official_extensions = ["data/cs_autochess/*.json"]
@@ -90,4 +90,4 @@ __all__ = [
"render_feature_list", "render_feature_list",
] ]
__version__ = "0.6.8a0+1015" __version__ = "0.6.9a0+1015"
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-transfer."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-transfer'
AUTHOR = 'jwebot'
DESCRIPTION = '金币转账'
HELP_ENTRIES = ('/转账 对方 金额;/transfer',)
COMMANDS = ('/转账', '/transfer')
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))
@@ -5,8 +5,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games' PROFILE = 'single-player-games'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点' DESCRIPTION = '金币游戏'
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games') HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,185 @@
{
"coaches": [
{
"bonus": 9,
"clutch": 88,
"firepower": 78,
"id": "zonic",
"level": 5,
"name": "zonic",
"stability": 92,
"style": "冠军体系"
},
{
"bonus": 9,
"clutch": 84,
"firepower": 72,
"id": "blade",
"level": 5,
"name": "B1ad3",
"stability": 96,
"style": "结构控图"
},
{
"bonus": 8,
"clutch": 80,
"firepower": 86,
"id": "xtqzzz",
"level": 4,
"name": "XTQZZZ",
"stability": 78,
"style": "进攻提速"
},
{
"bonus": 8,
"clutch": 90,
"firepower": 76,
"id": "neo",
"level": 4,
"name": "NEO",
"stability": 82,
"style": "残局纪律"
},
{
"bonus": 7,
"clutch": 82,
"firepower": 80,
"id": "saw",
"level": 3,
"name": "sAw",
"stability": 80,
"style": "临场调整"
},
{
"bonus": 7,
"clutch": 78,
"firepower": 74,
"id": "robban",
"level": 3,
"name": "RobbaN",
"stability": 86,
"style": "更衣室管理"
},
{
"bonus": 6,
"clutch": 76,
"firepower": 84,
"id": "kane",
"level": 2,
"name": "kane",
"stability": 72,
"style": "明星激活"
},
{
"bonus": 6,
"clutch": 74,
"firepower": 70,
"id": "trace",
"level": 2,
"name": "trace",
"stability": 86,
"style": "防守复盘"
},
{
"bonus": 5,
"clutch": 72,
"firepower": 76,
"id": "ash",
"level": 1,
"name": "ash",
"stability": 74,
"style": "新人培养"
},
{
"bonus": 5,
"clutch": 70,
"firepower": 70,
"id": "mithr",
"level": 1,
"name": "mithR",
"stability": 78,
"style": "基础纪律"
},
{
"bonus": 8,
"clutch": 82,
"firepower": 74,
"id": "kuben",
"level": 4,
"name": "kuben",
"stability": 88,
"style": "慢控默认"
},
{
"bonus": 7,
"clutch": 78,
"firepower": 72,
"id": "groove",
"level": 3,
"name": "groove",
"stability": 84,
"style": "学院体系"
},
{
"bonus": 8,
"clutch": 80,
"firepower": 86,
"id": "guerri",
"level": 4,
"name": "guerri",
"stability": 76,
"style": "巴西爆弹"
},
{
"bonus": 8,
"clutch": 82,
"firepower": 76,
"id": "xizt",
"level": 4,
"name": "Xizt",
"stability": 86,
"style": "默认纪律"
},
{
"bonus": 7,
"clutch": 78,
"firepower": 80,
"id": "sycrone",
"level": 3,
"name": "sycrone",
"stability": 80,
"style": "青年提拔"
},
{
"bonus": 7,
"clutch": 80,
"firepower": 82,
"id": "malek",
"level": 3,
"name": "maLeK",
"stability": 78,
"style": "明星平衡"
},
{
"bonus": 6,
"clutch": 76,
"firepower": 78,
"id": "peacemaker",
"level": 2,
"name": "peacemaker",
"stability": 74,
"style": "临时救火"
},
{
"bonus": 6,
"clutch": 74,
"firepower": 72,
"id": "kassad",
"level": 2,
"name": "kassad",
"stability": 82,
"style": "纪律重建"
}
],
"schema_version": 1
}
@@ -0,0 +1,163 @@
{
"fragments": {
"challenges": [
"媒体日问题很尖锐,队伍需要在镜头前保持状态。",
"赞助商临时加码,要求队伍用指定战术打出观赏性。",
"主办方安排了签名会,状态管理成了额外考验。",
"训练馆网络短暂波动,队伍必须快速适应备用机器。",
"赛前采访拖长,指挥需要把注意力重新拉回比赛。",
"赞助商想看激进打法,但稳住节奏的人会拿到更高回报。",
"媒体日不是战斗回合,却会影响队伍的运营资源。",
"队伍如果阵容完整,就更容易把这次插曲处理成奖金。",
"这次挑战只出现一次,处理完就回到小组赛主线。",
"主舞台彩排占用了热身时间,教练的管理能力开始生效。",
"赞助商要求展示新战术,选错方向也不会淘汰,只会少拿金币。",
"现场采访提到了前一场失利,队员心态需要稳住。",
"这类挑战不扣生命,目的只是给运营阶段增加变数。",
"处理好这次插曲的队伍,会在下一场前多一点经济余地。"
],
"closers": [
"{team} 没有给翻盘窗口,最后一波回防处理得很完整。",
"{team} 在赛点局选择最熟悉的默认,用纪律结束地图。",
"{team} 最后一回合没有贪枪,稳稳把人数优势转成胜势。",
"{team} 的收官很干净,地图尾声没有出现多余失误。",
"{team} 用一波双拉打开包点,最后比分定格。",
"{team} 把比赛拖进自己舒服的节奏,收尾阶段非常稳。",
"{team} 赛点局的道具层次很清楚,对手没有舒服的回防路线。",
"{team} 终于等到对手前压,抓住这个窗口终结地图。",
"{team} 收下最后一分时没有冒进,这就是强队式结尾。",
"{team} 最后一枪来得很快,但前面的信息铺垫已经足够。",
"{team} 在关键经济局之后顺势收掉地图。",
"{team} 先控退路再进包点,结尾没有留下残局悬念。",
"{team} 把上半场积累的优势守到最后。",
"{team} 用一波慢夹收官,比分看起来合理,过程更像真实比赛。",
"{team} 没让对手连追,赛点局直接把节奏抢回来。",
"{team} 的最后一分赢在补枪,枪线没有断。",
"{team} 收官回合把每个角落都清干净,耐心给了回报。",
"{team} 把悬念留到最后一刻,但关键枪没有手软。"
],
"clutches": [
"{player} 留到最后一个残局,靠一颗烟把拆包时间偷了出来。",
"{player} 没有急着补枪,等脚步声靠近后才横拉收掉残局。",
"{player} 在 1v2 里先断退路,再回头处理包点。",
"{player} 的残局处理很冷,假拆骗出第一枪后直接反清。",
"{player} 被逼到死角,但一颗反弹闪把局面重新打开。",
"{player} 没有追击残血,而是先保住雷包,判断非常老练。",
"{player} 关键回合双杀,把本来要崩的经济线救回来。",
"{player} 在读秒阶段完成补枪,队友的拉扯给得刚刚好。",
"{player} 先拿信息再开枪,这个残局赢在耐心。",
"{player} 硬吃白光完成换人,比分没有被对手咬住。",
"{player} 从连接位摸出,正好打掉回防节奏。",
"{player} 这次不追求集锦,只把枪线封死等队友到位。",
"{player} 的狙击枪没有空枪,关键首杀让整回合变得简单。",
"{player} 残血仍然敢看第一枪位,队伍因此拿到赛点。",
"{player} 拿下残局后没有庆祝,马上提醒队友保枪经济。",
"{player} 靠脚步假信息骗出转点,最后一枪很稳。",
"{player} 在包点里连续换位,对手始终没能定位。",
"{player} 把闪光背身吃完后仍然补到第二枪,这是关键转折。",
"{player} 一个人拖住整条回防路线,为队伍争到拆包窗口。",
"{player} 最后选择不 peek,让时间替自己赢下这一分。"
],
"economy": [
"{team} 赢下这一分后可以完整起枪,下一回合选择会更多。",
"{team} 输掉长枪局后经济见底,可能要交出一回合调整空间。",
"{team} 没有强行追梦,选择保枪把下一回合留住。",
"{team} 的连败补偿到位,下一回合有机会靠道具质量反打。",
"{team} 这回合缴下两把枪,经济收益比比分更重要。",
"{team} 虽然丢分,但保下主狙,下一回合还有破局点。",
"{team} 的半甲手枪没有打成,但至少逼出了对手不少道具。",
"{team} 经济优势开始显现,可以更大胆地争夺第一枪位。",
"{team} 如果下一分再丢,就必须进入半起调整。",
"{team} 这一波选择全甲全弹,明显是要在中期一次打穿。",
"{team} 保下来的两把枪让下回合不至于完全被动。",
"{team} 连续换枪成功,把对手的存款一点点磨掉。",
"{team} 用低成本回合换到下包,经济线还没有断。",
"{team} 这波赢的不只是分数,还有下一回合的主动权。",
"{team} 被重置经济后节奏放慢,先求稳住半场尾声。",
"{team} 靠残局胜利拿到额外空间,可以考虑继续 D 牌补强。"
],
"openings": [
"{team} 开局没有急着提速,先把默认控图铺开。",
"{team} 第一波提速很坚决,外场道具落点干净。",
"{team} 在手枪局后选择慢摸,试图逼出对手的前压信息。",
"{team} 的狙击手早早架住第一交火点,节奏被压得很低。",
"{team} 连续用两颗闪光清掉近点,进攻面一下打开。",
"{team} 防守开局站位很深,把回防路线留得很完整。",
"{team} 先拿地图控制权,再把包点压力一点点推上去。",
"{team} 开局没有赌点,靠信息互换慢慢读阵型。",
"{team} 在第一枪位赢下后没有贪,马上回收人数优势。",
"{team} 用一波假打骗出轮转,第二时间才开始真正爆弹。",
"{team} 选择保守开局,宁愿少拿信息也不送首杀。",
"{team} 的指挥把默认拉得很散,让对手很难判断重心。",
"{team} 手枪局道具很少,但站位互补做得很扎实。",
"{team} 没有被前几回合影响,依旧按自己的节奏展开。",
"{team} 的第一套进攻明显针对了对手弱侧。",
"{team} 在经济局也保留了两颗关键闪,准备打后点反清。",
"{team} 上来就抢中路,想用主动权换后续转点空间。",
"{team} 先把狙击枪放在安全位,围绕它慢慢组织推进。"
],
"pauses": [
"比分被拉开后,双方都慢了半拍,像是在重新读这张图。",
"这里适合停一下,刚才几回合的经济差已经开始影响下一枪。",
"暂停回来之后,防守方明显把第一接触点后撤了半格。",
"连续交火后节奏突然变慢,双方都在等对方先犯错。",
"这一段没有夸张集锦,但每一颗道具都在改变下一回合。",
"场面安静了几秒,真正决定胜负的是下一次首杀。",
"队伍语音应该都在压节奏,没人愿意在这里送掉经济。",
"这不是简单的比分变化,战术重心已经从爆弹转到控图。",
"现场进入短暂拉扯,地图控制权一点点被重新分配。",
"两边都知道下一分很贵,所以这回合开局格外谨慎。",
"这一小段空白很重要,强队往往就是在这里调整默认。",
"比赛没有马上进入下一波交火,指挥正在重新排优先级。",
"刚才的连分让对手不得不换站位,防线开始出现试探。",
"经济压力让双方都少了一点容错,下一波道具会很关键。",
"这里没有急躁的前压,只有一点点互相试探。",
"暂停不是为了拖时间,而是在给下一套进攻换答案。",
"双方都把节奏压住,地图像被重新归零。",
"这一分之前的几秒钟很长,所有人都在等第一个脚步声。",
"场上没有无意义的 peek,每个人都在守自己的触发条件。",
"这段过渡让比赛更像真正的 BO 系列赛,而不是单纯掷骰子。"
],
"runs": [
"{team} 连拿 {count} 分之后,现场短暂安静下来。",
"{team} 把经济滚起来后,连续 {count} 个回合没有给对手完整开枪空间。",
"{team} 这一波 {count} 连分不是纯枪法碾压,而是每回合都换到正确信息。",
"{team} 的节奏忽快忽慢,{count} 个回合里每次进点方式都不一样。",
"{team} 靠补枪链吃下 {count} 分,队伍语音应该已经很热。",
"{team} 的中期处理开始成型,连续 {count} 分把对手钱打空。",
"{team} 没有急着结束比赛,而是用 {count} 连分把地图控制牢牢握住。",
"{team} 这段 {count} 连分很关键,尤其是第二个残局稳定了军心。",
"{team} 在暂停之后突然提速,直接打出 {count} 连分。",
"{team} 先抓前压,再抓回防,{count} 个回合把读秒处理做满。",
"{team} 的防守交叉火力越站越舒服,{count} 连分让比分拉开。",
"{team} 把对手的道具节奏读透,连续 {count} 分都避开了主防区。",
"{team} 经济起飞后没有乱买,{count} 连分中枪线一直保持完整。",
"{team} 在被首杀的回合仍然能翻,{count} 连分含金量很高。",
"{team} 的默认推进开始收利息,{count} 个回合每次都能换到便宜。",
"{team} 靠一波小连胜把压力交回去,{count} 分之后对手必须叫停。",
"{team} 的二楼夹击反复奏效,{count} 连分让对手开始犹豫。",
"{team} 连续 {count} 回合把残局拖到优势人数,后点纪律很稳。",
"{team} 打出 {count} 连分后节奏放慢,像是在等对手自己露破绽。",
"{team} 这波 {count} 连分让地图气质变了,所有枪位都开始往前压。"
],
"training_hooks": [
"训练赛不计入小组胜负,但会发金币,方便后续刷新商店。",
"这轮训练更像实战热身,每队都会遇到同场参赛对手。",
"训练赛重点不是淘汰谁,而是检验当前阵容能不能打完整张图。",
"这轮结束后可以 D 牌、锁店或调首发,下一轮再检验成果。",
"AI 战队也会参与训练,对局结果会写入档案。",
"如果阵容缺少指挥,训练赛的中期回合会更容易失控。",
"如果你想追羁绊,现在是最适合刷新商店的窗口。",
"训练赛的金币很重要,别把每次刷新都当成免费试错。",
"这一阶段没有生命扣除,重点是运营和阵容成型。",
"训练赛的地图会轮换,不会一直用同一个固定比分模板。",
"每轮训练结束后,真人都需要发送 /结束回合 才会进入下一轮。",
"训练赛会按轮次播报,避免一次性刷出一大串结果。",
"如果出现轮空,会安排本地陪练队补一张 BO1。",
"训练阶段适合观察战术克制,火力、稳定、残局都会影响回合走向。",
"连胜会带来金币,但失败也有保底,保证测试局能继续运营。",
"这部分是纯文字自走棋的节奏层,不再是单条目标数值判定。"
]
},
"schema_version": 1
}
@@ -0,0 +1,309 @@
{
"ai": {
"power_multiplier": 0.82,
"primary_display_name": "AI 战队",
"primary_person_key": "__local_ai_team__",
"secondary_display_name": "AI 战队 B",
"secondary_person_key": "__local_ai_team_2__"
},
"battle": {
"damage": {
"bo5_sweep": 18,
"non_sweep": 10,
"sweep": 15
},
"maps": [
"Mirage",
"Inferno",
"Nuke",
"Ancient",
"Dust2",
"Anubis",
"Overpass"
],
"match_best_of": {
"final": 5,
"group": 3,
"semifinal": 5,
"training": 1
},
"mr_rounds": 30,
"overtime_safety_cap": 72,
"overtime_segment_rounds": 6,
"overtime_start_score": 15,
"overtime_target_score": 19,
"overtime_win_by": 2,
"round_probability": {
"base": 0.5,
"first_half_side_swing": 0.018,
"jitter_center": 350,
"jitter_divisor": 10000,
"jitter_mod": 701,
"max": 0.68,
"min": 0.32,
"power_edge_cap": 0.16,
"power_edge_scale": 0.3,
"pressure_cap": 0.04,
"pressure_scale": 0.004,
"second_half_side_swing": -0.012
},
"score_bands": [
{
"label": "压制",
"max_loser_score": 6
},
{
"label": "优势",
"max_loser_score": 10
},
{
"label": "小胜",
"max_loser_score": 13
},
{
"label": "险胜",
"max_loser_score": 999
}
],
"target_score": 16
},
"coach": {
"fallback_bonuses": {
"1": 2,
"2": 4,
"3": 6,
"4": 8,
"5": 10
},
"upgrade_costs": {
"1": 6,
"2": 10,
"3": 16,
"4": 24
}
},
"draft": {
"coach_choice_count": 10,
"coach_round": 1,
"later_round_preferred_costs": [
3,
2,
4,
1,
5
],
"player_choice_count": 6,
"player_copy_count": 3,
"round_two_preferred_costs": [
2,
3,
1,
4,
5
],
"rounds": 3
},
"economy": {
"round_income": {
"base": 5,
"interest_cap": 5,
"interest_divisor": 10
},
"series_rewards": {
"group_win_gold": 5,
"playoff_win_gold": 6
},
"starting_gold": 10,
"starting_hp": 100,
"training_rewards": {
"bye_gold": 2,
"loss_gold": 1,
"win_gold": 3
}
},
"pve_events": {
"pve_media": {
"loss_gold": 3,
"name": "媒体日挑战",
"target": 335,
"win_gold": 6
},
"pve_sponsor": {
"loss_gold": 4,
"name": "赞助挑战",
"target": 395,
"win_gold": 8
},
"pve_training": {
"loss_gold": 2,
"name": "训练赛",
"target": 275,
"win_gold": 4
}
},
"roster": {
"bench_limit": 3,
"nation_synergy": {
"max_sources": 1,
"thresholds": [
{
"bonus": 18,
"min_count": 5
},
{
"bonus": 12,
"min_count": 4
},
{
"bonus": 7,
"min_count": 3
},
{
"bonus": 3,
"min_count": 2
}
]
},
"star_multipliers": {
"1": 1.0,
"2": 1.35,
"3": 1.8
},
"starter_count": 5,
"team_synergy": {
"max_sources": 2,
"thresholds": [
{
"bonus": 22,
"min_count": 5
},
{
"bonus": 15,
"min_count": 4
},
{
"bonus": 9,
"min_count": 3
},
{
"bonus": 4,
"min_count": 2
}
]
},
"unit_power_weights": {
"clutch": 0.25,
"firepower": 0.45,
"leadership_scale": 0.12,
"leadership_threshold": 70,
"stability": 0.3
}
},
"schema_version": 1,
"shop": {
"cost_pool_copies": {
"1": 18,
"2": 15,
"3": 12,
"4": 9,
"5": 7
},
"odds": {
"1": {
"1": 70,
"2": 25,
"3": 5,
"4": 0,
"5": 0
},
"2": {
"1": 50,
"2": 30,
"3": 18,
"4": 2,
"5": 0
},
"3": {
"1": 30,
"2": 35,
"3": 25,
"4": 9,
"5": 1
},
"4": {
"1": 15,
"2": 30,
"3": 30,
"4": 20,
"5": 5
},
"5": {
"1": 5,
"2": 20,
"3": 30,
"4": 30,
"5": 15
}
},
"refresh_cost": 2,
"size": 6
},
"special_events": {
"media": {
"loss_gold": 1,
"name": "媒体日挑战",
"target": 320,
"win_gold": 4
},
"sponsor": {
"loss_gold": 1,
"name": "赞助挑战",
"target": 340,
"win_gold": 4
}
},
"tactics": {
"attribute_counters": [
[
"稳定",
"火力"
],
[
"火力",
"残局"
],
[
"残局",
"稳定"
]
],
"axis_percent_cap": 8.0,
"axis_percent_divisor": 12.5,
"counters": {
"残局": "稳定",
"火力": "残局",
"稳定": "火力"
},
"names": [
"火力",
"稳定",
"残局"
],
"role_counters": {
"指挥": "狙击手",
"步枪手": "指挥",
"狙击手": "步枪手"
},
"role_percent_cap": 5.0,
"role_percent_scale": 0.8,
"selected_axis_multiplier": 1.08,
"tactic_counter_percent": 6.0,
"total_percent_cap": 15.0
},
"tournament": {
"event_title_template": "第 {season} 届 IEM 盐田 Major",
"host_name": "Taffy",
"max_players": 8,
"min_players": 2
}
}
@@ -42,6 +42,8 @@ LOCAL_COMMAND_ALIASES = {
"context": "context_summary", "context": "context_summary",
"金币": "wallet", "金币": "wallet",
"wallet": "wallet", "wallet": "wallet",
"转账": "transfer",
"transfer": "transfer",
"抽奖": "prize_draw", "抽奖": "prize_draw",
"draw": "prize_draw", "draw": "prize_draw",
"彩票": "lottery", "彩票": "lottery",
@@ -138,12 +140,13 @@ LOCAL_EXTENSIONS = (
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="single-player-games", extension_id="single-player-games",
author=JWEBOT_AUTHOR, author=JWEBOT_AUTHOR,
description="虚拟金币账户、抽奖、群内多人彩票和单人21点", description="金币游戏",
help_entries=( help_entries=(
"/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory", "/金币 /wallet;/游戏记录 /gamehistory",
"/彩票 开始|报名|当前状态|开奖|取消;/lottery", "/抽奖 [金额];/draw",
"/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)", "/彩票 开始|报名|状态|开奖|取消;/lottery",
"/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", "/21点 开始 [1~500];/blackjack start",
"/21点 要牌|停牌|状态|结束;/blackjack",
"/游戏帮助;/games", "/游戏帮助;/games",
), ),
command_kinds=( command_kinds=(
@@ -155,6 +158,13 @@ LOCAL_EXTENSIONS = (
"game_rules", "game_rules",
), ),
), ),
LocalExtensionDescriptor(
extension_id="coin-transfer",
author=JWEBOT_AUTHOR,
description="金币转账",
help_entries=("/转账 对方 金额;/transfer",),
command_kinds=("transfer",),
),
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="cs-major-autochess", extension_id="cs-major-autochess",
author=JWEBOT_AUTHOR, author=JWEBOT_AUTHOR,
@@ -154,6 +154,7 @@ class LocalCommandEngine:
return self.reply_cleaner(self.person_index(row, kind, arguments)) return self.reply_cleaner(self.person_index(row, kind, arguments))
if kind in { if kind in {
"wallet", "wallet",
"transfer",
"prize_draw", "prize_draw",
"lottery", "lottery",
"blackjack", "blackjack",
@@ -16,6 +16,8 @@ MAX_LOTTERY_BET = 500
DEFAULT_LOTTERY_BET = 10 DEFAULT_LOTTERY_BET = 10
MIN_BLACKJACK_BET = 1 MIN_BLACKJACK_BET = 1
MAX_BLACKJACK_BET = 500 MAX_BLACKJACK_BET = 500
MIN_TRANSFER_AMOUNT = 1
MAX_TRANSFER_AMOUNT = 100_000
GAME_TIMEOUT_SECONDS = 600 GAME_TIMEOUT_SECONDS = 600
GAME_HISTORY_LIMIT = 50 GAME_HISTORY_LIMIT = 50
GAME_HISTORY_PAGE_SIZE = 5 GAME_HISTORY_PAGE_SIZE = 5
@@ -41,6 +43,11 @@ BLACKJACK_COMBINED_ACTION = re.compile(
r"^(?:开始|start)(?P<bet>\d{1,3})?" r"^(?:开始|start)(?P<bet>\d{1,3})?"
r"(?:要牌|hit|停牌|stand)$" r"(?:要牌|hit|停牌|stand)$"
) )
TRANSFER_MARKED = re.compile(
r"^(?:给|to)?\s*(?P<target>.+?)\s*"
r"(?:金额|金币|coin|coins|:|:)\s*(?P<amount>\d{1,6})$",
re.IGNORECASE,
)
def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]: def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
@@ -128,6 +135,8 @@ class PlayerGames:
def rules() -> str: def rules() -> str:
return ( return (
"每人首次使用获 1000 虚拟金币;/金币 查看余额;/游戏记录 查看本人记录。\n" "每人首次使用获 1000 虚拟金币;/金币 查看余额;/游戏记录 查看本人记录。\n"
"转账:/转账 对方 金额,或 /transfer name amount;"
"对方需先用 /金币 初始化账户。\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;"
@@ -137,7 +146,7 @@ class PlayerGames:
"(/blackjack hit|stand|status|end);" "(/blackjack hit|stand|status|end);"
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n" "每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。" "21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
"以上均含本金;虚拟金币不能充值、转账或兑换。" "以上均含本金;虚拟金币不能充值或兑换。"
) )
def handle(self, row: dict[str, Any], kind: str, arguments: str) -> str: def handle(self, row: dict[str, Any], kind: str, arguments: str) -> str:
@@ -150,6 +159,8 @@ class PlayerGames:
return self.game_history(row, arguments) return self.game_history(row, arguments)
if kind == "lottery": if kind == "lottery":
return self.group_lottery(row, arguments) return self.group_lottery(row, arguments)
if kind == "transfer":
return self.transfer(row, arguments)
if kind == "wallet" and arguments: if kind == "wallet" and arguments:
return "用法:/金币(/wallet),查看本人的虚拟金币;/游戏帮助 查看规则" return "用法:/金币(/wallet),查看本人的虚拟金币;/游戏帮助 查看规则"
if kind == "prize_draw": if kind == "prize_draw":
@@ -283,6 +294,78 @@ 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)
@staticmethod
def parse_transfer(
arguments: str,
) -> tuple[str, int] | None:
value = arguments.strip()
if not value:
return None
marked = TRANSFER_MARKED.fullmatch(value)
if marked is not None:
target = marked["target"]
amount = int(marked["amount"])
else:
tokens = value.split()
if len(tokens) < 2:
return None
if re.fullmatch(r"\d{1,6}", tokens[0]):
amount = int(tokens[0])
target = " ".join(tokens[1:])
elif re.fullmatch(r"\d{1,6}", tokens[-1]):
amount = int(tokens[-1])
target = " ".join(tokens[:-1])
else:
return None
target = re.sub(r"^(?:给|to)\s*", "", target.strip(), flags=re.IGNORECASE)
target = target.lstrip("@@").strip()
if not target:
return None
return target, amount
def transfer(self, row: dict[str, Any], arguments: str) -> str:
parsed = self.parse_transfer(arguments)
if parsed is None:
return (
"用法:/转账 对方 金额,例如 /转账 Bob 100;"
"英文 /transfer Bob 100。金额范围 1~100000,"
"对方需先发送 /金币 初始化账户"
)
target, amount = parsed
if not MIN_TRANSFER_AMOUNT <= amount <= MAX_TRANSFER_AMOUNT:
return "转账金额必须为 1~100000 的整数金币"
def render(data: dict[str, Any]) -> str:
source = data["source_display_name"]
target_name = data["target_display_name"]
if data["status"] == "self":
return f"{source}:不能给自己转账"
if data["status"] == "target_missing":
return (
f"{source}:{target_name} 还没有金币账户,"
"请先让对方发送 /金币 初始化"
)
if data["status"] == "insufficient":
return (
f"{source}:金币不足,余额 {data['source_balance']},"
f"本次需要 {data['amount']}"
)
return (
f"{source}:已向 {target_name} 转账 {data['amount']} 金币;"
f"本人金币 {data['source_balance']};"
f"{target_name} 金币 {data['target_balance']}"
)
return self.ledger.transfer_player_coins(
str(row["group_fingerprint"]),
str(row["sender"]),
str(row["event_id"]),
"transfer:" + arguments,
target,
amount,
render,
)
@staticmethod @staticmethod
def parse_lottery_entry( def parse_lottery_entry(
arguments: str, arguments: str,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-sdk" name = "jwebot-sdk"
version = "0.6.8a0+1015" version = "0.6.9a0+1015"
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__ = "0.6.8a0+1015" __version__ = "0.6.9a0+1015"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot] [tool.jwebot]
release = "alpha 0.6.8.1015" release = "alpha 0.6.9.1015"
production_deployments_enabled_by_default = false production_deployments_enabled_by_default = false
[tool.jwebot.workspace] [tool.jwebot.workspace]
@@ -90,6 +90,7 @@ class LocalExtensionInventoryTests(unittest.TestCase):
"debug-telemetry": OFFICIAL_AUTHOR, "debug-telemetry": OFFICIAL_AUTHOR,
"dice": JWEBOT_AUTHOR, "dice": JWEBOT_AUTHOR,
"coin": JWEBOT_AUTHOR, "coin": JWEBOT_AUTHOR,
"coin-transfer": 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,
+28
View File
@@ -191,6 +191,34 @@ class Release063GameTests(unittest.TestCase):
self.assertIn("alice #2:抽奖 未中奖", history) self.assertIn("alice #2:抽奖 未中奖", history)
self.assertIn("alice #1:抽奖 未中奖", history) self.assertIn("alice #1:抽奖 未中奖", history)
def test_transfer_requires_initialized_target_and_replays_once(self):
self.assertIn("金币 1000", self.command("/金币"))
missing = self.command("/转账 Bob 100", event_id="missing-target")
self.assertIn("bob 还没有金币账户", missing)
self.assertEqual(self.state()["balance"], 1000)
self.assertIn("金币 1000", self.command("/金币", sender="Bob"))
first = self.command("/转账 Bob 250", event_id="transfer-1")
replay = self.command(
"/转账 Bob 250",
sender="a1",
event_id="transfer-1",
)
self.assertEqual(replay, first)
self.assertIn("alice:已向 bob 转账 250 金币", first)
self.assertEqual(self.state()["balance"], 750)
self.assertEqual(self.state("Bob")["balance"], 1250)
def test_transfer_rejects_invalid_self_and_insufficient_cases(self):
self.assertIn("金币 1000", self.command("/金币"))
self.assertIn("转账金额必须为 1~100000", self.command("/转账 Bob 0"))
self.assertIn("不能给自己转账", self.command("/转账 a1 100"))
self.assertIn("金币 1000", self.command("/金币", sender="Bob"))
self.assertIn("金币不足", self.command("/transfer 1001 Bob"))
self.assertEqual(self.state()["balance"], 1000)
self.assertEqual(self.state("Bob")["balance"], 1000)
def test_open_round_and_history_survive_restart(self): def test_open_round_and_history_survive_restart(self):
root = Path(self.temp.name) root = Path(self.temp.name)
self.command("/彩票开始") self.command("/彩票开始")
+30
View File
@@ -6,6 +6,7 @@ import json
import re import re
import tempfile import tempfile
import unittest import unittest
from importlib import resources
from pathlib import Path from pathlib import Path
from test_jwebot_edge import MODULE, config from test_jwebot_edge import MODULE, config
@@ -161,6 +162,35 @@ class Release067AutochessTests(unittest.TestCase):
self.assertEqual(new_state["season_number"], 2) self.assertEqual(new_state["season_number"], 2)
self.assertEqual(new_state["status"], "registration") self.assertEqual(new_state["status"], "registration")
def test_autochess_data_files_are_externalized_and_loaded(self):
data_root = resources.files(AUTOCHESS.__package__).joinpath(
AUTOCHESS.DATA_ROOT
)
payloads = {}
for filename in AUTOCHESS.DATA_FILES:
path = data_root.joinpath(filename)
self.assertTrue(path.is_file(), filename)
payload = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(payload["schema_version"], 1)
payloads[filename] = payload
self.assertEqual(
len(payloads["players.json"]["players"]),
len(AUTOCHESS.PLAYER_POOL),
)
self.assertEqual(
len(payloads["coaches.json"]["coaches"]),
len(AUTOCHESS.COACH_POOL),
)
narrative_count = sum(
len(items)
for items in payloads["narratives.json"]["fragments"].values()
)
self.assertEqual(narrative_count, AUTOCHESS._narrative_inventory_size())
self.assertGreaterEqual(narrative_count, 120)
self.assertEqual(AUTOCHESS.MATCH_BEST_OF["group"], 3)
self.assertEqual(AUTOCHESS.MATCH_BEST_OF["final"], 5)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()