diff --git a/CHANGELOG.md b/CHANGELOG.md index 5060773..d7a0ad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # 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 - Add an atomic compatibility path for old active CS autochess runs. When a diff --git a/README.md b/README.md index d1b33cd..717a345 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 并通过带视觉确认的 GUI 发送链路回复。 -当前版本:`alpha 0.6.8.1015`。 +当前版本:`alpha 0.6.9.1015`。 0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加 两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见 @@ -36,6 +36,11 @@ D 牌、锁店、调阵容和战术窗口会在轮次之间自然提示。 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) /人物列表 [页码](/people);/人物 [名称或别名](/person) /金币(/wallet);/抽奖 [金额](/draw) +/转账 对方 金额(/transfer name amount) /彩票 开始|报名|当前状态|开奖|取消(/lottery) /21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 /游戏记录 [页码](/gamehistory) @@ -278,7 +284,7 @@ visually confirmed GUI bridge. Current version: ```text -alpha 0.6.8.1015 +alpha 0.6.9.1015 ``` ## 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` single-player virtual coin games. Chinese aliases share the same implementations. Accounts receive 1000 virtual coins once; funds, game state and event replay -results commit together. OCR display aliases identify group-scoped people; -native WeChat account IDs are unavailable. See +results commit together. `/transfer name amount` moves virtual coins between +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). 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 diff --git a/VERSION b/VERSION index d08e84a..168a7de 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -alpha 0.6.8.1015 +alpha 0.6.9.1015 diff --git a/docs/cs-major-autochess-design.md b/docs/cs-major-autochess-design.md index 5df60d0..c2f2439 100644 --- a/docs/cs-major-autochess-design.md +++ b/docs/cs-major-autochess-design.md @@ -81,6 +81,12 @@ 选手数据不是对真实比赛数据的一比一复刻,而是基于公众认知做游戏化映射。 0.6.7 起本地维护 `100+` 名棋子和 `18` 名教练元数据;运行期不在线抓取 或改写数值。首轮教练选秀仍固定展示 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 费用层级 diff --git a/docs/local-plugins-0.6.1.md b/docs/local-plugins-0.6.1.md index 6ea50ce..3ebf2ed 100644 --- a/docs/local-plugins-0.6.1.md +++ b/docs/local-plugins-0.6.1.md @@ -8,6 +8,7 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写, |---|---|---| | context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` | | single-player-games | `/金币` | `/wallet` | +| coin-transfer | `/转账 对方 金额` | `/transfer name amount` | | single-player-games | `/抽奖 [金额]` | `/draw [金额]` | | single-player-games | `/彩票 开始/报名/当前状态/开奖/取消` | `/lottery start/join/status/draw/cancel` | | single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) | @@ -49,8 +50,9 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使 仓库没有生产人物或映射文件。 昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域; -应优先追加别名而不是重命名。用户不能通过游戏命令指定他人的账户, -不能充值、转账、赠币或兑换真实资产。 +应优先追加别名而不是重命名。除 `/转账` 外,用户不能通过游戏命令 +指定他人的账户;转账只支持同群虚拟金币账户,收款人需先发送 `/金币` +初始化。虚拟金币不能充值、赠币或兑换真实资产。 ## 虚拟金币与游戏管理 @@ -58,6 +60,11 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使 重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和 21 点下注为 1~500;余额不足或参数非法不扣款。 +`/转账 对方 金额` 会把本人的虚拟金币转给同群已初始化账户,英文为 +`/transfer name amount`。金额范围 1~100000;不允许给自己转账。 +转账、响应记录和重试去重写在同一个 ledger 事务内,同一 event_id +不会重复扣款。 + 抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。 alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关, 下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见 diff --git a/extensions/jwebot/coin-transfer/README.md b/extensions/jwebot/coin-transfer/README.md new file mode 100644 index 0000000..8d5d6e5 --- /dev/null +++ b/extensions/jwebot/coin-transfer/README.md @@ -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`. diff --git a/extensions/jwebot/coin-transfer/plugin.toml b/extensions/jwebot/coin-transfer/plugin.toml new file mode 100644 index 0000000..4e8b9f9 --- /dev/null +++ b/extensions/jwebot/coin-transfer/plugin.toml @@ -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"] diff --git a/extensions/jwebot/coin-transfer/src/coin_transfer.py b/extensions/jwebot/coin-transfer/src/coin_transfer.py new file mode 100644 index 0000000..532fa19 --- /dev/null +++ b/extensions/jwebot/coin-transfer/src/coin_transfer.py @@ -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)) diff --git a/extensions/jwebot/coin-transfer/tests/test_plugin.py b/extensions/jwebot/coin-transfer/tests/test_plugin.py new file mode 100644 index 0000000..6a12a24 --- /dev/null +++ b/extensions/jwebot/coin-transfer/tests/test_plugin.py @@ -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() diff --git a/extensions/jwebot/single-player-games/README.md b/extensions/jwebot/single-player-games/README.md index 8280e60..37cce13 100644 --- a/extensions/jwebot/single-player-games/README.md +++ b/extensions/jwebot/single-player-games/README.md @@ -2,14 +2,15 @@ Author: `jwebot` -虚拟金币账户、抽奖、群内多人彩票和单人21点 +金币游戏 Help: -- `/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory` -- `/彩票 开始|报名|当前状态|开奖|取消;/lottery` -- `/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)` -- `/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end` +- `/金币 /wallet;/游戏记录 /gamehistory` +- `/抽奖 [金额];/draw` +- `/彩票 开始|报名|状态|开奖|取消;/lottery` +- `/21点 开始 [1~500];/blackjack start` +- `/21点 要牌|停牌|状态|结束;/blackjack` - `/游戏帮助;/games` Registered commands: diff --git a/extensions/jwebot/single-player-games/plugin.toml b/extensions/jwebot/single-player-games/plugin.toml index 4d0412f..26f5319 100644 --- a/extensions/jwebot/single-player-games/plugin.toml +++ b/extensions/jwebot/single-player-games/plugin.toml @@ -1,7 +1,7 @@ id = "single-player-games" author = "jwebot" -description = "虚拟金币账户、抽奖、群内多人彩票和单人21点" -help = ["/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory", "/彩票 开始|报名|当前状态|开奖|取消;/lottery", "/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)", "/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", "/游戏帮助;/games"] +description = "金币游戏" +help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 开始 [1~500];/blackjack start", "/21点 要牌|停牌|状态|结束;/blackjack", "/游戏帮助;/games"] version = "1.0.0" api_version = "1" entrypoint = "single_player_games:plugin" diff --git a/extensions/jwebot/single-player-games/src/single_player_games.py b/extensions/jwebot/single-player-games/src/single_player_games.py index 6114e2b..a568ecf 100644 --- a/extensions/jwebot/single-player-games/src/single_player_games.py +++ b/extensions/jwebot/single-player-games/src/single_player_games.py @@ -5,8 +5,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext PROFILE = 'single-player-games' AUTHOR = 'jwebot' -DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点' -HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games') +DESCRIPTION = '金币游戏' +HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games') COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') diff --git a/extensions/jwebot/single-player-games/tests/test_plugin.py b/extensions/jwebot/single-player-games/tests/test_plugin.py index 013ad2a..742d724 100644 --- a/extensions/jwebot/single-player-games/tests/test_plugin.py +++ b/extensions/jwebot/single-player-games/tests/test_plugin.py @@ -19,8 +19,8 @@ class PluginTests(unittest.TestCase): for call in context.commands.register.call_args_list ] self.assertEqual(AUTHOR, 'jwebot') - self.assertEqual(DESCRIPTION, '虚拟金币账户、抽奖、群内多人彩票和单人21点') - self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')) + self.assertEqual(DESCRIPTION, '金币游戏') + self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')) self.assertIn('/金币', registered) self.assertEqual(len(registered), 15) diff --git a/packages/jwebot-config/pyproject.toml b/packages/jwebot-config/pyproject.toml index b3a4940..635ec2a 100644 --- a/packages/jwebot-config/pyproject.toml +++ b/packages/jwebot-config/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-config" -version = "0.6.8a0+1015" +version = "0.6.9a0+1015" description = "Profile-based configuration compiler for JWeBot" requires-python = ">=3.9" dependencies = [ diff --git a/packages/jwebot-config/src/jwebot_config/__init__.py b/packages/jwebot-config/src/jwebot_config/__init__.py index d5f6150..1e9fd3b 100644 --- a/packages/jwebot-config/src/jwebot_config/__init__.py +++ b/packages/jwebot-config/src/jwebot_config/__init__.py @@ -36,4 +36,4 @@ __all__ = [ "write_default_profile", ] -__version__ = "0.6.8a0+1015" +__version__ = "0.6.9a0+1015" diff --git a/packages/jwebot-extension-host/pyproject.toml b/packages/jwebot-extension-host/pyproject.toml index 86ecb5f..fe698a1 100644 --- a/packages/jwebot-extension-host/pyproject.toml +++ b/packages/jwebot-extension-host/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-extension-host" -version = "0.6.8a0+1015" +version = "0.6.9a0+1015" description = "Single-release, single-invocation process host for JWeBot extensions" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.8a0+1015", - "jwebot-kernel==0.6.8a0+1015", + "jwebot-sdk==0.6.9a0+1015", + "jwebot-kernel==0.6.9a0+1015", ] [project.scripts] diff --git a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py index f7bf726..dfa0df5 100644 --- a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py +++ b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py @@ -16,4 +16,4 @@ __all__ = [ "process_request", ] -__version__ = "0.6.8a0+1015" +__version__ = "0.6.9a0+1015" diff --git a/packages/jwebot-kernel/pyproject.toml b/packages/jwebot-kernel/pyproject.toml index d3ec683..63d71da 100644 --- a/packages/jwebot-kernel/pyproject.toml +++ b/packages/jwebot-kernel/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-kernel" -version = "0.6.8a0+1015" +version = "0.6.9a0+1015" description = "Stable routing and extension authority for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-config==0.6.8a0+1015", - "jwebot-sdk==0.6.8a0+1015", - "jwebot-official-extensions==0.6.8a0+1015", + "jwebot-config==0.6.9a0+1015", + "jwebot-sdk==0.6.9a0+1015", + "jwebot-official-extensions==0.6.9a0+1015", "tomli>=2; python_version < '3.11'", ] diff --git a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py index 04342e5..6cbb35a 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py @@ -107,4 +107,4 @@ __all__ = [ "verify_installed_release_signature", ] -__version__ = "0.6.8a0+1015" +__version__ = "0.6.9a0+1015" diff --git a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py index 785cac8..177e968 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py @@ -3665,6 +3665,98 @@ class Ledger: "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( self, group: str, sender: str, event_id: str, command: str, action: Callable[[dict[str, Any]], str], diff --git a/packages/jwebot-official-extensions/pyproject.toml b/packages/jwebot-official-extensions/pyproject.toml index d830bc6..8f7ae74 100644 --- a/packages/jwebot-official-extensions/pyproject.toml +++ b/packages/jwebot-official-extensions/pyproject.toml @@ -4,12 +4,15 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-official-extensions" -version = "0.6.8a0+1015" +version = "0.6.9a0+1015" description = "Reviewed local command adapters and broker implementation for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.8a0+1015", + "jwebot-sdk==0.6.9a0+1015", ] [tool.setuptools.packages.find] where = ["src"] + +[tool.setuptools.package-data] +jwebot_official_extensions = ["data/cs_autochess/*.json"] diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py index 2ac3b3a..bf07804 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py @@ -90,4 +90,4 @@ __all__ = [ "render_feature_list", ] -__version__ = "0.6.8a0+1015" +__version__ = "0.6.9a0+1015" diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_transfer.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_transfer.py new file mode 100644 index 0000000..532fa19 --- /dev/null +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_transfer.py @@ -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)) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py index 6114e2b..a568ecf 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py @@ -5,8 +5,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext PROFILE = 'single-player-games' AUTHOR = 'jwebot' -DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点' -HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games') +DESCRIPTION = '金币游戏' +HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games') COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/cs_autochess.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/cs_autochess.py index 9b3419c..c18f660 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/cs_autochess.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/cs_autochess.py @@ -5,414 +5,409 @@ from __future__ import annotations import hashlib import json import re +from importlib import resources from typing import Any -MIN_PLAYERS = 2 -MAX_PLAYERS = 8 -AI_PERSON_KEY = "__local_ai_team__" -AI_DISPLAY_NAME = "AI 战队" -AI_SECOND_PERSON_KEY = "__local_ai_team_2__" -AI_SECOND_DISPLAY_NAME = "AI 战队 B" -HOST_NAME = "Taffy" -STARTING_GOLD = 10 -STARTING_HP = 100 -STARTER_COUNT = 5 -BENCH_LIMIT = 3 -SHOP_SIZE = 6 -REFRESH_COST = 2 -AI_POWER_MULTIPLIER = 0.82 +DATA_FILES = ("rules.json", "players.json", "coaches.json", "narratives.json") +DATA_ROOT = "data/cs_autochess" +MIN_NARRATIVE_FRAGMENTS = 120 MULTI_REPLY_MARKER = "\x1eJWEBOT_MULTI_REPLY_V1\x1e" -COACH_UPGRADE_COSTS = {1: 6, 2: 10, 3: 16, 4: 24} -COACH_BONUSES = {1: 2, 2: 4, 3: 6, 4: 8, 5: 10} -COST_POOL_COPIES = {1: 18, 2: 15, 3: 12, 4: 9, 5: 7} + + +def _load_json_file(filename: str) -> dict[str, Any]: + if filename not in DATA_FILES: + raise ValueError(f"unknown CS autochess data file: {filename}") + path = resources.files(__package__).joinpath(DATA_ROOT, filename) + with path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict): + raise ValueError(f"{filename} must contain a JSON object") + if int(payload.get("schema_version", 0)) != 1: + raise ValueError(f"{filename} has unsupported schema_version") + return payload + + +def _mapping(value: Any, context: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError(f"{context} must be an object") + return dict(value) + + +def _sequence(value: Any, context: str) -> list[Any]: + if not isinstance(value, list): + raise ValueError(f"{context} must be a list") + return list(value) + + +def _text(value: Any, context: str) -> str: + result = str(value).strip() + if not result: + raise ValueError(f"{context} must be a non-empty string") + return result + + +def _int_value(value: Any, context: str) -> int: + if isinstance(value, bool): + raise ValueError(f"{context} must be an integer") + try: + return int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{context} must be an integer") from exc + + +def _float_value(value: Any, context: str) -> float: + if isinstance(value, bool): + raise ValueError(f"{context} must be a number") + try: + return float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{context} must be a number") from exc + + +def _int_key_int_dict(value: Any, context: str) -> dict[int, int]: + return { + _int_value(key, f"{context}.key"): _int_value(item, f"{context}.{key}") + for key, item in _mapping(value, context).items() + } + + +def _str_key_int_dict(value: Any, context: str) -> dict[str, int]: + return { + _text(key, f"{context}.key"): _int_value(item, f"{context}.{key}") + for key, item in _mapping(value, context).items() + } + + +def _section(rules: dict[str, Any], key: str) -> dict[str, Any]: + if key not in rules: + raise ValueError(f"rules.json missing {key}") + return _mapping(rules[key], f"rules.{key}") + + +def _load_rules() -> dict[str, Any]: + rules = _load_json_file("rules.json") + for key in ( + "tournament", + "ai", + "roster", + "economy", + "coach", + "shop", + "draft", + "tactics", + "battle", + "pve_events", + "special_events", + ): + _section(rules, key) + return rules + + +def _load_player_pool() -> tuple[dict[str, Any], ...]: + payload = _load_json_file("players.json") + players = _sequence(payload.get("players"), "players.players") + required = { + "id", + "name", + "cost", + "role", + "nation", + "team", + "teams", + "firepower", + "stability", + "clutch", + "leadership", + } + seen: set[str] = set() + normalized: list[dict[str, Any]] = [] + for index, raw_item in enumerate(players, start=1): + item = _mapping(raw_item, f"players[{index}]") + missing = sorted(required - item.keys()) + if missing: + raise ValueError(f"players[{index}] missing {', '.join(missing)}") + card_id = _text(item["id"], f"players[{index}].id") + if card_id in seen: + raise ValueError(f"duplicate player id: {card_id}") + seen.add(card_id) + team = _text(item["team"], f"players[{index}].team") + teams = [ + _text(team_name, f"players[{index}].teams") + for team_name in _sequence(item["teams"], f"players[{index}].teams") + ] + if team not in teams: + teams.insert(0, team) + teams = [team, *[team_name for team_name in teams if team_name != team]][:3] + cost = _int_value(item["cost"], f"players[{index}].cost") + if not 1 <= cost <= 5: + raise ValueError(f"players[{index}].cost must be 1..5") + normalized.append( + { + "id": card_id, + "name": _text(item["name"], f"players[{index}].name"), + "cost": cost, + "role": _text(item["role"], f"players[{index}].role"), + "nation": _text(item["nation"], f"players[{index}].nation"), + "team": team, + "teams": teams, + "firepower": _int_value(item["firepower"], f"players[{index}].firepower"), + "stability": _int_value(item["stability"], f"players[{index}].stability"), + "clutch": _int_value(item["clutch"], f"players[{index}].clutch"), + "leadership": _int_value(item["leadership"], f"players[{index}].leadership"), + } + ) + if len(normalized) < 100: + raise ValueError("CS autochess player pool must contain at least 100 cards") + return tuple(normalized) + + +def _load_coach_pool() -> tuple[dict[str, Any], ...]: + payload = _load_json_file("coaches.json") + coaches = _sequence(payload.get("coaches"), "coaches.coaches") + required = {"id", "name", "level", "style", "firepower", "stability", "clutch", "bonus"} + seen: set[str] = set() + normalized: list[dict[str, Any]] = [] + for index, raw_item in enumerate(coaches, start=1): + item = _mapping(raw_item, f"coaches[{index}]") + missing = sorted(required - item.keys()) + if missing: + raise ValueError(f"coaches[{index}] missing {', '.join(missing)}") + coach_id = _text(item["id"], f"coaches[{index}].id") + if coach_id in seen: + raise ValueError(f"duplicate coach id: {coach_id}") + seen.add(coach_id) + normalized.append( + { + "id": coach_id, + "name": _text(item["name"], f"coaches[{index}].name"), + "level": _int_value(item["level"], f"coaches[{index}].level"), + "style": _text(item["style"], f"coaches[{index}].style"), + "firepower": _int_value(item["firepower"], f"coaches[{index}].firepower"), + "stability": _int_value(item["stability"], f"coaches[{index}].stability"), + "clutch": _int_value(item["clutch"], f"coaches[{index}].clutch"), + "bonus": _int_value(item["bonus"], f"coaches[{index}].bonus"), + } + ) + if len(normalized) < 10: + raise ValueError("CS autochess coach pool must contain at least 10 coaches") + return tuple(normalized) + + +def _load_narratives() -> dict[str, tuple[str, ...]]: + payload = _load_json_file("narratives.json") + fragments = _mapping(payload.get("fragments"), "narratives.fragments") + normalized: dict[str, tuple[str, ...]] = {} + for category, raw_items in fragments.items(): + key = _text(category, "narratives category") + items = tuple( + _text(item, f"narratives.{key}") + for item in _sequence(raw_items, f"narratives.{key}") + ) + if not items: + raise ValueError(f"narratives.{key} must not be empty") + normalized[key] = items + total = sum(len(items) for items in normalized.values()) + if total < MIN_NARRATIVE_FRAGMENTS: + raise ValueError( + f"CS autochess needs at least {MIN_NARRATIVE_FRAGMENTS} narrative fragments" + ) + return normalized + + +def _synergy_rule(value: Any, context: str) -> dict[str, Any]: + raw = _mapping(value, context) + thresholds = [] + for index, item in enumerate(_sequence(raw.get("thresholds"), f"{context}.thresholds"), start=1): + rule = _mapping(item, f"{context}.thresholds[{index}]") + thresholds.append( + { + "min_count": _int_value(rule.get("min_count"), f"{context}.thresholds[{index}].min_count"), + "bonus": _int_value(rule.get("bonus"), f"{context}.thresholds[{index}].bonus"), + } + ) + thresholds.sort(key=lambda item: int(item["min_count"]), reverse=True) + return { + "max_sources": _int_value(raw.get("max_sources"), f"{context}.max_sources"), + "thresholds": thresholds, + } + + +_RULES = _load_rules() +_TOURNAMENT_RULES = _section(_RULES, "tournament") +_AI_RULES = _section(_RULES, "ai") +_ROSTER_RULES = _section(_RULES, "roster") +_ECONOMY_RULES = _section(_RULES, "economy") +_COACH_RULES = _section(_RULES, "coach") +_SHOP_RULES = _section(_RULES, "shop") +_DRAFT_RULES = _section(_RULES, "draft") +_TACTIC_RULES = _section(_RULES, "tactics") +_BATTLE_RULES = _section(_RULES, "battle") + +MIN_PLAYERS = _int_value(_TOURNAMENT_RULES.get("min_players"), "rules.tournament.min_players") +MAX_PLAYERS = _int_value(_TOURNAMENT_RULES.get("max_players"), "rules.tournament.max_players") +HOST_NAME = _text(_TOURNAMENT_RULES.get("host_name"), "rules.tournament.host_name") +EVENT_TITLE_TEMPLATE = _text( + _TOURNAMENT_RULES.get("event_title_template"), + "rules.tournament.event_title_template", +) +AI_PERSON_KEY = _text(_AI_RULES.get("primary_person_key"), "rules.ai.primary_person_key") +AI_DISPLAY_NAME = _text(_AI_RULES.get("primary_display_name"), "rules.ai.primary_display_name") +AI_SECOND_PERSON_KEY = _text(_AI_RULES.get("secondary_person_key"), "rules.ai.secondary_person_key") +AI_SECOND_DISPLAY_NAME = _text(_AI_RULES.get("secondary_display_name"), "rules.ai.secondary_display_name") +AI_POWER_MULTIPLIER = _float_value(_AI_RULES.get("power_multiplier"), "rules.ai.power_multiplier") + +STARTING_GOLD = _int_value(_ECONOMY_RULES.get("starting_gold"), "rules.economy.starting_gold") +STARTING_HP = _int_value(_ECONOMY_RULES.get("starting_hp"), "rules.economy.starting_hp") +_ROUND_INCOME_RULES = _mapping(_ECONOMY_RULES.get("round_income"), "rules.economy.round_income") +ROUND_INCOME_BASE = _int_value(_ROUND_INCOME_RULES.get("base"), "rules.economy.round_income.base") +ROUND_INTEREST_DIVISOR = _int_value(_ROUND_INCOME_RULES.get("interest_divisor"), "rules.economy.round_income.interest_divisor") +ROUND_INTEREST_CAP = _int_value(_ROUND_INCOME_RULES.get("interest_cap"), "rules.economy.round_income.interest_cap") +_TRAINING_REWARDS = _mapping(_ECONOMY_RULES.get("training_rewards"), "rules.economy.training_rewards") +TRAINING_WIN_GOLD = _int_value(_TRAINING_REWARDS.get("win_gold"), "rules.economy.training_rewards.win_gold") +TRAINING_LOSS_GOLD = _int_value(_TRAINING_REWARDS.get("loss_gold"), "rules.economy.training_rewards.loss_gold") +TRAINING_BYE_GOLD = _int_value(_TRAINING_REWARDS.get("bye_gold"), "rules.economy.training_rewards.bye_gold") +_SERIES_REWARDS = _mapping(_ECONOMY_RULES.get("series_rewards"), "rules.economy.series_rewards") +GROUP_SERIES_WIN_GOLD = _int_value(_SERIES_REWARDS.get("group_win_gold"), "rules.economy.series_rewards.group_win_gold") +PLAYOFF_SERIES_WIN_GOLD = _int_value(_SERIES_REWARDS.get("playoff_win_gold"), "rules.economy.series_rewards.playoff_win_gold") + +STARTER_COUNT = _int_value(_ROSTER_RULES.get("starter_count"), "rules.roster.starter_count") +BENCH_LIMIT = _int_value(_ROSTER_RULES.get("bench_limit"), "rules.roster.bench_limit") +STAR_MULTIPLIERS = { + _int_value(star, "rules.roster.star_multipliers.key"): _float_value(multiplier, f"rules.roster.star_multipliers.{star}") + for star, multiplier in _mapping(_ROSTER_RULES.get("star_multipliers"), "rules.roster.star_multipliers").items() +} +POWER_WEIGHTS = _mapping(_ROSTER_RULES.get("unit_power_weights"), "rules.roster.unit_power_weights") +POWER_FIREPOWER_WEIGHT = _float_value(POWER_WEIGHTS.get("firepower"), "rules.roster.unit_power_weights.firepower") +POWER_STABILITY_WEIGHT = _float_value(POWER_WEIGHTS.get("stability"), "rules.roster.unit_power_weights.stability") +POWER_CLUTCH_WEIGHT = _float_value(POWER_WEIGHTS.get("clutch"), "rules.roster.unit_power_weights.clutch") +POWER_LEADERSHIP_THRESHOLD = _int_value(POWER_WEIGHTS.get("leadership_threshold"), "rules.roster.unit_power_weights.leadership_threshold") +POWER_LEADERSHIP_SCALE = _float_value(POWER_WEIGHTS.get("leadership_scale"), "rules.roster.unit_power_weights.leadership_scale") +TEAM_SYNERGY_RULE = _synergy_rule(_ROSTER_RULES.get("team_synergy"), "rules.roster.team_synergy") +NATION_SYNERGY_RULE = _synergy_rule(_ROSTER_RULES.get("nation_synergy"), "rules.roster.nation_synergy") + +COACH_UPGRADE_COSTS = _int_key_int_dict(_COACH_RULES.get("upgrade_costs"), "rules.coach.upgrade_costs") +COACH_BONUSES = _int_key_int_dict(_COACH_RULES.get("fallback_bonuses"), "rules.coach.fallback_bonuses") + +SHOP_SIZE = _int_value(_SHOP_RULES.get("size"), "rules.shop.size") +REFRESH_COST = _int_value(_SHOP_RULES.get("refresh_cost"), "rules.shop.refresh_cost") +COST_POOL_COPIES = _int_key_int_dict(_SHOP_RULES.get("cost_pool_copies"), "rules.shop.cost_pool_copies") SHOP_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}, + _int_value(tier, "rules.shop.odds.tier"): _int_key_int_dict(costs, f"rules.shop.odds.{tier}") + for tier, costs in _mapping(_SHOP_RULES.get("odds"), "rules.shop.odds").items() } -TACTICS = ("火力", "稳定", "残局") + +DRAFT_ROUNDS = _int_value(_DRAFT_RULES.get("rounds"), "rules.draft.rounds") +DRAFT_COACH_ROUND = _int_value(_DRAFT_RULES.get("coach_round"), "rules.draft.coach_round") +DRAFT_COACH_CHOICE_COUNT = _int_value(_DRAFT_RULES.get("coach_choice_count"), "rules.draft.coach_choice_count") +DRAFT_PLAYER_CHOICE_COUNT = _int_value(_DRAFT_RULES.get("player_choice_count"), "rules.draft.player_choice_count") +DRAFT_PLAYER_COPY_COUNT = _int_value(_DRAFT_RULES.get("player_copy_count"), "rules.draft.player_copy_count") +DRAFT_ROUND_TWO_PREFERRED_COSTS = tuple( + _int_value(item, "rules.draft.round_two_preferred_costs") + for item in _sequence(_DRAFT_RULES.get("round_two_preferred_costs"), "rules.draft.round_two_preferred_costs") +) +DRAFT_LATER_ROUND_PREFERRED_COSTS = tuple( + _int_value(item, "rules.draft.later_round_preferred_costs") + for item in _sequence(_DRAFT_RULES.get("later_round_preferred_costs"), "rules.draft.later_round_preferred_costs") +) + +TACTICS = tuple( + _text(item, "rules.tactics.names") + for item in _sequence(_TACTIC_RULES.get("names"), "rules.tactics.names") +) TACTIC_COUNTERS = { - "稳定": "火力", - "火力": "残局", - "残局": "稳定", + _text(key, "rules.tactics.counters.key"): _text(value, f"rules.tactics.counters.{key}") + for key, value in _mapping(_TACTIC_RULES.get("counters"), "rules.tactics.counters").items() } -ATTRIBUTE_COUNTERS = ( - ("稳定", "火力"), - ("火力", "残局"), - ("残局", "稳定"), +ATTRIBUTE_COUNTERS = tuple( + ( + _text(pair[0], "rules.tactics.attribute_counters.strong"), + _text(pair[1], "rules.tactics.attribute_counters.weak"), + ) + for pair in _sequence(_TACTIC_RULES.get("attribute_counters"), "rules.tactics.attribute_counters") ) ROLE_COUNTERS = { - "狙击手": "步枪手", - "步枪手": "指挥", - "指挥": "狙击手", + _text(key, "rules.tactics.role_counters.key"): _text(value, f"rules.tactics.role_counters.{key}") + for key, value in _mapping(_TACTIC_RULES.get("role_counters"), "rules.tactics.role_counters").items() } +TACTIC_AXIS_MULTIPLIER = _float_value(_TACTIC_RULES.get("selected_axis_multiplier"), "rules.tactics.selected_axis_multiplier") +TACTIC_AXIS_PERCENT_DIVISOR = _float_value(_TACTIC_RULES.get("axis_percent_divisor"), "rules.tactics.axis_percent_divisor") +TACTIC_AXIS_PERCENT_CAP = _float_value(_TACTIC_RULES.get("axis_percent_cap"), "rules.tactics.axis_percent_cap") +TACTIC_ROLE_PERCENT_SCALE = _float_value(_TACTIC_RULES.get("role_percent_scale"), "rules.tactics.role_percent_scale") +TACTIC_ROLE_PERCENT_CAP = _float_value(_TACTIC_RULES.get("role_percent_cap"), "rules.tactics.role_percent_cap") +TACTIC_COUNTER_PERCENT = _float_value(_TACTIC_RULES.get("tactic_counter_percent"), "rules.tactics.tactic_counter_percent") +TACTIC_TOTAL_PERCENT_CAP = _float_value(_TACTIC_RULES.get("total_percent_cap"), "rules.tactics.total_percent_cap") + +MAP_POOL = tuple( + _text(item, "rules.battle.maps") + for item in _sequence(_BATTLE_RULES.get("maps"), "rules.battle.maps") +) +_BATTLE_DAMAGE = _mapping(_BATTLE_RULES.get("damage"), "rules.battle.damage") +BATTLE_BO5_SWEEP_DAMAGE = _int_value(_BATTLE_DAMAGE.get("bo5_sweep"), "rules.battle.damage.bo5_sweep") +BATTLE_SWEEP_DAMAGE = _int_value(_BATTLE_DAMAGE.get("sweep"), "rules.battle.damage.sweep") +BATTLE_NON_SWEEP_DAMAGE = _int_value(_BATTLE_DAMAGE.get("non_sweep"), "rules.battle.damage.non_sweep") +BATTLE_MR_ROUNDS = _int_value(_BATTLE_RULES.get("mr_rounds"), "rules.battle.mr_rounds") +BATTLE_TARGET_SCORE = _int_value(_BATTLE_RULES.get("target_score"), "rules.battle.target_score") +BATTLE_OVERTIME_START_SCORE = _int_value(_BATTLE_RULES.get("overtime_start_score"), "rules.battle.overtime_start_score") +BATTLE_OVERTIME_TARGET_SCORE = _int_value(_BATTLE_RULES.get("overtime_target_score"), "rules.battle.overtime_target_score") +BATTLE_OVERTIME_WIN_BY = _int_value(_BATTLE_RULES.get("overtime_win_by"), "rules.battle.overtime_win_by") +BATTLE_OVERTIME_SAFETY_CAP = _int_value(_BATTLE_RULES.get("overtime_safety_cap", 72), "rules.battle.overtime_safety_cap") +BATTLE_OVERTIME_SEGMENT_ROUNDS = _int_value(_BATTLE_RULES.get("overtime_segment_rounds"), "rules.battle.overtime_segment_rounds") +MATCH_BEST_OF = _str_key_int_dict(_BATTLE_RULES.get("match_best_of"), "rules.battle.match_best_of") +_ROUND_PROBABILITY_RULES = _mapping(_BATTLE_RULES.get("round_probability"), "rules.battle.round_probability") +ROUND_PROBABILITY_BASE = _float_value(_ROUND_PROBABILITY_RULES.get("base"), "rules.battle.round_probability.base") +ROUND_PROBABILITY_MIN = _float_value(_ROUND_PROBABILITY_RULES.get("min"), "rules.battle.round_probability.min") +ROUND_PROBABILITY_MAX = _float_value(_ROUND_PROBABILITY_RULES.get("max"), "rules.battle.round_probability.max") +ROUND_POWER_EDGE_CAP = _float_value(_ROUND_PROBABILITY_RULES.get("power_edge_cap"), "rules.battle.round_probability.power_edge_cap") +ROUND_POWER_EDGE_SCALE = _float_value(_ROUND_PROBABILITY_RULES.get("power_edge_scale"), "rules.battle.round_probability.power_edge_scale") +ROUND_PRESSURE_CAP = _float_value(_ROUND_PROBABILITY_RULES.get("pressure_cap"), "rules.battle.round_probability.pressure_cap") +ROUND_PRESSURE_SCALE = _float_value(_ROUND_PROBABILITY_RULES.get("pressure_scale"), "rules.battle.round_probability.pressure_scale") +ROUND_FIRST_HALF_SIDE_SWING = _float_value(_ROUND_PROBABILITY_RULES.get("first_half_side_swing"), "rules.battle.round_probability.first_half_side_swing") +ROUND_SECOND_HALF_SIDE_SWING = _float_value(_ROUND_PROBABILITY_RULES.get("second_half_side_swing"), "rules.battle.round_probability.second_half_side_swing") +ROUND_JITTER_MOD = _int_value(_ROUND_PROBABILITY_RULES.get("jitter_mod"), "rules.battle.round_probability.jitter_mod") +ROUND_JITTER_CENTER = _int_value(_ROUND_PROBABILITY_RULES.get("jitter_center"), "rules.battle.round_probability.jitter_center") +ROUND_JITTER_DIVISOR = _float_value(_ROUND_PROBABILITY_RULES.get("jitter_divisor"), "rules.battle.round_probability.jitter_divisor") +SCORE_BANDS = tuple( + { + "max_loser_score": _int_value(item.get("max_loser_score"), "rules.battle.score_bands.max_loser_score"), + "label": _text(item.get("label"), "rules.battle.score_bands.label"), + } + for item in ( + _mapping(raw_item, "rules.battle.score_bands.item") + for raw_item in _sequence(_BATTLE_RULES.get("score_bands"), "rules.battle.score_bands") + ) +) + PVE_EVENTS = { - "pve_training": ("训练赛", 275, 4, 2), - "pve_media": ("媒体日挑战", 335, 6, 3), - "pve_sponsor": ("赞助挑战", 395, 8, 4), + key: ( + _text(item.get("name"), f"rules.pve_events.{key}.name"), + _int_value(item.get("target"), f"rules.pve_events.{key}.target"), + _int_value(item.get("win_gold"), f"rules.pve_events.{key}.win_gold"), + _int_value(item.get("loss_gold"), f"rules.pve_events.{key}.loss_gold"), + ) + for key, item in ( + (str(name), _mapping(value, f"rules.pve_events.{name}")) + for name, value in _section(_RULES, "pve_events").items() + ) +} +SPECIAL_EVENTS = { + key: ( + _text(item.get("name"), f"rules.special_events.{key}.name"), + _int_value(item.get("target"), f"rules.special_events.{key}.target"), + _int_value(item.get("win_gold"), f"rules.special_events.{key}.win_gold"), + _int_value(item.get("loss_gold"), f"rules.special_events.{key}.loss_gold"), + ) + for key, item in ( + (str(name), _mapping(value, f"rules.special_events.{name}")) + for name, value in _section(_RULES, "special_events").items() + ) } - -def _card( - card_id: str, - name: str, - cost: int, - role: str, - nation: str, - team: str, - history: tuple[str, ...], - firepower: int, - stability: int, - clutch: int, - leadership: int = 0, -) -> dict[str, Any]: - return { - "id": card_id, - "name": name, - "cost": cost, - "role": role, - "nation": nation, - "team": team, - "teams": [team, *history][:3], - "firepower": firepower, - "stability": stability, - "clutch": clutch, - "leadership": leadership, - } - - -PLAYER_POOL = ( - _card("donk", "donk", 5, "步枪手", "俄罗斯", "Spirit", (), 98, 92, 91), - _card("sh1ro", "sh1ro", 4, "狙击手", "俄罗斯", "Spirit", ("Cloud9", "Gambit"), 91, 92, 90), - _card("chopper", "chopper", 3, "指挥", "俄罗斯", "Spirit", ("Vega",), 72, 83, 76, 94), - _card("zont1x", "zont1x", 1, "步枪手", "乌克兰", "Spirit", (), 65, 66, 61), - _card("magixx", "magixx", 1, "步枪手", "俄罗斯", "Spirit", (), 63, 67, 64), - _card("zywoo", "ZywOo", 5, "狙击手", "法国", "Vitality", (), 97, 96, 98), - _card("ropz", "ropz", 4, "步枪手", "爱沙尼亚", "Vitality", ("FaZe", "MOUZ"), 90, 92, 92), - _card("flamez", "flameZ", 3, "步枪手", "以色列", "Vitality", ("OG",), 84, 79, 77), - _card("apex", "apEX", 3, "指挥", "法国", "Vitality", ("EnVyUs", "G2"), 70, 82, 73, 97), - _card("mezii", "mezii", 1, "步枪手", "英国", "Vitality", ("fnatic", "Cloud9"), 66, 68, 65), - _card("m0nesy", "m0NESY", 4, "狙击手", "俄罗斯", "Falcons", ("G2", "NAVI"), 92, 89, 92), - _card("niko", "NiKo", 4, "步枪手", "波黑", "Falcons", ("G2", "FaZe"), 92, 88, 91), - _card("kyousuke", "kyousuke", 4, "步枪手", "俄罗斯", "Falcons", ("Spirit",), 92, 82, 84), - _card("magisk", "Magisk", 3, "步枪手", "丹麦", "Falcons", ("Vitality", "Astralis"), 80, 84, 84), - _card("teses", "TeSeS", 1, "步枪手", "丹麦", "Falcons", ("HEROIC",), 66, 65, 62), - _card("malbsmd", "malbsMd", 3, "步枪手", "危地马拉", "G2", ("M80",), 84, 78, 81), - _card("hunter", "huNter-", 3, "步枪手", "波黑", "G2", (), 79, 84, 82), - _card("heavygod", "HeavyGod", 1, "步枪手", "以色列", "G2", ("Cloud9", "OG"), 67, 66, 63), - _card("snax", "Snax", 2, "指挥", "波兰", "G2", ("GamerLegion", "Virtus.pro"), 62, 73, 72, 88), - _card("sunpayus", "SunPayus", 3, "狙击手", "西班牙", "G2", ("Falcons", "ENCE"), 82, 83, 84), - _card("frozen", "frozen", 4, "步枪手", "斯洛伐克", "FaZe", ("MOUZ",), 89, 91, 88), - _card("broky", "broky", 4, "狙击手", "拉脱维亚", "FaZe", (), 87, 88, 92), - _card("rain", "rain", 3, "步枪手", "挪威", "FaZe", (), 79, 82, 84), - _card("karrigan", "karrigan", 1, "指挥", "丹麦", "FaZe", ("MOUZ", "Astralis"), 54, 64, 60, 98), - _card("elige", "EliGE", 3, "步枪手", "美国", "FaZe", ("Complexity", "Liquid"), 84, 80, 79), - _card("b1t", "b1t", 3, "步枪手", "乌克兰", "NAVI", (), 84, 84, 83), - _card("w0nderful", "w0nderful", 3, "狙击手", "乌克兰", "NAVI", ("Spirit",), 84, 82, 84), - _card("jl", "jL", 3, "步枪手", "立陶宛", "NAVI", ("Apeks",), 82, 81, 84), - _card("aleksib", "Aleksib", 2, "指挥", "芬兰", "NAVI", ("G2", "ENCE"), 64, 76, 70, 95), - _card("im", "iM", 1, "步枪手", "罗马尼亚", "NAVI", ("GamerLegion",), 67, 63, 64), - _card("xertion", "xertioN", 3, "步枪手", "以色列", "MOUZ", (), 84, 78, 80), - _card("torzsi", "torzsi", 3, "狙击手", "匈牙利", "MOUZ", (), 82, 83, 84), - _card("jimpphat", "Jimpphat", 3, "步枪手", "芬兰", "MOUZ", (), 80, 84, 84), - _card("brollan", "Brollan", 1, "步枪手", "瑞典", "MOUZ", ("NIP", "fnatic"), 68, 64, 61), - _card("siuhy", "siuhy", 3, "指挥", "波兰", "Liquid", ("MOUZ", "GamerLegion"), 71, 82, 78, 95), - _card("senzu", "Senzu", 3, "步枪手", "蒙古", "The MongolZ", (), 84, 80, 83), - _card("910", "910", 1, "狙击手", "蒙古", "The MongolZ", (), 68, 65, 66), - _card("blitz", "bLitz", 2, "指挥", "蒙古", "The MongolZ", (), 68, 76, 74, 91), - _card("techno", "Techno", 1, "步枪手", "蒙古", "The MongolZ", (), 66, 67, 62), - _card("mzinho", "mzinho", 1, "步枪手", "蒙古", "The MongolZ", (), 64, 66, 65), - _card("device", "device", 4, "狙击手", "丹麦", "Astralis", ("NIP",), 89, 92, 92), - _card("stavn", "stavn", 3, "步枪手", "丹麦", "Astralis", ("HEROIC",), 83, 82, 81), - _card("jabbi", "jabbi", 3, "步枪手", "丹麦", "Astralis", ("HEROIC",), 80, 81, 80), - _card("hooxi", "HooXi", 2, "指挥", "丹麦", "Astralis", ("G2", "Copenhagen Flames"), 60, 71, 67, 94), - _card("staehr", "Staehr", 1, "步枪手", "丹麦", "Astralis", ("Sprout",), 68, 66, 64), - _card("naf", "NAF", 3, "步枪手", "加拿大", "Liquid", ("Renegades",), 80, 84, 84), - _card("twistzz", "Twistzz", 4, "指挥", "加拿大", "Liquid", ("FaZe",), 87, 90, 91, 92), - _card("nertz", "NertZ", 3, "步枪手", "以色列", "Liquid", ("HEROIC", "ENCE"), 84, 80, 82), - _card("ultimate", "ultimate", 2, "狙击手", "波兰", "Liquid", (), 74, 72, 73), - _card("skullz", "skullz", 1, "步枪手", "巴西", "Liquid", ("FURIA", "paiN"), 65, 67, 64), - _card("kscerato", "KSCERATO", 4, "步枪手", "巴西", "FURIA", (), 90, 88, 89), - _card("yuurih", "yuurih", 3, "步枪手", "巴西", "FURIA", (), 82, 81, 80), - _card("fallen", "FalleN", 3, "指挥", "巴西", "FURIA", ("Liquid", "MIBR"), 70, 80, 83, 93), - _card("molodoy", "molodoy", 2, "狙击手", "哈萨克斯坦", "FURIA", ("AMKAL",), 76, 70, 72), - _card("yekindar", "YEKINDAR", 3, "步枪手", "拉脱维亚", "FURIA", ("Liquid", "Virtus.pro"), 84, 74, 72), - _card("xantares", "XANTARES", 4, "步枪手", "土耳其", "Eternal Fire", ("BIG",), 92, 86, 85), - _card("woxic", "woxic", 3, "狙击手", "土耳其", "Eternal Fire", ("Cloud9", "MOUZ"), 83, 80, 82), - _card("maj3r", "MAJ3R", 2, "指挥", "土耳其", "Eternal Fire", ("Space Soldiers",), 68, 74, 71, 93), - _card("wicadia", "Wicadia", 2, "步枪手", "土耳其", "Eternal Fire", (), 76, 70, 68), - _card("jottaaa", "jottAAA", 1, "步枪手", "土耳其", "Eternal Fire", ("Sangal",), 67, 64, 63), - _card("teses_heroic", "TeSeS", 2, "步枪手", "丹麦", "HEROIC", ("Falcons",), 76, 73, 72), - _card("sjuush", "sjuush", 2, "步枪手", "丹麦", "HEROIC", (), 74, 78, 77), - _card("degster", "degster", 3, "狙击手", "俄罗斯", "HEROIC", ("OG", "Spirit"), 82, 76, 82), - _card("kyxsan", "kyxsan", 2, "指挥", "北马其顿", "HEROIC", ("Apeks",), 64, 75, 70, 90), - _card("nertz_heroic", "NertZ", 3, "步枪手", "以色列", "HEROIC", ("ENCE", "Liquid"), 85, 79, 82), - _card("torzi_mouz", "torzsi", 2, "狙击手", "匈牙利", "MOUZ", (), 78, 78, 80), - _card("spinx", "Spinx", 4, "步枪手", "以色列", "Vitality", ("ENCE",), 88, 87, 86), - _card("electronic", "electroNic", 4, "步枪手", "俄罗斯", "Virtus.pro", ("NAVI", "Cloud9"), 87, 84, 84), - _card("fame", "fame", 3, "步枪手", "俄罗斯", "Virtus.pro", (), 82, 83, 82), - _card("fl1t", "FL1T", 3, "步枪手", "俄罗斯", "Virtus.pro", ("forZe",), 81, 82, 80), - _card("jame", "Jame", 3, "指挥", "俄罗斯", "Virtus.pro", ("AVANGAR",), 72, 87, 86, 96), - _card("yekindar_vp", "YEKINDAR", 4, "步枪手", "拉脱维亚", "Virtus.pro", ("Liquid", "FURIA"), 90, 78, 76), - _card("hallzerk", "hallzerk", 2, "狙击手", "挪威", "Complexity", ("Dignitas",), 75, 72, 75), - _card("grim", "Grim", 2, "步枪手", "美国", "Complexity", ("Liquid",), 74, 74, 73), - _card("jt", "JT", 2, "指挥", "南非", "Complexity", ("Cloud9",), 62, 73, 69, 89), - _card("floppy", "floppy", 1, "步枪手", "美国", "Complexity", ("Cloud9",), 68, 67, 65), - _card("junior", "junior", 1, "狙击手", "美国", "NRG", ("FURIA", "Complexity"), 68, 64, 66), - _card("art", "arT", 2, "指挥", "巴西", "Fluxo", ("FURIA",), 69, 68, 74, 92), - _card("biguzera", "biguzera", 3, "指挥", "巴西", "paiN", (), 75, 80, 80, 92), - _card("nqz", "nqz", 2, "狙击手", "巴西", "paiN", ("00Nation",), 76, 72, 74), - _card("snow", "snow", 1, "步枪手", "巴西", "paiN", (), 67, 65, 63), - _card("brnz4n", "brnz4n", 1, "步枪手", "巴西", "MIBR", (), 66, 66, 64), - _card("exit", "exit", 2, "指挥", "巴西", "MIBR", (), 67, 75, 71, 88), - _card("insani", "insani", 3, "步枪手", "巴西", "MIBR", (), 83, 75, 77), - _card("saffee", "saffee", 2, "狙击手", "巴西", "MIBR", ("FURIA",), 76, 72, 74), - _card("slaxz", "slaxz-", 2, "狙击手", "德国", "GamerLegion", ("ALTERNATE",), 75, 72, 74), - _card("volt", "volt", 2, "步枪手", "英国", "GamerLegion", ("Into the Breach",), 74, 73, 72), - _card("acoR", "acoR", 2, "狙击手", "丹麦", "GamerLegion", ("MOUZ",), 75, 73, 76), - _card("keoz", "Keoz", 1, "步枪手", "比利时", "GamerLegion", (), 65, 66, 64), - _card("isak", "isak", 1, "步枪手", "瑞典", "GamerLegion", (), 66, 67, 65), - _card("tabseN", "tabseN", 3, "指挥", "德国", "BIG", (), 77, 82, 79, 94), - _card("krimbo", "Krimbo", 3, "步枪手", "德国", "BIG", (), 82, 82, 81), - _card("syrson", "syrsoN", 2, "狙击手", "德国", "BIG", (), 76, 73, 76), - _card("mantuu", "mantuu", 2, "狙击手", "波兰", "BIG", ("OG",), 76, 74, 75), - _card("blamef", "blameF", 4, "步枪手", "丹麦", "fnatic", ("Astralis", "Complexity"), 88, 88, 84), - _card("bodyy", "bodyy", 2, "指挥", "法国", "fnatic", ("G2",), 66, 75, 72, 89), - _card("matys", "MATYS", 2, "步枪手", "斯洛伐克", "fnatic", (), 76, 72, 72), - _card("afro", "afro", 2, "狙击手", "法国", "fnatic", ("LDLC",), 75, 72, 74), - _card("kairon", "KaiR0N-", 2, "步枪手", "俄罗斯", "BetBoom", ("Virtus.pro",), 76, 70, 72), - _card("zorte", "zorte", 2, "狙击手", "俄罗斯", "BetBoom", ("forZe",), 77, 73, 75), - _card("nafany", "nafany", 2, "指挥", "俄罗斯", "BetBoom", ("Cloud9", "Gambit"), 65, 74, 70, 91), - _card("s1ren", "s1ren", 2, "步枪手", "俄罗斯", "BetBoom", ("Spirit",), 75, 73, 72), - _card("rigoN", "rigoN", 2, "步枪手", "瑞士", "Bad News Eagles", ("Guild Eagles",), 77, 70, 72), - _card("juanflatroo", "juanflatroo", 2, "步枪手", "科索沃", "Bad News Eagles", ("Guild Eagles",), 76, 71, 72), - _card("calyx", "Calyx", 2, "步枪手", "土耳其", "Eternal Fire", ("Space Soldiers",), 75, 73, 74), - _card("issaa", "ISSAA", 1, "步枪手", "约旦", "JiJieHao", ("OG",), 67, 64, 66), - _card("boros", "BOROS", 2, "步枪手", "约旦", "Into the Breach", ("Falcons", "Monte"), 78, 68, 70), - _card("woro2k", "Woro2k", 3, "狙击手", "乌克兰", "Monte", (), 82, 76, 80), - _card("demqq", "DemQQ", 2, "步枪手", "乌克兰", "Monte", (), 76, 74, 73), - _card("dycha", "dycha", 2, "步枪手", "波兰", "ENCE", (), 75, 74, 72), - _card("gla1ve", "gla1ve", 3, "指挥", "丹麦", "ENCE", ("Astralis",), 72, 84, 79, 98), - _card("hades", "hades", 2, "狙击手", "波兰", "ENCE", ("9INE",), 76, 73, 75), - _card("goofy", "Goofy", 1, "步枪手", "波兰", "ENCE", ("9INE",), 67, 66, 64), - _card("sausol", "sausol", 1, "步枪手", "西班牙", "Movistar Riders", (), 66, 65, 63), - _card("alex_movistar", "alex", 2, "指挥", "西班牙", "Movistar Riders", ("NiP",), 64, 74, 70, 88), - _card("dav1g", "dav1g", 1, "步枪手", "西班牙", "KOI", ("Movistar Riders",), 65, 65, 64), -) +PLAYER_POOL = _load_player_pool() CARD_BY_ID = {card["id"]: card for card in PLAYER_POOL} - - -def _coach( - coach_id: str, - name: str, - level: int, - style: str, - firepower: int, - stability: int, - clutch: int, - bonus: int, -) -> dict[str, Any]: - return { - "id": coach_id, - "name": name, - "level": level, - "style": style, - "firepower": firepower, - "stability": stability, - "clutch": clutch, - "bonus": bonus, - } - - -COACH_POOL = ( - _coach("zonic", "zonic", 5, "冠军体系", 78, 92, 88, 9), - _coach("blade", "B1ad3", 5, "结构控图", 72, 96, 84, 9), - _coach("xtqzzz", "XTQZZZ", 4, "进攻提速", 86, 78, 80, 8), - _coach("neo", "NEO", 4, "残局纪律", 76, 82, 90, 8), - _coach("saw", "sAw", 3, "临场调整", 80, 80, 82, 7), - _coach("robban", "RobbaN", 3, "更衣室管理", 74, 86, 78, 7), - _coach("kane", "kane", 2, "明星激活", 84, 72, 76, 6), - _coach("trace", "trace", 2, "防守复盘", 70, 86, 74, 6), - _coach("ash", "ash", 1, "新人培养", 76, 74, 72, 5), - _coach("mithr", "mithR", 1, "基础纪律", 70, 78, 70, 5), - _coach("kuben", "kuben", 4, "慢控默认", 74, 88, 82, 8), - _coach("groove", "groove", 3, "学院体系", 72, 84, 78, 7), - _coach("guerri", "guerri", 4, "巴西爆弹", 86, 76, 80, 8), - _coach("xizt", "Xizt", 4, "默认纪律", 76, 86, 82, 8), - _coach("sycrone", "sycrone", 3, "青年提拔", 80, 80, 78, 7), - _coach("malek", "maLeK", 3, "明星平衡", 82, 78, 80, 7), - _coach("peacemaker", "peacemaker", 2, "临时救火", 78, 74, 76, 6), - _coach("kassad", "kassad", 2, "纪律重建", 72, 82, 74, 6), -) +COACH_POOL = _load_coach_pool() COACH_BY_ID = {coach["id"]: coach for coach in COACH_POOL} -TRAINING_WIN_GOLD = 3 -TRAINING_LOSS_GOLD = 1 -TRAINING_BYE_GOLD = 2 - -NARRATIVE_FRAGMENTS = { - "openings": ( - "{team} 开局没有急着提速,先把默认控图铺开。", - "{team} 第一波提速很坚决,外场道具落点干净。", - "{team} 在手枪局后选择慢摸,试图逼出对手的前压信息。", - "{team} 的狙击手早早架住第一交火点,节奏被压得很低。", - "{team} 连续用两颗闪光清掉近点,进攻面一下打开。", - "{team} 防守开局站位很深,把回防路线留得很完整。", - "{team} 先拿地图控制权,再把包点压力一点点推上去。", - "{team} 开局没有赌点,靠信息互换慢慢读阵型。", - "{team} 在第一枪位赢下后没有贪,马上回收人数优势。", - "{team} 用一波假打骗出轮转,第二时间才开始真正爆弹。", - "{team} 选择保守开局,宁愿少拿信息也不送首杀。", - "{team} 的指挥把默认拉得很散,让对手很难判断重心。", - "{team} 手枪局道具很少,但站位互补做得很扎实。", - "{team} 没有被前几回合影响,依旧按自己的节奏展开。", - "{team} 的第一套进攻明显针对了对手弱侧。", - "{team} 在经济局也保留了两颗关键闪,准备打后点反清。", - "{team} 上来就抢中路,想用主动权换后续转点空间。", - "{team} 先把狙击枪放在安全位,围绕它慢慢组织推进。", - ), - "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} 连分让地图气质变了,所有枪位都开始往前压。", - ), - "clutches": ( - "{player} 留到最后一个残局,靠一颗烟把拆包时间偷了出来。", - "{player} 没有急着补枪,等脚步声靠近后才横拉收掉残局。", - "{player} 在 1v2 里先断退路,再回头处理包点。", - "{player} 的残局处理很冷,假拆骗出第一枪后直接反清。", - "{player} 被逼到死角,但一颗反弹闪把局面重新打开。", - "{player} 没有追击残血,而是先保住雷包,判断非常老练。", - "{player} 关键回合双杀,把本来要崩的经济线救回来。", - "{player} 在读秒阶段完成补枪,队友的拉扯给得刚刚好。", - "{player} 先拿信息再开枪,这个残局赢在耐心。", - "{player} 硬吃白光完成换人,比分没有被对手咬住。", - "{player} 从连接位摸出,正好打掉回防节奏。", - "{player} 这次不追求集锦,只把枪线封死等队友到位。", - "{player} 的狙击枪没有空枪,关键首杀让整回合变得简单。", - "{player} 残血仍然敢看第一枪位,队伍因此拿到赛点。", - "{player} 拿下残局后没有庆祝,马上提醒队友保枪经济。", - "{player} 靠脚步假信息骗出转点,最后一枪很稳。", - "{player} 在包点里连续换位,对手始终没能定位。", - "{player} 把闪光背身吃完后仍然补到第二枪,这是关键转折。", - "{player} 一个人拖住整条回防路线,为队伍争到拆包窗口。", - "{player} 最后选择不 peek,让时间替自己赢下这一分。", - ), - "pauses": ( - "比分被拉开后,双方都慢了半拍,像是在重新读这张图。", - "这里适合停一下,刚才几回合的经济差已经开始影响下一枪。", - "暂停回来之后,防守方明显把第一接触点后撤了半格。", - "连续交火后节奏突然变慢,双方都在等对方先犯错。", - "这一段没有夸张集锦,但每一颗道具都在改变下一回合。", - "场面安静了几秒,真正决定胜负的是下一次首杀。", - "队伍语音应该都在压节奏,没人愿意在这里送掉经济。", - "这不是简单的比分变化,战术重心已经从爆弹转到控图。", - "现场进入短暂拉扯,地图控制权一点点被重新分配。", - "两边都知道下一分很贵,所以这回合开局格外谨慎。", - "这一小段空白很重要,强队往往就是在这里调整默认。", - "比赛没有马上进入下一波交火,指挥正在重新排优先级。", - "刚才的连分让对手不得不换站位,防线开始出现试探。", - "经济压力让双方都少了一点容错,下一波道具会很关键。", - "这里没有急躁的前压,只有一点点互相试探。", - "暂停不是为了拖时间,而是在给下一套进攻换答案。", - "双方都把节奏压住,地图像被重新归零。", - "这一分之前的几秒钟很长,所有人都在等第一个脚步声。", - "场上没有无意义的 peek,每个人都在守自己的触发条件。", - "这段过渡让比赛更像真正的 BO 系列赛,而不是单纯掷骰子。", - ), - "economy": ( - "{team} 赢下这一分后可以完整起枪,下一回合选择会更多。", - "{team} 输掉长枪局后经济见底,可能要交出一回合调整空间。", - "{team} 没有强行追梦,选择保枪把下一回合留住。", - "{team} 的连败补偿到位,下一回合有机会靠道具质量反打。", - "{team} 这回合缴下两把枪,经济收益比比分更重要。", - "{team} 虽然丢分,但保下主狙,下一回合还有破局点。", - "{team} 的半甲手枪没有打成,但至少逼出了对手不少道具。", - "{team} 经济优势开始显现,可以更大胆地争夺第一枪位。", - "{team} 如果下一分再丢,就必须进入半起调整。", - "{team} 这一波选择全甲全弹,明显是要在中期一次打穿。", - "{team} 保下来的两把枪让下回合不至于完全被动。", - "{team} 连续换枪成功,把对手的存款一点点磨掉。", - "{team} 用低成本回合换到下包,经济线还没有断。", - "{team} 这波赢的不只是分数,还有下一回合的主动权。", - "{team} 被重置经济后节奏放慢,先求稳住半场尾声。", - "{team} 靠残局胜利拿到额外空间,可以考虑继续 D 牌补强。", - ), - "closers": ( - "{team} 没有给翻盘窗口,最后一波回防处理得很完整。", - "{team} 在赛点局选择最熟悉的默认,用纪律结束地图。", - "{team} 最后一回合没有贪枪,稳稳把人数优势转成胜势。", - "{team} 的收官很干净,地图尾声没有出现多余失误。", - "{team} 用一波双拉打开包点,最后比分定格。", - "{team} 把比赛拖进自己舒服的节奏,收尾阶段非常稳。", - "{team} 赛点局的道具层次很清楚,对手没有舒服的回防路线。", - "{team} 终于等到对手前压,抓住这个窗口终结地图。", - "{team} 收下最后一分时没有冒进,这就是强队式结尾。", - "{team} 最后一枪来得很快,但前面的信息铺垫已经足够。", - "{team} 在关键经济局之后顺势收掉地图。", - "{team} 先控退路再进包点,结尾没有留下残局悬念。", - "{team} 把上半场积累的优势守到最后。", - "{team} 用一波慢夹收官,比分看起来合理,过程更像真实比赛。", - "{team} 没让对手连追,赛点局直接把节奏抢回来。", - "{team} 的最后一分赢在补枪,枪线没有断。", - "{team} 收官回合把每个角落都清干净,耐心给了回报。", - "{team} 把悬念留到最后一刻,但关键枪没有手软。", - ), - "training_hooks": ( - "训练赛不计入小组胜负,但会发金币,方便后续刷新商店。", - "这轮训练更像实战热身,每队都会遇到同场参赛对手。", - "训练赛重点不是淘汰谁,而是检验当前阵容能不能打完整张图。", - "这轮结束后可以 D 牌、锁店或调首发,下一轮再检验成果。", - "AI 战队也会参与训练,对局结果会写入档案。", - "如果阵容缺少指挥,训练赛的中期回合会更容易失控。", - "如果你想追羁绊,现在是最适合刷新商店的窗口。", - "训练赛的金币很重要,别把每次刷新都当成免费试错。", - "这一阶段没有生命扣除,重点是运营和阵容成型。", - "训练赛的地图会轮换,不会一直用同一个固定比分模板。", - "每轮训练结束后,真人都需要发送 /结束回合 才会进入下一轮。", - "训练赛会按轮次播报,避免一次性刷出一大串结果。", - "如果出现轮空,会安排本地陪练队补一张 BO1。", - "训练阶段适合观察战术克制,火力、稳定、残局都会影响回合走向。", - "连胜会带来金币,但失败也有保底,保证测试局能继续运营。", - "这部分是纯文字自走棋的节奏层,不再是单条目标数值判定。", - ), - "challenges": ( - "媒体日问题很尖锐,队伍需要在镜头前保持状态。", - "赞助商临时加码,要求队伍用指定战术打出观赏性。", - "主办方安排了签名会,状态管理成了额外考验。", - "训练馆网络短暂波动,队伍必须快速适应备用机器。", - "赛前采访拖长,指挥需要把注意力重新拉回比赛。", - "赞助商想看激进打法,但稳住节奏的人会拿到更高回报。", - "媒体日不是战斗回合,却会影响队伍的运营资源。", - "队伍如果阵容完整,就更容易把这次插曲处理成奖金。", - "这次挑战只出现一次,处理完就回到小组赛主线。", - "主舞台彩排占用了热身时间,教练的管理能力开始生效。", - "赞助商要求展示新战术,选错方向也不会淘汰,只会少拿金币。", - "现场采访提到了前一场失利,队员心态需要稳住。", - "这类挑战不扣生命,目的只是给运营阶段增加变数。", - "处理好这次插曲的队伍,会在下一场前多一点经济余地。", - ), -} - +NARRATIVE_FRAGMENTS = _load_narratives() def _hash_int(seed: str) -> int: return int.from_bytes( @@ -636,16 +631,37 @@ def _normalize_unit(unit: dict[str, Any]) -> None: def _unit_power(unit: dict[str, Any]) -> float: card = CARD_BY_ID[str(unit["card_id"])] base = ( - card["firepower"] * 0.45 - + card["stability"] * 0.30 - + card["clutch"] * 0.25 + card["firepower"] * POWER_FIREPOWER_WEIGHT + + card["stability"] * POWER_STABILITY_WEIGHT + + card["clutch"] * POWER_CLUTCH_WEIGHT ) if card["role"] == "指挥": - base += max(0, card["leadership"] - 70) * 0.12 - multiplier = {1: 1.0, 2: 1.35, 3: 1.80}[int(unit["star"])] + base += ( + max(0, card["leadership"] - POWER_LEADERSHIP_THRESHOLD) + * POWER_LEADERSHIP_SCALE + ) + multiplier = STAR_MULTIPLIERS[int(unit["star"])] return base * multiplier +def _synergy_values( + counts: dict[str, int], + rules: dict[str, Any], +) -> list[tuple[int, int, str]]: + values: list[tuple[int, int, str]] = [] + thresholds = list(rules["thresholds"]) + for name, count in counts.items(): + bonus = 0 + for rule in thresholds: + if count >= int(rule["min_count"]): + bonus = int(rule["bonus"]) + break + if bonus: + values.append((bonus, count, name)) + values.sort(reverse=True) + return values[: int(rules["max_sources"])] + + def lineup_power(player: dict[str, Any]) -> dict[str, Any]: board = player.get("board", []) if len(board) != STARTER_COUNT: @@ -657,7 +673,7 @@ def lineup_power(player: dict[str, Any]) -> dict[str, Any]: roles = {"步枪手": 0, "狙击手": 0, "指挥": 0} for unit in board: card = CARD_BY_ID[str(unit["card_id"])] - multiplier = {1: 1.0, 2: 1.35, 3: 1.80}[int(unit["star"])] + multiplier = STAR_MULTIPLIERS[int(unit["star"])] axes["火力"] += float(card["firepower"]) * multiplier axes["稳定"] += float(card["stability"]) * multiplier axes["残局"] += float(card["clutch"]) * multiplier @@ -665,20 +681,10 @@ def lineup_power(player: dict[str, Any]) -> dict[str, Any]: nations[card["nation"]] = nations.get(card["nation"], 0) + 1 for team in card["teams"]: teams[team] = teams.get(team, 0) + 1 - team_values = [] - for name, count in teams.items(): - bonus = 22 if count >= 5 else 15 if count >= 4 else 9 if count >= 3 else 4 if count >= 2 else 0 - if bonus: - team_values.append((bonus, count, name)) - team_values.sort(reverse=True) - team_bonus = sum(item[0] for item in team_values[:2]) - nation_values = [] - for name, count in nations.items(): - bonus = 18 if count >= 5 else 12 if count >= 4 else 7 if count >= 3 else 3 if count >= 2 else 0 - if bonus: - nation_values.append((bonus, count, name)) - nation_values.sort(reverse=True) - nation_bonus = nation_values[0][0] if nation_values else 0 + team_values = _synergy_values(teams, TEAM_SYNERGY_RULE) + team_bonus = sum(item[0] for item in team_values) + nation_values = _synergy_values(nations, NATION_SYNERGY_RULE) + nation_bonus = sum(item[0] for item in nation_values) coach_id = str(player.get("coach_id", "")) coach = COACH_BY_ID.get(coach_id) if coach is not None: @@ -721,8 +727,8 @@ def matchup_modifier( axes_b = dict(info_b["axes"]) tactic_a = str(player_a.get("tactic", "稳定")) tactic_b = str(player_b.get("tactic", "稳定")) - axes_a[tactic_a] *= 1.08 - axes_b[tactic_b] *= 1.08 + axes_a[tactic_a] *= TACTIC_AXIS_MULTIPLIER + axes_b[tactic_b] *= TACTIC_AXIS_MULTIPLIER def pressure( own: dict[str, float], @@ -734,7 +740,10 @@ def matchup_modifier( ) axis_net = pressure(axes_a, axes_b) - pressure(axes_b, axes_a) - axis_percent = max(-8.0, min(8.0, axis_net / 12.5)) + axis_percent = max( + -TACTIC_AXIS_PERCENT_CAP, + min(TACTIC_AXIS_PERCENT_CAP, axis_net / TACTIC_AXIS_PERCENT_DIVISOR), + ) roles_a = info_a["roles"] roles_b = info_b["roles"] role_edge_a = sum( @@ -746,17 +755,20 @@ def matchup_modifier( for strong, weak in ROLE_COUNTERS.items() ) role_percent = max( - -5.0, - min(5.0, (role_edge_a - role_edge_b) * 0.8), + -TACTIC_ROLE_PERCENT_CAP, + min( + TACTIC_ROLE_PERCENT_CAP, + (role_edge_a - role_edge_b) * TACTIC_ROLE_PERCENT_SCALE, + ), ) tactic_percent = 0.0 if TACTIC_COUNTERS[tactic_a] == tactic_b: - tactic_percent = 6.0 + tactic_percent = TACTIC_COUNTER_PERCENT elif TACTIC_COUNTERS[tactic_b] == tactic_a: - tactic_percent = -6.0 + tactic_percent = -TACTIC_COUNTER_PERCENT total = max( - -15.0, - min(15.0, axis_percent + role_percent + tactic_percent), + -TACTIC_TOTAL_PERCENT_CAP, + min(TACTIC_TOTAL_PERCENT_CAP, axis_percent + role_percent + tactic_percent), ) return { "a_total_percent": round(total, 2), @@ -1150,8 +1162,6 @@ def _auto_manage_ai( return stories -MAP_POOL = ("Mirage", "Inferno", "Nuke", "Ancient", "Dust2", "Anubis", "Overpass") - def _round_probability( adjusted_a: float, @@ -1162,13 +1172,35 @@ def _round_probability( score_b: int, ) -> float: average = max(1.0, (adjusted_a + adjusted_b) / 2) - power_edge = max(-0.16, min(0.16, (adjusted_a - adjusted_b) / average * 0.30)) - pressure = max(-0.04, min(0.04, (score_b - score_a) * 0.004)) - side_swing = 0.018 if round_index <= 15 else -0.012 - if round_index > 30: + power_edge = max( + -ROUND_POWER_EDGE_CAP, + min( + ROUND_POWER_EDGE_CAP, + (adjusted_a - adjusted_b) / average * ROUND_POWER_EDGE_SCALE, + ), + ) + pressure = max( + -ROUND_PRESSURE_CAP, + min(ROUND_PRESSURE_CAP, (score_b - score_a) * ROUND_PRESSURE_SCALE), + ) + side_swing = ( + ROUND_FIRST_HALF_SIDE_SWING + if round_index <= BATTLE_MR_ROUNDS // 2 + else ROUND_SECOND_HALF_SIDE_SWING + ) + if round_index > BATTLE_MR_ROUNDS: side_swing = 0.0 - jitter = ((_hash_int(f"{seed}|prob|{round_index}") % 701) - 350) / 10_000 - return max(0.32, min(0.68, 0.5 + power_edge + pressure + side_swing + jitter)) + jitter = ( + (_hash_int(f"{seed}|prob|{round_index}") % ROUND_JITTER_MOD) + - ROUND_JITTER_CENTER + ) / ROUND_JITTER_DIVISOR + return max( + ROUND_PROBABILITY_MIN, + min( + ROUND_PROBABILITY_MAX, + ROUND_PROBABILITY_BASE + power_edge + pressure + side_swing + jitter, + ), + ) def _simulate_map( @@ -1215,34 +1247,40 @@ def _simulate_map( best_run = current_run best_run_key = winner_key - for round_index in range(1, 31): + for round_index in range(1, BATTLE_MR_ROUNDS + 1): record_round(round_index) - if score_a >= 16 or score_b >= 16: + if score_a >= BATTLE_TARGET_SCORE or score_b >= BATTLE_TARGET_SCORE: break round_index = len(rounds) - if score_a == 15 and score_b == 15: - while max(score_a, score_b) < 19 or abs(score_a - score_b) < 2: + if ( + score_a == BATTLE_OVERTIME_START_SCORE + and score_b == BATTLE_OVERTIME_START_SCORE + ): + while ( + max(score_a, score_b) < BATTLE_OVERTIME_TARGET_SCORE + or abs(score_a - score_b) < BATTLE_OVERTIME_WIN_BY + ): round_index += 1 record_round(round_index) - if round_index > 72: + if round_index > BATTLE_OVERTIME_SAFETY_CAP: break winner_key = key_a if score_a > score_b else key_b loser_key = key_b if winner_key == key_a else key_a winner_score = score_a if winner_key == key_a else score_b loser_score = score_b if winner_key == key_a else score_a - overtime_rounds = max(0, len(rounds) - 30) - overtime_count = (overtime_rounds + 5) // 6 + overtime_rounds = max(0, len(rounds) - BATTLE_MR_ROUNDS) + overtime_count = ( + overtime_rounds + BATTLE_OVERTIME_SEGMENT_ROUNDS - 1 + ) // BATTLE_OVERTIME_SEGMENT_ROUNDS if overtime_count: band = "加时险胜" - elif loser_score <= 6: - band = "压制" - elif loser_score <= 10: - band = "优势" - elif loser_score <= 13: - band = "小胜" else: - band = "险胜" + band = next( + str(item["label"]) + for item in SCORE_BANDS + if loser_score <= int(item["max_loser_score"]) + ) map_name = MAP_POOL[(map_index - 1) % len(MAP_POOL)] winner_team = _team_name(state["players"][winner_key]) loser_team = _team_name(state["players"][loser_key]) @@ -1357,7 +1395,13 @@ def _battle( winner_maps = max(wins_a, wins_b) loser_maps = min(wins_a, wins_b) sweep = loser_maps == 0 - damage = 18 if sweep and best_of == 5 else 15 if sweep else 10 + damage = ( + BATTLE_BO5_SWEEP_DAMAGE + if sweep and best_of == 5 + else BATTLE_SWEEP_DAMAGE + if sweep + else BATTLE_NON_SWEEP_DAMAGE + ) return { "winner_key": winner_key, "loser_key": loser_key, @@ -1563,7 +1607,7 @@ def _settle_group( key_a, key_b, f"{state['run_id']}|group|{stage['day']}|{event_id}|{index}", - best_of=3, + best_of=MATCH_BEST_OF["group"], ) _apply_match_result( state, @@ -1641,7 +1685,7 @@ def _settle_semifinal( key_a, key_b, f"{state['run_id']}|semifinal|{event_id}|{index}", - best_of=5, + best_of=MATCH_BEST_OF["semifinal"], ) _apply_match_result( state, @@ -1689,7 +1733,7 @@ def _settle_final( key_a, key_b, f"{state['run_id']}|final|{event_id}", - best_of=5, + best_of=MATCH_BEST_OF["final"], ) _apply_match_result( state, @@ -1779,7 +1823,10 @@ def _settle_round( return lines, stories, battles for key in state["join_order"]: player = state["players"][key] - income = 5 + min(5, int(player["gold"]) // 10) + income = ROUND_INCOME_BASE + min( + ROUND_INTEREST_CAP, + int(player["gold"]) // ROUND_INTEREST_DIVISOR, + ) player["gold"] = int(player["gold"]) + income player["ready"] = False state["round_number"] = int(state["round_number"]) + 1 @@ -1998,32 +2045,32 @@ def _draft_candidate_cards(state: dict[str, Any], round_number: int) -> list[str ) selected: list[str] = [] preferred_costs = ( - (2, 3, 1, 4, 5) + DRAFT_ROUND_TWO_PREFERRED_COSTS if round_number == 2 - else (3, 2, 4, 1, 5) + else DRAFT_LATER_ROUND_PREFERRED_COSTS ) for cost in preferred_costs: for card_id in cards: if ( card_id not in selected and CARD_BY_ID[card_id]["cost"] == cost - and len(selected) < SHOP_SIZE + and len(selected) < DRAFT_PLAYER_CHOICE_COUNT ): selected.append(card_id) - if len(selected) >= SHOP_SIZE: + if len(selected) >= DRAFT_PLAYER_CHOICE_COUNT: break for card_id in cards: - if len(selected) >= SHOP_SIZE: + if len(selected) >= DRAFT_PLAYER_CHOICE_COUNT: break if card_id not in selected: selected.append(card_id) - return selected[:SHOP_SIZE] + return selected[:DRAFT_PLAYER_CHOICE_COUNT] def _start_draft_round(state: dict[str, Any], round_number: int) -> None: - draft_type = "coach" if round_number == 1 else "player" + draft_type = "coach" if round_number == DRAFT_COACH_ROUND else "player" candidates = ( - [coach["id"] for coach in COACH_POOL[:10]] + [coach["id"] for coach in COACH_POOL[:DRAFT_COACH_CHOICE_COUNT]] if draft_type == "coach" else _draft_candidate_cards(state, round_number) ) @@ -2039,7 +2086,11 @@ def _start_draft_round(state: dict[str, Any], round_number: int) -> None: def _draft_round_title(draft: dict[str, Any]) -> str: number = int(draft["round"]) - return "第一轮选秀,本轮选教练" if number == 1 else f"第 {number} 轮选秀,本轮选选手" + return ( + "第一轮选秀,本轮选教练" + if number == DRAFT_COACH_ROUND + else f"第 {number} 轮选秀,本轮选选手" + ) def _draft_board_part(state: dict[str, Any]) -> str: @@ -2057,7 +2108,7 @@ def _draft_board_part(state: dict[str, Any]) -> str: _coach_line(index, coach_id) for index, coach_id in enumerate(draft["candidates"], start=1) ) - lines.append("操作:轮到你时发送 /选1~/选10。") + lines.append(f"操作:轮到你时发送 /选1~/选{DRAFT_COACH_CHOICE_COUNT}。") else: lines.extend( _card_line(index, card_id) @@ -2075,7 +2126,11 @@ def _draft_prompt_part(state: dict[str, Any]) -> str: return "本轮选秀已完成。" key = str(order[cursor]) name = state["players"][key]["display_name"] - command = "/选1~/选10" if draft["type"] == "coach" else "/选1~/选6" + command = ( + f"/选1~/选{DRAFT_COACH_CHOICE_COUNT}" + if draft["type"] == "coach" + else f"/选1~/选{DRAFT_PLAYER_CHOICE_COUNT}" + ) return f"现在轮到 @{name} 做出选择,使用 {command}。" @@ -2115,7 +2170,7 @@ def _apply_draft_choice( ) else: card = CARD_BY_ID[choice_id] - draft_copies = 3 + draft_copies = DRAFT_PLAYER_COPY_COUNT if int(state["pool"].get(choice_id, 0)) < draft_copies: raise ValueError("这名选手已经被选走") state["pool"][choice_id] -= draft_copies @@ -2181,7 +2236,7 @@ def _finish_draft_round_if_needed(state: dict[str, Any]) -> list[str]: if int(draft.get("cursor", 0)) < len(draft.get("order", [])): break round_number = int(draft["round"]) - if round_number < 3: + if round_number < DRAFT_ROUNDS: _start_draft_round(state, round_number + 1) parts.append(_draft_board_part(state)) parts.extend(_auto_advance_draft(state)) @@ -2425,7 +2480,7 @@ def _settle_training_round( key_a, key_b, f"{state['run_id']}|training|{round_number}|{match_index}|{event_id}", - best_of=1, + best_of=MATCH_BEST_OF["training"], ) winner_key = str(result["winner_key"]) loser_key = str(result["loser_key"]) @@ -2494,7 +2549,7 @@ def _group_schedule(state: dict[str, Any]) -> list[dict[str, Any]]: "round": round_index, "match": match_index, "pair": [str(pair[0]), str(pair[1])], - "best_of": 3, + "best_of": MATCH_BEST_OF["group"], "bye": bye, } ) @@ -2508,10 +2563,10 @@ def _match_title(state: dict[str, Any], match: dict[str, Any]) -> str: if match["stage"] == "group": return ( f"小组赛第 {match['round']} 轮第 {match['match']} 场:" - f"{_team_name(player_a)} vs {_team_name(player_b)}(BO3)" + f"{_team_name(player_a)} vs {_team_name(player_b)}(BO{match['best_of']})" ) label = "淘汰赛半决赛" if match["stage"] == "semifinal" else "总决赛" - return f"{label}:{_team_name(player_a)} vs {_team_name(player_b)}(BO5)" + return f"{label}:{_team_name(player_a)} vs {_team_name(player_b)}(BO{match['best_of']})" def _match_ready_keys(state: dict[str, Any], match: dict[str, Any]) -> list[str]: @@ -2557,7 +2612,10 @@ def _start_group_parts(state: dict[str, Any]) -> list[str]: rendered.append(f"{state['players'][bye]['display_name']} 轮空") pair_lines.append(";".join(rendered)) parts = [ - f"欢迎来到{state['event_title']}正赛小组赛!小组赛采用单循环 BO3。", + ( + f"欢迎来到{state['event_title']}正赛小组赛!" + f"小组赛采用单循环 BO{MATCH_BEST_OF['group']}。" + ), "本阶段对阵:\n" + "\n".join(pair_lines), ] if state["group_matches"]: @@ -2604,7 +2662,11 @@ def _series_report_parts( ) winner_key = str(result["winner_key"]) loser_key = str(result["loser_key"]) - reward = 5 if match["stage"] == "group" else 6 + reward = ( + GROUP_SERIES_WIN_GOLD + if match["stage"] == "group" + else PLAYOFF_SERIES_WIN_GOLD + ) state["players"][winner_key]["gold"] = int(state["players"][winner_key]["gold"]) + reward final_text = ( f"系列赛结束,{_team_name(state['players'][winner_key])} 以 {result['score']} 击败 " @@ -2667,15 +2729,15 @@ def _special_event_parts( state: dict[str, Any], event_id: str, ) -> tuple[list[str], list[dict[str, Any]]]: - event_kind = "媒体日挑战" if _hash_int(state["run_id"] + event_id) % 2 == 0 else "赞助挑战" - target = 320 if event_kind == "媒体日挑战" else 340 + event_key = "media" if _hash_int(state["run_id"] + event_id) % 2 == 0 else "sponsor" + event_kind, target, win_gold, loss_gold = SPECIAL_EVENTS[event_key] parts = [f"{event_kind}突然插入。本届只会出现一次这种挑战;完成后继续小组赛。"] stories: list[dict[str, Any]] = [] for key in state["join_order"]: player = state["players"][key] power = lineup_power(player)["power"] won = power >= target - reward = 4 if won else 1 + reward = win_gold if won else loss_gold player["gold"] = int(player["gold"]) + reward hook = _pick_fragment( "challenges", @@ -2701,13 +2763,31 @@ def _playoff_matches_from_rankings(state: dict[str, Any]) -> list[dict[str, Any] qualifiers = ranking[:4] state["qualifiers"] = qualifiers return [ - {"stage": "semifinal", "round": 1, "match": 1, "pair": [qualifiers[0], qualifiers[3]], "best_of": 5}, - {"stage": "semifinal", "round": 1, "match": 2, "pair": [qualifiers[1], qualifiers[2]], "best_of": 5}, + { + "stage": "semifinal", + "round": 1, + "match": 1, + "pair": [qualifiers[0], qualifiers[3]], + "best_of": MATCH_BEST_OF["semifinal"], + }, + { + "stage": "semifinal", + "round": 1, + "match": 2, + "pair": [qualifiers[1], qualifiers[2]], + "best_of": MATCH_BEST_OF["semifinal"], + }, ] finalists = ranking[:2] state["finalists"] = finalists return [ - {"stage": "final", "round": 1, "match": 1, "pair": finalists, "best_of": 5} + { + "stage": "final", + "round": 1, + "match": 1, + "pair": finalists, + "best_of": MATCH_BEST_OF["final"], + } ] @@ -2752,12 +2832,14 @@ class CsMajorAutochess: "CS自走棋:/自走棋开始 创建报名;/自走棋报名;主持人 /自走棋开赛。\n" "两名或三名真人报名后,主持人可用 /自走棋加AI战队," "分别开启 2 人+2 AI 或 3 人+1 AI 测试模式。\n" - "开赛后三轮选秀:第一轮固定 10 名教练,第二、三轮选手。" + f"开赛后 {DRAFT_ROUNDS} 轮选秀:第一轮固定 {DRAFT_COACH_CHOICE_COUNT} 名教练," + "后续轮次选选手。" "选秀完成后每队 5 名首发、1 名教练、3 个备战席。\n" "/选1~/选6;/阵容;/刷新;/锁定;/上阵 备战编号 [首发位];/出售 备战编号。" "/战术 火力|稳定|残局,稳定克火力、火力克残局、残局克稳定。\n" "训练赛按参赛人数生成多轮真实对阵,每轮结束后可 D 牌和调阵容;" - "随后进入 BO3 单循环小组赛,半决赛/决赛 BO5。" + f"随后进入 BO{MATCH_BEST_OF['group']} 单循环小组赛," + f"半决赛/决赛 BO{MATCH_BEST_OF['final']}。" "所有需要推进的阶段由相关真人发送 /结束回合,满足条件后自动进入下一步。" ) @@ -2853,7 +2935,7 @@ class CsMajorAutochess: "schema_version": 3, "run_id": f"major_{season:04d}", "season_number": season, - "event_title": f"第 {season} 届 IEM 盐田 Major", + "event_title": EVENT_TITLE_TEMPLATE.format(season=season), "status": "registration", "host_person_key": person_key, "host_display_name": display_name, @@ -2966,8 +3048,10 @@ class CsMajorAutochess: f"参赛者:{names}。", ( f"种子顺序:{_seed_lines(state)}。本届采用 {mode}:" - "先进行三轮选秀,随后按轮次训练赛、小组赛 BO3 单循环," - "淘汰赛 BO5;单图 MR15,常规时间 15-15 进入加时。" + f"先进行 {DRAFT_ROUNDS} 轮选秀,随后按轮次训练赛、" + f"小组赛 BO{MATCH_BEST_OF['group']} 单循环," + f"淘汰赛 BO{MATCH_BEST_OF['final']};单图 MR15," + f"常规时间 {BATTLE_OVERTIME_START_SCORE}-{BATTLE_OVERTIME_START_SCORE} 进入加时。" ), _draft_board_part(state), ] @@ -3018,7 +3102,13 @@ class CsMajorAutochess: ) values = _parse_indices(arguments) if len(values) != 1: - return respond(state, "用法:/选1~/选10(教练)或 /选1~/选6(选手)") + return respond( + state, + ( + f"用法:/选1~/选{DRAFT_COACH_CHOICE_COUNT}(教练)" + f"或 /选1~/选{DRAFT_PLAYER_CHOICE_COUNT}(选手)" + ), + ) try: text = _apply_draft_choice(state, person_key, values[0]) except ValueError as error: @@ -3215,10 +3305,12 @@ class CsMajorAutochess: "round": 1, "match": 1, "pair": winners[:2], - "best_of": 5, + "best_of": MATCH_BEST_OF["final"], } state["finalists"] = winners[:2] - parts.append("半决赛全部结束,胜者进入 BO5 总决赛。") + parts.append( + f"半决赛全部结束,胜者进入 BO{MATCH_BEST_OF['final']} 总决赛。" + ) parts.extend(_set_current_match(state, final_match)) if not state.get("ready_keys"): final_parts, final_stories, final_battles = _settle_current_match( diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/coaches.json b/packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/coaches.json new file mode 100644 index 0000000..965ee65 --- /dev/null +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/coaches.json @@ -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 +} diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/narratives.json b/packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/narratives.json new file mode 100644 index 0000000..3ec0f8b --- /dev/null +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/narratives.json @@ -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 +} diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/players.json b/packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/players.json new file mode 100644 index 0000000..a94cf7c --- /dev/null +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/players.json @@ -0,0 +1,1852 @@ +{ + "players": [ + { + "clutch": 91, + "cost": 5, + "firepower": 98, + "id": "donk", + "leadership": 0, + "name": "donk", + "nation": "俄罗斯", + "role": "步枪手", + "stability": 92, + "team": "Spirit", + "teams": [ + "Spirit" + ] + }, + { + "clutch": 90, + "cost": 4, + "firepower": 91, + "id": "sh1ro", + "leadership": 0, + "name": "sh1ro", + "nation": "俄罗斯", + "role": "狙击手", + "stability": 92, + "team": "Spirit", + "teams": [ + "Spirit", + "Cloud9", + "Gambit" + ] + }, + { + "clutch": 76, + "cost": 3, + "firepower": 72, + "id": "chopper", + "leadership": 94, + "name": "chopper", + "nation": "俄罗斯", + "role": "指挥", + "stability": 83, + "team": "Spirit", + "teams": [ + "Spirit", + "Vega" + ] + }, + { + "clutch": 61, + "cost": 1, + "firepower": 65, + "id": "zont1x", + "leadership": 0, + "name": "zont1x", + "nation": "乌克兰", + "role": "步枪手", + "stability": 66, + "team": "Spirit", + "teams": [ + "Spirit" + ] + }, + { + "clutch": 64, + "cost": 1, + "firepower": 63, + "id": "magixx", + "leadership": 0, + "name": "magixx", + "nation": "俄罗斯", + "role": "步枪手", + "stability": 67, + "team": "Spirit", + "teams": [ + "Spirit" + ] + }, + { + "clutch": 98, + "cost": 5, + "firepower": 97, + "id": "zywoo", + "leadership": 0, + "name": "ZywOo", + "nation": "法国", + "role": "狙击手", + "stability": 96, + "team": "Vitality", + "teams": [ + "Vitality" + ] + }, + { + "clutch": 92, + "cost": 4, + "firepower": 90, + "id": "ropz", + "leadership": 0, + "name": "ropz", + "nation": "爱沙尼亚", + "role": "步枪手", + "stability": 92, + "team": "Vitality", + "teams": [ + "Vitality", + "FaZe", + "MOUZ" + ] + }, + { + "clutch": 77, + "cost": 3, + "firepower": 84, + "id": "flamez", + "leadership": 0, + "name": "flameZ", + "nation": "以色列", + "role": "步枪手", + "stability": 79, + "team": "Vitality", + "teams": [ + "Vitality", + "OG" + ] + }, + { + "clutch": 73, + "cost": 3, + "firepower": 70, + "id": "apex", + "leadership": 97, + "name": "apEX", + "nation": "法国", + "role": "指挥", + "stability": 82, + "team": "Vitality", + "teams": [ + "Vitality", + "EnVyUs", + "G2" + ] + }, + { + "clutch": 65, + "cost": 1, + "firepower": 66, + "id": "mezii", + "leadership": 0, + "name": "mezii", + "nation": "英国", + "role": "步枪手", + "stability": 68, + "team": "Vitality", + "teams": [ + "Vitality", + "fnatic", + "Cloud9" + ] + }, + { + "clutch": 92, + "cost": 4, + "firepower": 92, + "id": "m0nesy", + "leadership": 0, + "name": "m0NESY", + "nation": "俄罗斯", + "role": "狙击手", + "stability": 89, + "team": "Falcons", + "teams": [ + "Falcons", + "G2", + "NAVI" + ] + }, + { + "clutch": 91, + "cost": 4, + "firepower": 92, + "id": "niko", + "leadership": 0, + "name": "NiKo", + "nation": "波黑", + "role": "步枪手", + "stability": 88, + "team": "Falcons", + "teams": [ + "Falcons", + "G2", + "FaZe" + ] + }, + { + "clutch": 84, + "cost": 4, + "firepower": 92, + "id": "kyousuke", + "leadership": 0, + "name": "kyousuke", + "nation": "俄罗斯", + "role": "步枪手", + "stability": 82, + "team": "Falcons", + "teams": [ + "Falcons", + "Spirit" + ] + }, + { + "clutch": 84, + "cost": 3, + "firepower": 80, + "id": "magisk", + "leadership": 0, + "name": "Magisk", + "nation": "丹麦", + "role": "步枪手", + "stability": 84, + "team": "Falcons", + "teams": [ + "Falcons", + "Vitality", + "Astralis" + ] + }, + { + "clutch": 62, + "cost": 1, + "firepower": 66, + "id": "teses", + "leadership": 0, + "name": "TeSeS", + "nation": "丹麦", + "role": "步枪手", + "stability": 65, + "team": "Falcons", + "teams": [ + "Falcons", + "HEROIC" + ] + }, + { + "clutch": 81, + "cost": 3, + "firepower": 84, + "id": "malbsmd", + "leadership": 0, + "name": "malbsMd", + "nation": "危地马拉", + "role": "步枪手", + "stability": 78, + "team": "G2", + "teams": [ + "G2", + "M80" + ] + }, + { + "clutch": 82, + "cost": 3, + "firepower": 79, + "id": "hunter", + "leadership": 0, + "name": "huNter-", + "nation": "波黑", + "role": "步枪手", + "stability": 84, + "team": "G2", + "teams": [ + "G2" + ] + }, + { + "clutch": 63, + "cost": 1, + "firepower": 67, + "id": "heavygod", + "leadership": 0, + "name": "HeavyGod", + "nation": "以色列", + "role": "步枪手", + "stability": 66, + "team": "G2", + "teams": [ + "G2", + "Cloud9", + "OG" + ] + }, + { + "clutch": 72, + "cost": 2, + "firepower": 62, + "id": "snax", + "leadership": 88, + "name": "Snax", + "nation": "波兰", + "role": "指挥", + "stability": 73, + "team": "G2", + "teams": [ + "G2", + "GamerLegion", + "Virtus.pro" + ] + }, + { + "clutch": 84, + "cost": 3, + "firepower": 82, + "id": "sunpayus", + "leadership": 0, + "name": "SunPayus", + "nation": "西班牙", + "role": "狙击手", + "stability": 83, + "team": "G2", + "teams": [ + "G2", + "Falcons", + "ENCE" + ] + }, + { + "clutch": 88, + "cost": 4, + "firepower": 89, + "id": "frozen", + "leadership": 0, + "name": "frozen", + "nation": "斯洛伐克", + "role": "步枪手", + "stability": 91, + "team": "FaZe", + "teams": [ + "FaZe", + "MOUZ" + ] + }, + { + "clutch": 92, + "cost": 4, + "firepower": 87, + "id": "broky", + "leadership": 0, + "name": "broky", + "nation": "拉脱维亚", + "role": "狙击手", + "stability": 88, + "team": "FaZe", + "teams": [ + "FaZe" + ] + }, + { + "clutch": 84, + "cost": 3, + "firepower": 79, + "id": "rain", + "leadership": 0, + "name": "rain", + "nation": "挪威", + "role": "步枪手", + "stability": 82, + "team": "FaZe", + "teams": [ + "FaZe" + ] + }, + { + "clutch": 60, + "cost": 1, + "firepower": 54, + "id": "karrigan", + "leadership": 98, + "name": "karrigan", + "nation": "丹麦", + "role": "指挥", + "stability": 64, + "team": "FaZe", + "teams": [ + "FaZe", + "MOUZ", + "Astralis" + ] + }, + { + "clutch": 79, + "cost": 3, + "firepower": 84, + "id": "elige", + "leadership": 0, + "name": "EliGE", + "nation": "美国", + "role": "步枪手", + "stability": 80, + "team": "FaZe", + "teams": [ + "FaZe", + "Complexity", + "Liquid" + ] + }, + { + "clutch": 83, + "cost": 3, + "firepower": 84, + "id": "b1t", + "leadership": 0, + "name": "b1t", + "nation": "乌克兰", + "role": "步枪手", + "stability": 84, + "team": "NAVI", + "teams": [ + "NAVI" + ] + }, + { + "clutch": 84, + "cost": 3, + "firepower": 84, + "id": "w0nderful", + "leadership": 0, + "name": "w0nderful", + "nation": "乌克兰", + "role": "狙击手", + "stability": 82, + "team": "NAVI", + "teams": [ + "NAVI", + "Spirit" + ] + }, + { + "clutch": 84, + "cost": 3, + "firepower": 82, + "id": "jl", + "leadership": 0, + "name": "jL", + "nation": "立陶宛", + "role": "步枪手", + "stability": 81, + "team": "NAVI", + "teams": [ + "NAVI", + "Apeks" + ] + }, + { + "clutch": 70, + "cost": 2, + "firepower": 64, + "id": "aleksib", + "leadership": 95, + "name": "Aleksib", + "nation": "芬兰", + "role": "指挥", + "stability": 76, + "team": "NAVI", + "teams": [ + "NAVI", + "G2", + "ENCE" + ] + }, + { + "clutch": 64, + "cost": 1, + "firepower": 67, + "id": "im", + "leadership": 0, + "name": "iM", + "nation": "罗马尼亚", + "role": "步枪手", + "stability": 63, + "team": "NAVI", + "teams": [ + "NAVI", + "GamerLegion" + ] + }, + { + "clutch": 80, + "cost": 3, + "firepower": 84, + "id": "xertion", + "leadership": 0, + "name": "xertioN", + "nation": "以色列", + "role": "步枪手", + "stability": 78, + "team": "MOUZ", + "teams": [ + "MOUZ" + ] + }, + { + "clutch": 84, + "cost": 3, + "firepower": 82, + "id": "torzsi", + "leadership": 0, + "name": "torzsi", + "nation": "匈牙利", + "role": "狙击手", + "stability": 83, + "team": "MOUZ", + "teams": [ + "MOUZ" + ] + }, + { + "clutch": 84, + "cost": 3, + "firepower": 80, + "id": "jimpphat", + "leadership": 0, + "name": "Jimpphat", + "nation": "芬兰", + "role": "步枪手", + "stability": 84, + "team": "MOUZ", + "teams": [ + "MOUZ" + ] + }, + { + "clutch": 61, + "cost": 1, + "firepower": 68, + "id": "brollan", + "leadership": 0, + "name": "Brollan", + "nation": "瑞典", + "role": "步枪手", + "stability": 64, + "team": "MOUZ", + "teams": [ + "MOUZ", + "NIP", + "fnatic" + ] + }, + { + "clutch": 78, + "cost": 3, + "firepower": 71, + "id": "siuhy", + "leadership": 95, + "name": "siuhy", + "nation": "波兰", + "role": "指挥", + "stability": 82, + "team": "Liquid", + "teams": [ + "Liquid", + "MOUZ", + "GamerLegion" + ] + }, + { + "clutch": 83, + "cost": 3, + "firepower": 84, + "id": "senzu", + "leadership": 0, + "name": "Senzu", + "nation": "蒙古", + "role": "步枪手", + "stability": 80, + "team": "The MongolZ", + "teams": [ + "The MongolZ" + ] + }, + { + "clutch": 66, + "cost": 1, + "firepower": 68, + "id": "910", + "leadership": 0, + "name": "910", + "nation": "蒙古", + "role": "狙击手", + "stability": 65, + "team": "The MongolZ", + "teams": [ + "The MongolZ" + ] + }, + { + "clutch": 74, + "cost": 2, + "firepower": 68, + "id": "blitz", + "leadership": 91, + "name": "bLitz", + "nation": "蒙古", + "role": "指挥", + "stability": 76, + "team": "The MongolZ", + "teams": [ + "The MongolZ" + ] + }, + { + "clutch": 62, + "cost": 1, + "firepower": 66, + "id": "techno", + "leadership": 0, + "name": "Techno", + "nation": "蒙古", + "role": "步枪手", + "stability": 67, + "team": "The MongolZ", + "teams": [ + "The MongolZ" + ] + }, + { + "clutch": 65, + "cost": 1, + "firepower": 64, + "id": "mzinho", + "leadership": 0, + "name": "mzinho", + "nation": "蒙古", + "role": "步枪手", + "stability": 66, + "team": "The MongolZ", + "teams": [ + "The MongolZ" + ] + }, + { + "clutch": 92, + "cost": 4, + "firepower": 89, + "id": "device", + "leadership": 0, + "name": "device", + "nation": "丹麦", + "role": "狙击手", + "stability": 92, + "team": "Astralis", + "teams": [ + "Astralis", + "NIP" + ] + }, + { + "clutch": 81, + "cost": 3, + "firepower": 83, + "id": "stavn", + "leadership": 0, + "name": "stavn", + "nation": "丹麦", + "role": "步枪手", + "stability": 82, + "team": "Astralis", + "teams": [ + "Astralis", + "HEROIC" + ] + }, + { + "clutch": 80, + "cost": 3, + "firepower": 80, + "id": "jabbi", + "leadership": 0, + "name": "jabbi", + "nation": "丹麦", + "role": "步枪手", + "stability": 81, + "team": "Astralis", + "teams": [ + "Astralis", + "HEROIC" + ] + }, + { + "clutch": 67, + "cost": 2, + "firepower": 60, + "id": "hooxi", + "leadership": 94, + "name": "HooXi", + "nation": "丹麦", + "role": "指挥", + "stability": 71, + "team": "Astralis", + "teams": [ + "Astralis", + "G2", + "Copenhagen Flames" + ] + }, + { + "clutch": 64, + "cost": 1, + "firepower": 68, + "id": "staehr", + "leadership": 0, + "name": "Staehr", + "nation": "丹麦", + "role": "步枪手", + "stability": 66, + "team": "Astralis", + "teams": [ + "Astralis", + "Sprout" + ] + }, + { + "clutch": 84, + "cost": 3, + "firepower": 80, + "id": "naf", + "leadership": 0, + "name": "NAF", + "nation": "加拿大", + "role": "步枪手", + "stability": 84, + "team": "Liquid", + "teams": [ + "Liquid", + "Renegades" + ] + }, + { + "clutch": 91, + "cost": 4, + "firepower": 87, + "id": "twistzz", + "leadership": 92, + "name": "Twistzz", + "nation": "加拿大", + "role": "指挥", + "stability": 90, + "team": "Liquid", + "teams": [ + "Liquid", + "FaZe" + ] + }, + { + "clutch": 82, + "cost": 3, + "firepower": 84, + "id": "nertz", + "leadership": 0, + "name": "NertZ", + "nation": "以色列", + "role": "步枪手", + "stability": 80, + "team": "Liquid", + "teams": [ + "Liquid", + "HEROIC", + "ENCE" + ] + }, + { + "clutch": 73, + "cost": 2, + "firepower": 74, + "id": "ultimate", + "leadership": 0, + "name": "ultimate", + "nation": "波兰", + "role": "狙击手", + "stability": 72, + "team": "Liquid", + "teams": [ + "Liquid" + ] + }, + { + "clutch": 64, + "cost": 1, + "firepower": 65, + "id": "skullz", + "leadership": 0, + "name": "skullz", + "nation": "巴西", + "role": "步枪手", + "stability": 67, + "team": "Liquid", + "teams": [ + "Liquid", + "FURIA", + "paiN" + ] + }, + { + "clutch": 89, + "cost": 4, + "firepower": 90, + "id": "kscerato", + "leadership": 0, + "name": "KSCERATO", + "nation": "巴西", + "role": "步枪手", + "stability": 88, + "team": "FURIA", + "teams": [ + "FURIA" + ] + }, + { + "clutch": 80, + "cost": 3, + "firepower": 82, + "id": "yuurih", + "leadership": 0, + "name": "yuurih", + "nation": "巴西", + "role": "步枪手", + "stability": 81, + "team": "FURIA", + "teams": [ + "FURIA" + ] + }, + { + "clutch": 83, + "cost": 3, + "firepower": 70, + "id": "fallen", + "leadership": 93, + "name": "FalleN", + "nation": "巴西", + "role": "指挥", + "stability": 80, + "team": "FURIA", + "teams": [ + "FURIA", + "Liquid", + "MIBR" + ] + }, + { + "clutch": 72, + "cost": 2, + "firepower": 76, + "id": "molodoy", + "leadership": 0, + "name": "molodoy", + "nation": "哈萨克斯坦", + "role": "狙击手", + "stability": 70, + "team": "FURIA", + "teams": [ + "FURIA", + "AMKAL" + ] + }, + { + "clutch": 72, + "cost": 3, + "firepower": 84, + "id": "yekindar", + "leadership": 0, + "name": "YEKINDAR", + "nation": "拉脱维亚", + "role": "步枪手", + "stability": 74, + "team": "FURIA", + "teams": [ + "FURIA", + "Liquid", + "Virtus.pro" + ] + }, + { + "clutch": 85, + "cost": 4, + "firepower": 92, + "id": "xantares", + "leadership": 0, + "name": "XANTARES", + "nation": "土耳其", + "role": "步枪手", + "stability": 86, + "team": "Eternal Fire", + "teams": [ + "Eternal Fire", + "BIG" + ] + }, + { + "clutch": 82, + "cost": 3, + "firepower": 83, + "id": "woxic", + "leadership": 0, + "name": "woxic", + "nation": "土耳其", + "role": "狙击手", + "stability": 80, + "team": "Eternal Fire", + "teams": [ + "Eternal Fire", + "Cloud9", + "MOUZ" + ] + }, + { + "clutch": 71, + "cost": 2, + "firepower": 68, + "id": "maj3r", + "leadership": 93, + "name": "MAJ3R", + "nation": "土耳其", + "role": "指挥", + "stability": 74, + "team": "Eternal Fire", + "teams": [ + "Eternal Fire", + "Space Soldiers" + ] + }, + { + "clutch": 68, + "cost": 2, + "firepower": 76, + "id": "wicadia", + "leadership": 0, + "name": "Wicadia", + "nation": "土耳其", + "role": "步枪手", + "stability": 70, + "team": "Eternal Fire", + "teams": [ + "Eternal Fire" + ] + }, + { + "clutch": 63, + "cost": 1, + "firepower": 67, + "id": "jottaaa", + "leadership": 0, + "name": "jottAAA", + "nation": "土耳其", + "role": "步枪手", + "stability": 64, + "team": "Eternal Fire", + "teams": [ + "Eternal Fire", + "Sangal" + ] + }, + { + "clutch": 72, + "cost": 2, + "firepower": 76, + "id": "teses_heroic", + "leadership": 0, + "name": "TeSeS", + "nation": "丹麦", + "role": "步枪手", + "stability": 73, + "team": "HEROIC", + "teams": [ + "HEROIC", + "Falcons" + ] + }, + { + "clutch": 77, + "cost": 2, + "firepower": 74, + "id": "sjuush", + "leadership": 0, + "name": "sjuush", + "nation": "丹麦", + "role": "步枪手", + "stability": 78, + "team": "HEROIC", + "teams": [ + "HEROIC" + ] + }, + { + "clutch": 82, + "cost": 3, + "firepower": 82, + "id": "degster", + "leadership": 0, + "name": "degster", + "nation": "俄罗斯", + "role": "狙击手", + "stability": 76, + "team": "HEROIC", + "teams": [ + "HEROIC", + "OG", + "Spirit" + ] + }, + { + "clutch": 70, + "cost": 2, + "firepower": 64, + "id": "kyxsan", + "leadership": 90, + "name": "kyxsan", + "nation": "北马其顿", + "role": "指挥", + "stability": 75, + "team": "HEROIC", + "teams": [ + "HEROIC", + "Apeks" + ] + }, + { + "clutch": 82, + "cost": 3, + "firepower": 85, + "id": "nertz_heroic", + "leadership": 0, + "name": "NertZ", + "nation": "以色列", + "role": "步枪手", + "stability": 79, + "team": "HEROIC", + "teams": [ + "HEROIC", + "ENCE", + "Liquid" + ] + }, + { + "clutch": 80, + "cost": 2, + "firepower": 78, + "id": "torzi_mouz", + "leadership": 0, + "name": "torzsi", + "nation": "匈牙利", + "role": "狙击手", + "stability": 78, + "team": "MOUZ", + "teams": [ + "MOUZ" + ] + }, + { + "clutch": 86, + "cost": 4, + "firepower": 88, + "id": "spinx", + "leadership": 0, + "name": "Spinx", + "nation": "以色列", + "role": "步枪手", + "stability": 87, + "team": "Vitality", + "teams": [ + "Vitality", + "ENCE" + ] + }, + { + "clutch": 84, + "cost": 4, + "firepower": 87, + "id": "electronic", + "leadership": 0, + "name": "electroNic", + "nation": "俄罗斯", + "role": "步枪手", + "stability": 84, + "team": "Virtus.pro", + "teams": [ + "Virtus.pro", + "NAVI", + "Cloud9" + ] + }, + { + "clutch": 82, + "cost": 3, + "firepower": 82, + "id": "fame", + "leadership": 0, + "name": "fame", + "nation": "俄罗斯", + "role": "步枪手", + "stability": 83, + "team": "Virtus.pro", + "teams": [ + "Virtus.pro" + ] + }, + { + "clutch": 80, + "cost": 3, + "firepower": 81, + "id": "fl1t", + "leadership": 0, + "name": "FL1T", + "nation": "俄罗斯", + "role": "步枪手", + "stability": 82, + "team": "Virtus.pro", + "teams": [ + "Virtus.pro", + "forZe" + ] + }, + { + "clutch": 86, + "cost": 3, + "firepower": 72, + "id": "jame", + "leadership": 96, + "name": "Jame", + "nation": "俄罗斯", + "role": "指挥", + "stability": 87, + "team": "Virtus.pro", + "teams": [ + "Virtus.pro", + "AVANGAR" + ] + }, + { + "clutch": 76, + "cost": 4, + "firepower": 90, + "id": "yekindar_vp", + "leadership": 0, + "name": "YEKINDAR", + "nation": "拉脱维亚", + "role": "步枪手", + "stability": 78, + "team": "Virtus.pro", + "teams": [ + "Virtus.pro", + "Liquid", + "FURIA" + ] + }, + { + "clutch": 75, + "cost": 2, + "firepower": 75, + "id": "hallzerk", + "leadership": 0, + "name": "hallzerk", + "nation": "挪威", + "role": "狙击手", + "stability": 72, + "team": "Complexity", + "teams": [ + "Complexity", + "Dignitas" + ] + }, + { + "clutch": 73, + "cost": 2, + "firepower": 74, + "id": "grim", + "leadership": 0, + "name": "Grim", + "nation": "美国", + "role": "步枪手", + "stability": 74, + "team": "Complexity", + "teams": [ + "Complexity", + "Liquid" + ] + }, + { + "clutch": 69, + "cost": 2, + "firepower": 62, + "id": "jt", + "leadership": 89, + "name": "JT", + "nation": "南非", + "role": "指挥", + "stability": 73, + "team": "Complexity", + "teams": [ + "Complexity", + "Cloud9" + ] + }, + { + "clutch": 65, + "cost": 1, + "firepower": 68, + "id": "floppy", + "leadership": 0, + "name": "floppy", + "nation": "美国", + "role": "步枪手", + "stability": 67, + "team": "Complexity", + "teams": [ + "Complexity", + "Cloud9" + ] + }, + { + "clutch": 66, + "cost": 1, + "firepower": 68, + "id": "junior", + "leadership": 0, + "name": "junior", + "nation": "美国", + "role": "狙击手", + "stability": 64, + "team": "NRG", + "teams": [ + "NRG", + "FURIA", + "Complexity" + ] + }, + { + "clutch": 74, + "cost": 2, + "firepower": 69, + "id": "art", + "leadership": 92, + "name": "arT", + "nation": "巴西", + "role": "指挥", + "stability": 68, + "team": "Fluxo", + "teams": [ + "Fluxo", + "FURIA" + ] + }, + { + "clutch": 80, + "cost": 3, + "firepower": 75, + "id": "biguzera", + "leadership": 92, + "name": "biguzera", + "nation": "巴西", + "role": "指挥", + "stability": 80, + "team": "paiN", + "teams": [ + "paiN" + ] + }, + { + "clutch": 74, + "cost": 2, + "firepower": 76, + "id": "nqz", + "leadership": 0, + "name": "nqz", + "nation": "巴西", + "role": "狙击手", + "stability": 72, + "team": "paiN", + "teams": [ + "paiN", + "00Nation" + ] + }, + { + "clutch": 63, + "cost": 1, + "firepower": 67, + "id": "snow", + "leadership": 0, + "name": "snow", + "nation": "巴西", + "role": "步枪手", + "stability": 65, + "team": "paiN", + "teams": [ + "paiN" + ] + }, + { + "clutch": 64, + "cost": 1, + "firepower": 66, + "id": "brnz4n", + "leadership": 0, + "name": "brnz4n", + "nation": "巴西", + "role": "步枪手", + "stability": 66, + "team": "MIBR", + "teams": [ + "MIBR" + ] + }, + { + "clutch": 71, + "cost": 2, + "firepower": 67, + "id": "exit", + "leadership": 88, + "name": "exit", + "nation": "巴西", + "role": "指挥", + "stability": 75, + "team": "MIBR", + "teams": [ + "MIBR" + ] + }, + { + "clutch": 77, + "cost": 3, + "firepower": 83, + "id": "insani", + "leadership": 0, + "name": "insani", + "nation": "巴西", + "role": "步枪手", + "stability": 75, + "team": "MIBR", + "teams": [ + "MIBR" + ] + }, + { + "clutch": 74, + "cost": 2, + "firepower": 76, + "id": "saffee", + "leadership": 0, + "name": "saffee", + "nation": "巴西", + "role": "狙击手", + "stability": 72, + "team": "MIBR", + "teams": [ + "MIBR", + "FURIA" + ] + }, + { + "clutch": 74, + "cost": 2, + "firepower": 75, + "id": "slaxz", + "leadership": 0, + "name": "slaxz-", + "nation": "德国", + "role": "狙击手", + "stability": 72, + "team": "GamerLegion", + "teams": [ + "GamerLegion", + "ALTERNATE" + ] + }, + { + "clutch": 72, + "cost": 2, + "firepower": 74, + "id": "volt", + "leadership": 0, + "name": "volt", + "nation": "英国", + "role": "步枪手", + "stability": 73, + "team": "GamerLegion", + "teams": [ + "GamerLegion", + "Into the Breach" + ] + }, + { + "clutch": 76, + "cost": 2, + "firepower": 75, + "id": "acoR", + "leadership": 0, + "name": "acoR", + "nation": "丹麦", + "role": "狙击手", + "stability": 73, + "team": "GamerLegion", + "teams": [ + "GamerLegion", + "MOUZ" + ] + }, + { + "clutch": 64, + "cost": 1, + "firepower": 65, + "id": "keoz", + "leadership": 0, + "name": "Keoz", + "nation": "比利时", + "role": "步枪手", + "stability": 66, + "team": "GamerLegion", + "teams": [ + "GamerLegion" + ] + }, + { + "clutch": 65, + "cost": 1, + "firepower": 66, + "id": "isak", + "leadership": 0, + "name": "isak", + "nation": "瑞典", + "role": "步枪手", + "stability": 67, + "team": "GamerLegion", + "teams": [ + "GamerLegion" + ] + }, + { + "clutch": 79, + "cost": 3, + "firepower": 77, + "id": "tabseN", + "leadership": 94, + "name": "tabseN", + "nation": "德国", + "role": "指挥", + "stability": 82, + "team": "BIG", + "teams": [ + "BIG" + ] + }, + { + "clutch": 81, + "cost": 3, + "firepower": 82, + "id": "krimbo", + "leadership": 0, + "name": "Krimbo", + "nation": "德国", + "role": "步枪手", + "stability": 82, + "team": "BIG", + "teams": [ + "BIG" + ] + }, + { + "clutch": 76, + "cost": 2, + "firepower": 76, + "id": "syrson", + "leadership": 0, + "name": "syrsoN", + "nation": "德国", + "role": "狙击手", + "stability": 73, + "team": "BIG", + "teams": [ + "BIG" + ] + }, + { + "clutch": 75, + "cost": 2, + "firepower": 76, + "id": "mantuu", + "leadership": 0, + "name": "mantuu", + "nation": "波兰", + "role": "狙击手", + "stability": 74, + "team": "BIG", + "teams": [ + "BIG", + "OG" + ] + }, + { + "clutch": 84, + "cost": 4, + "firepower": 88, + "id": "blamef", + "leadership": 0, + "name": "blameF", + "nation": "丹麦", + "role": "步枪手", + "stability": 88, + "team": "fnatic", + "teams": [ + "fnatic", + "Astralis", + "Complexity" + ] + }, + { + "clutch": 72, + "cost": 2, + "firepower": 66, + "id": "bodyy", + "leadership": 89, + "name": "bodyy", + "nation": "法国", + "role": "指挥", + "stability": 75, + "team": "fnatic", + "teams": [ + "fnatic", + "G2" + ] + }, + { + "clutch": 72, + "cost": 2, + "firepower": 76, + "id": "matys", + "leadership": 0, + "name": "MATYS", + "nation": "斯洛伐克", + "role": "步枪手", + "stability": 72, + "team": "fnatic", + "teams": [ + "fnatic" + ] + }, + { + "clutch": 74, + "cost": 2, + "firepower": 75, + "id": "afro", + "leadership": 0, + "name": "afro", + "nation": "法国", + "role": "狙击手", + "stability": 72, + "team": "fnatic", + "teams": [ + "fnatic", + "LDLC" + ] + }, + { + "clutch": 72, + "cost": 2, + "firepower": 76, + "id": "kairon", + "leadership": 0, + "name": "KaiR0N-", + "nation": "俄罗斯", + "role": "步枪手", + "stability": 70, + "team": "BetBoom", + "teams": [ + "BetBoom", + "Virtus.pro" + ] + }, + { + "clutch": 75, + "cost": 2, + "firepower": 77, + "id": "zorte", + "leadership": 0, + "name": "zorte", + "nation": "俄罗斯", + "role": "狙击手", + "stability": 73, + "team": "BetBoom", + "teams": [ + "BetBoom", + "forZe" + ] + }, + { + "clutch": 70, + "cost": 2, + "firepower": 65, + "id": "nafany", + "leadership": 91, + "name": "nafany", + "nation": "俄罗斯", + "role": "指挥", + "stability": 74, + "team": "BetBoom", + "teams": [ + "BetBoom", + "Cloud9", + "Gambit" + ] + }, + { + "clutch": 72, + "cost": 2, + "firepower": 75, + "id": "s1ren", + "leadership": 0, + "name": "s1ren", + "nation": "俄罗斯", + "role": "步枪手", + "stability": 73, + "team": "BetBoom", + "teams": [ + "BetBoom", + "Spirit" + ] + }, + { + "clutch": 72, + "cost": 2, + "firepower": 77, + "id": "rigoN", + "leadership": 0, + "name": "rigoN", + "nation": "瑞士", + "role": "步枪手", + "stability": 70, + "team": "Bad News Eagles", + "teams": [ + "Bad News Eagles", + "Guild Eagles" + ] + }, + { + "clutch": 72, + "cost": 2, + "firepower": 76, + "id": "juanflatroo", + "leadership": 0, + "name": "juanflatroo", + "nation": "科索沃", + "role": "步枪手", + "stability": 71, + "team": "Bad News Eagles", + "teams": [ + "Bad News Eagles", + "Guild Eagles" + ] + }, + { + "clutch": 74, + "cost": 2, + "firepower": 75, + "id": "calyx", + "leadership": 0, + "name": "Calyx", + "nation": "土耳其", + "role": "步枪手", + "stability": 73, + "team": "Eternal Fire", + "teams": [ + "Eternal Fire", + "Space Soldiers" + ] + }, + { + "clutch": 66, + "cost": 1, + "firepower": 67, + "id": "issaa", + "leadership": 0, + "name": "ISSAA", + "nation": "约旦", + "role": "步枪手", + "stability": 64, + "team": "JiJieHao", + "teams": [ + "JiJieHao", + "OG" + ] + }, + { + "clutch": 70, + "cost": 2, + "firepower": 78, + "id": "boros", + "leadership": 0, + "name": "BOROS", + "nation": "约旦", + "role": "步枪手", + "stability": 68, + "team": "Into the Breach", + "teams": [ + "Into the Breach", + "Falcons", + "Monte" + ] + }, + { + "clutch": 80, + "cost": 3, + "firepower": 82, + "id": "woro2k", + "leadership": 0, + "name": "Woro2k", + "nation": "乌克兰", + "role": "狙击手", + "stability": 76, + "team": "Monte", + "teams": [ + "Monte" + ] + }, + { + "clutch": 73, + "cost": 2, + "firepower": 76, + "id": "demqq", + "leadership": 0, + "name": "DemQQ", + "nation": "乌克兰", + "role": "步枪手", + "stability": 74, + "team": "Monte", + "teams": [ + "Monte" + ] + }, + { + "clutch": 72, + "cost": 2, + "firepower": 75, + "id": "dycha", + "leadership": 0, + "name": "dycha", + "nation": "波兰", + "role": "步枪手", + "stability": 74, + "team": "ENCE", + "teams": [ + "ENCE" + ] + }, + { + "clutch": 79, + "cost": 3, + "firepower": 72, + "id": "gla1ve", + "leadership": 98, + "name": "gla1ve", + "nation": "丹麦", + "role": "指挥", + "stability": 84, + "team": "ENCE", + "teams": [ + "ENCE", + "Astralis" + ] + }, + { + "clutch": 75, + "cost": 2, + "firepower": 76, + "id": "hades", + "leadership": 0, + "name": "hades", + "nation": "波兰", + "role": "狙击手", + "stability": 73, + "team": "ENCE", + "teams": [ + "ENCE", + "9INE" + ] + }, + { + "clutch": 64, + "cost": 1, + "firepower": 67, + "id": "goofy", + "leadership": 0, + "name": "Goofy", + "nation": "波兰", + "role": "步枪手", + "stability": 66, + "team": "ENCE", + "teams": [ + "ENCE", + "9INE" + ] + }, + { + "clutch": 63, + "cost": 1, + "firepower": 66, + "id": "sausol", + "leadership": 0, + "name": "sausol", + "nation": "西班牙", + "role": "步枪手", + "stability": 65, + "team": "Movistar Riders", + "teams": [ + "Movistar Riders" + ] + }, + { + "clutch": 70, + "cost": 2, + "firepower": 64, + "id": "alex_movistar", + "leadership": 88, + "name": "alex", + "nation": "西班牙", + "role": "指挥", + "stability": 74, + "team": "Movistar Riders", + "teams": [ + "Movistar Riders", + "NiP" + ] + }, + { + "clutch": 64, + "cost": 1, + "firepower": 65, + "id": "dav1g", + "leadership": 0, + "name": "dav1g", + "nation": "西班牙", + "role": "步枪手", + "stability": 65, + "team": "KOI", + "teams": [ + "KOI", + "Movistar Riders" + ] + } + ], + "schema_version": 1 +} diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/rules.json b/packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/rules.json new file mode 100644 index 0000000..fee2ace --- /dev/null +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/data/cs_autochess/rules.json @@ -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 + } +} diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py index 0971519..6307626 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py @@ -42,6 +42,8 @@ LOCAL_COMMAND_ALIASES = { "context": "context_summary", "金币": "wallet", "wallet": "wallet", + "转账": "transfer", + "transfer": "transfer", "抽奖": "prize_draw", "draw": "prize_draw", "彩票": "lottery", @@ -138,12 +140,13 @@ LOCAL_EXTENSIONS = ( LocalExtensionDescriptor( extension_id="single-player-games", author=JWEBOT_AUTHOR, - description="虚拟金币账户、抽奖、群内多人彩票和单人21点", + description="金币游戏", help_entries=( - "/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory", - "/彩票 开始|报名|当前状态|开奖|取消;/lottery", - "/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)", - "/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", + "/金币 /wallet;/游戏记录 /gamehistory", + "/抽奖 [金额];/draw", + "/彩票 开始|报名|状态|开奖|取消;/lottery", + "/21点 开始 [1~500];/blackjack start", + "/21点 要牌|停牌|状态|结束;/blackjack", "/游戏帮助;/games", ), command_kinds=( @@ -155,6 +158,13 @@ LOCAL_EXTENSIONS = ( "game_rules", ), ), + LocalExtensionDescriptor( + extension_id="coin-transfer", + author=JWEBOT_AUTHOR, + description="金币转账", + help_entries=("/转账 对方 金额;/transfer",), + command_kinds=("transfer",), + ), LocalExtensionDescriptor( extension_id="cs-major-autochess", author=JWEBOT_AUTHOR, diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py index 4922f81..f29f63f 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py @@ -154,6 +154,7 @@ class LocalCommandEngine: return self.reply_cleaner(self.person_index(row, kind, arguments)) if kind in { "wallet", + "transfer", "prize_draw", "lottery", "blackjack", diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py index c318a02..6c6df54 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/player_games.py @@ -16,6 +16,8 @@ MAX_LOTTERY_BET = 500 DEFAULT_LOTTERY_BET = 10 MIN_BLACKJACK_BET = 1 MAX_BLACKJACK_BET = 500 +MIN_TRANSFER_AMOUNT = 1 +MAX_TRANSFER_AMOUNT = 100_000 GAME_TIMEOUT_SECONDS = 600 GAME_HISTORY_LIMIT = 50 GAME_HISTORY_PAGE_SIZE = 5 @@ -41,6 +43,11 @@ BLACKJACK_COMBINED_ACTION = re.compile( r"^(?:开始|start)(?P\d{1,3})?" r"(?:要牌|hit|停牌|stand)$" ) +TRANSFER_MARKED = re.compile( + r"^(?:给|to)?\s*(?P.+?)\s*" + r"(?:金额|金币|coin|coins|:|:)\s*(?P\d{1,6})$", + re.IGNORECASE, +) def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]: @@ -128,6 +135,8 @@ class PlayerGames: def rules() -> str: return ( "每人首次使用获 1000 虚拟金币;/金币 查看余额;/游戏记录 查看本人记录。\n" + "转账:/转账 对方 金额,或 /transfer name amount;" + "对方需先用 /金币 初始化账户。\n" "抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n" "多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];" "/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;" @@ -137,7 +146,7 @@ class PlayerGames: "(/blackjack hit|stand|status|end);" "每人最多一局,10分钟无操作视为认输,结束不退下注。\n" "21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。" - "以上均含本金;虚拟金币不能充值、转账或兑换。" + "以上均含本金;虚拟金币不能充值或兑换。" ) def handle(self, row: dict[str, Any], kind: str, arguments: str) -> str: @@ -150,6 +159,8 @@ class PlayerGames: return self.game_history(row, arguments) if kind == "lottery": return self.group_lottery(row, arguments) + if kind == "transfer": + return self.transfer(row, arguments) if kind == "wallet" and arguments: return "用法:/金币(/wallet),查看本人的虚拟金币;/游戏帮助 查看规则" if kind == "prize_draw": @@ -283,6 +294,78 @@ class PlayerGames: str(row["group_fingerprint"]), str(row["sender"]), 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 def parse_lottery_entry( arguments: str, diff --git a/packages/jwebot-sdk/pyproject.toml b/packages/jwebot-sdk/pyproject.toml index 75c7c59..f571060 100644 --- a/packages/jwebot-sdk/pyproject.toml +++ b/packages/jwebot-sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-sdk" -version = "0.6.8a0+1015" +version = "0.6.9a0+1015" description = "Typed, capability-limited extension API for JWeBot plugins" requires-python = ">=3.9" dependencies = [] diff --git a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py index ce6b086..b680006 100644 --- a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py +++ b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py @@ -38,4 +38,4 @@ __all__ = [ "SingleGroupContext", ] -__version__ = "0.6.8a0+1015" +__version__ = "0.6.9a0+1015" diff --git a/pyproject.toml b/pyproject.toml index 874d8bb..ec5fd7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.jwebot] -release = "alpha 0.6.8.1015" +release = "alpha 0.6.9.1015" production_deployments_enabled_by_default = false [tool.jwebot.workspace] diff --git a/tests/test_official_extension_inventory.py b/tests/test_official_extension_inventory.py index 5b3c9cf..dcdaae2 100644 --- a/tests/test_official_extension_inventory.py +++ b/tests/test_official_extension_inventory.py @@ -90,6 +90,7 @@ class LocalExtensionInventoryTests(unittest.TestCase): "debug-telemetry": OFFICIAL_AUTHOR, "dice": JWEBOT_AUTHOR, "coin": JWEBOT_AUTHOR, + "coin-transfer": JWEBOT_AUTHOR, "random-number": JWEBOT_AUTHOR, "draw-lot": JWEBOT_AUTHOR, "daily-fortune": JWEBOT_AUTHOR, diff --git a/tests/test_release_063.py b/tests/test_release_063.py index 4da2bb0..6f5ad15 100644 --- a/tests/test_release_063.py +++ b/tests/test_release_063.py @@ -191,6 +191,34 @@ class Release063GameTests(unittest.TestCase): self.assertIn("alice #2:抽奖 未中奖", history) self.assertIn("alice #1:抽奖 未中奖", history) + def test_transfer_requires_initialized_target_and_replays_once(self): + self.assertIn("金币 1000", self.command("/金币")) + 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): root = Path(self.temp.name) self.command("/彩票开始") diff --git a/tests/test_release_067.py b/tests/test_release_067.py index 35ded89..fc7c7fc 100644 --- a/tests/test_release_067.py +++ b/tests/test_release_067.py @@ -6,6 +6,7 @@ import json import re import tempfile import unittest +from importlib import resources from pathlib import Path 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["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__": unittest.main()