From 3dea42b268efbdb5180f3b452b7d445ffc6a02bf Mon Sep 17 00:00:00 2001 From: JWeBot Contributors Date: Thu, 15 Oct 2026 18:08:55 +0800 Subject: [PATCH] Release alpha 0.6.7 autochess pacing --- CHANGELOG.md | 19 + README.md | 15 +- VERSION | 2 +- docs/cs-major-autochess-design.md | 63 +- .../jwebot/cs-major-autochess/README.md | 4 +- .../jwebot/cs-major-autochess/plugin.toml | 4 +- .../src/cs_major_autochess.py | 4 +- .../cs-major-autochess/tests/test_plugin.py | 4 +- packages/jwebot-config/pyproject.toml | 2 +- .../src/jwebot_config/__init__.py | 2 +- packages/jwebot-extension-host/pyproject.toml | 6 +- .../src/jwebot_extension_host/__init__.py | 2 +- packages/jwebot-kernel/pyproject.toml | 8 +- .../src/jwebot_kernel/__init__.py | 2 +- .../jwebot-official-extensions/pyproject.toml | 4 +- .../jwebot_official_extensions/__init__.py | 2 +- .../adapters/cs_major_autochess.py | 4 +- .../cs_autochess.py | 754 ++++++++++++++---- .../jwebot_official_extensions/descriptors.py | 6 +- packages/jwebot-sdk/pyproject.toml | 2 +- .../jwebot-sdk/src/jwebot_sdk/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_release_064.py | 32 +- tests/test_release_067.py | 138 ++++ 24 files changed, 865 insertions(+), 218 deletions(-) create mode 100644 tests/test_release_067.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fc9b3b7..69d1340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## alpha 0.6.7.1015 + +- Expand CS Major autochess content to 100+ player cards and an 18-coach + metadata pool while keeping the opening coach draft to 10 visible choices. +- Replace abstract training targets with real round-robin training rounds. + Each round resolves participant-vs-participant or participant-vs-AI BO1 + scrims, then opens an operation window for D rolling, locking, lineup swaps, + tactics, and readiness. +- Replace fixed score bands with deterministic MR15 round simulation. Map + scores now come from individual round outcomes, including 15-15 overtime, + instead of direct mappings such as 16-6 or 16-9. +- Add a 120+ line local narrative library for openings, streaks, pauses, + clutches, economy beats, closes, training hooks, and media/sponsor + challenges so match reports vary across maps. +- Hide raw target/performance calculation text from training, match, and + challenge reports while preserving structured battle facts in the archive. +- Start drafted players as two-star素材 and rebalance roster completion toward + low/mid-cost cards, leaving upgrades and synergies to the shop loop. + ## alpha 0.6.6.1015 - Rebuild CS Major autochess around multi-message hosting: tournament diff --git a/README.md b/README.md index eef37a6..132e621 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 并通过带视觉确认的 GUI 发送链路回复。 -当前版本:`alpha 0.6.6.1015`。 +当前版本:`alpha 0.6.7.1015`。 0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加 两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见 @@ -26,10 +26,13 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 0.6.5 将 AI 测试模式扩展到双人:两名真人可一键补入 AI 战队 A、B, 两支 AI 独立运营。 0.6.6 重构自走棋流程:开赛后三轮选秀,第一轮从固定 10 人教练池中 -选教练,第二、三轮选选手;随后进入三场训练赛、BO3 单循环小组赛、 -一次媒体日或赞助挑战、BO5 半决赛和总决赛。主持消息会拆分为多条 -回复;所有需要推进的阶段由相关真人发送 `/结束回合` 自动进入下一步, -已淘汰玩家不会再收到赛前准备提示。 +选教练,第二、三轮选选手;随后进入训练赛、小组赛、挑战、淘汰赛和 +总决赛。主持消息会拆分为多条回复;所有需要推进的阶段由相关真人发送 +`/结束回合` 自动进入下一步,已淘汰玩家不会再收到赛前准备提示。 +0.6.7 继续强化文字自走棋体验:卡池扩充到 100+ 人、教练库扩充到 +18 人;训练赛改为按参赛人数生成的真实对阵轮次;单图比分由 MR15 +逐回合模拟产生,不再使用固定比分档;赛事叙事素材扩充到 120+ 条, +D 牌、锁店、调阵容和战术窗口会在轮次之间自然提示。 ### 运行流程 @@ -272,7 +275,7 @@ visually confirmed GUI bridge. Current version: ```text -alpha 0.6.6.1015 +alpha 0.6.7.1015 ``` ## Runtime Flow diff --git a/VERSION b/VERSION index f3625ef..a76aa17 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -alpha 0.6.6.1015 +alpha 0.6.7.1015 diff --git a/docs/cs-major-autochess-design.md b/docs/cs-major-autochess-design.md index 6ed4fe2..5df60d0 100644 --- a/docs/cs-major-autochess-design.md +++ b/docs/cs-major-autochess-design.md @@ -8,7 +8,7 @@ 1. 做成一个多人报名、多人运营、多人对战的群聊自走棋。 2. 玩法重心放在每轮选秀和阵容运营,而不是复杂微操。 -3. 用 Major 赛事包装 PvE、小组单循环、淘汰赛和终局剧情。 +3. 用 Major 赛事包装训练赛、小组单循环、淘汰赛和终局剧情。 4. 结算逻辑必须完全本地、可重放、可持久化、可回档验证。 5. 每一段剧情都要落档,且剧情只能在数值结算之后生成。 @@ -16,7 +16,7 @@ 一句话定义: -`三轮赛前选秀 -> 训练赛拿资源 -> D 牌与布阵 -> BO3 小组单循环 -> BO5 淘汰赛 -> 保存每轮赛报与剧情` +`三轮赛前选秀 -> 轮次训练赛拿资源 -> D 牌与布阵 -> BO3 小组单循环 -> BO5 淘汰赛 -> 保存每轮赛报与剧情` 玩家感受到的乐趣应当主要来自: @@ -79,7 +79,8 @@ - Eternal Fire 选手数据不是对真实比赛数据的一比一复刻,而是基于公众认知做游戏化映射。 -首发版本固定维护 `60` 名棋子,运行期不在线抓取或改写数值。 +0.6.7 起本地维护 `100+` 名棋子和 `18` 名教练元数据;运行期不在线抓取 +或改写数值。首轮教练选秀仍固定展示 10 个候选,保证聊天窗口可读。 ### 4.2 费用层级 @@ -280,41 +281,44 @@ 1. `回合开始播报` 2. `6 人选秀 / 刷新 / 升级 / 调阵` -3. `PvE 或 PvP 结算` +3. `训练赛 / 赛事挑战 / PvP 结算` 4. `回合结算与经济发放` 5. `剧情归档` -## 10. PvE 设计 +## 10. 训练赛与赛事挑战 -PvE 不叫“打野怪”,统一包装成赛事内活动。 +训练赛不再是阈值型 PvE。它是正式小组赛前的多轮真实对阵,用来给玩家 +检验阵容、获取金币并自然插入 D 牌窗口。媒体日和赞助挑战仍作为赛事内 +插曲出现,但只提供资源变动,不淘汰玩家。 -### 10.1 PvE 类型 +### 10.1 活动类型 - 训练赛 - 媒体日 -- 热身赛 - 赞助挑战 -- 地图专项训练 +- 本地陪练队热身 -### 10.2 PvE 作用 +### 10.2 训练赛作用 -PvE 的目标不是淘汰人,而是给资源: +训练赛按参赛队伍生成 round-robin 轮次。四队测试局会有三轮训练赛, +每队在训练阶段遇到另外三支队伍;奇数队伍时轮空队会与本地陪练队热身。 +训练赛只发放金币,不记录小组胜负,也不扣生命。 -- 金币 -- 装备 -- 战术卡 -- 教练经验 -- 临时 buff +每轮训练赛结束后进入运营窗口: -### 10.3 PvE 结算逻辑 +- `/刷新` 即 D 牌,消耗金币换一组 6 人商店; +- `/选1~/选6` 买入商店选手,重复卡自动累积星级; +- `/锁定` 保留当前商店; +- `/上阵` 调整 5 人首发; +- `/战术 火力|稳定|残局` 改变下一场的回合倾向。 -PvE 对手是模板化敌方阵容或阈值型事件,不需要复杂 AI。 +### 10.3 训练赛结算逻辑 -示例: +训练赛使用与正式比赛相同的单图 MR15 逐回合模拟,只是赛制为 BO1。 +胜方获得更多金币,负方也有保底金币,保证测试局能继续运营。 -`玩家总战力 >= 目标战力` 则获胜,否则失败。 - -这样实现简单,也利于稳定复盘。 +媒体日和赞助挑战根据阵容完整度、教练、战术和本地确定性随机因子发放 +金币,但播报不展示“目标/发挥”这类计算日志,只写剧情和奖励。 ## 11. PvP 赛制 @@ -430,7 +434,7 @@ PvE 对手是模板化敌方阵容或阈值型事件,不需要复杂 AI。 - 开局报名完成 - 每轮 6 人选秀结果 - 每次刷新和关键升星 -- 每次 PvE 事件结果 +- 每轮训练赛和赛事挑战结果 - 每场 PvP 结果 - 小组赛排名、晋级和淘汰节点 - 淘汰赛对阵与结果 @@ -597,8 +601,9 @@ PvE 对手是模板化敌方阵容或阈值型事件,不需要复杂 AI。 Jacky 选择了 zonic! 现在轮到 @Alice 做出选择,使用 /选1~/选10。 -训练赛阶段结束。可以 /刷新、/选1~/选6、/上阵、/战术; -所有真人发送 /结束回合 后进入小组赛。 +训练赛第 1/3 轮对阵:Team Jacky vs Team Alayak;AI 战队 A vs AI 战队 B +本轮训练结束。现在是运营窗口:/刷新 是 D 牌,/选1~/选6 是买店内选手; +所有真人发送 /结束回合 后进入下一轮训练。 ``` 以下节点都必须由 Taffy 主持并落档: @@ -607,7 +612,7 @@ Jacky 选择了 zonic! - AI 测试战队加入 - 赛制与种子公布 - 三轮选秀候选池、选择结果和选秀完成 -- 三场训练赛结果 +- 每轮训练赛对阵、比分、金币和运营提示 - 媒体日或赞助挑战 - 每场 BO3/BO5 对阵与结果 - 每张加时地图 @@ -625,7 +630,7 @@ Jacky 选择了 zonic! - 每轮 6 人选秀 - 买卡、卖卡、升星 - 教练等级 -- 简化 PvE +- 简化训练赛 - 简化 PvP - 基础剧情归档 @@ -638,7 +643,7 @@ Jacky 选择了 zonic! ### 第三阶段:内容扩展 -- 在固定 60 人首发池基础上增加新赛季棋子 +- 在 100+ 人本地卡池基础上增加新赛季棋子 - 更多俱乐部和国家 - 特殊赛事事件 - 更细的剧情风格与本届 Major 年鉴 @@ -660,7 +665,7 @@ Jacky 选择了 zonic! 最重要的三个锚点已经确定: 1. `每轮固定 6 人公开选秀` -2. `PvE 负责资源,PvP 负责小组单循环与淘汰赛` +2. `训练赛与挑战负责资源,PvP 负责小组单循环与淘汰赛` 3. `每一段剧情都必须落档` 如果按这个设计推进,实现出来的成品会更像一场可回放、可复盘、 diff --git a/extensions/jwebot/cs-major-autochess/README.md b/extensions/jwebot/cs-major-autochess/README.md index 10c9384..93b5cdb 100644 --- a/extensions/jwebot/cs-major-autochess/README.md +++ b/extensions/jwebot/cs-major-autochess/README.md @@ -2,11 +2,11 @@ Author: `jwebot` -CS自走棋 +自走棋 Help: -- `/自走棋开始|报名` +- `/自走棋 开始|报名|选` Registered commands: diff --git a/extensions/jwebot/cs-major-autochess/plugin.toml b/extensions/jwebot/cs-major-autochess/plugin.toml index 7ff0e19..29431ae 100644 --- a/extensions/jwebot/cs-major-autochess/plugin.toml +++ b/extensions/jwebot/cs-major-autochess/plugin.toml @@ -1,7 +1,7 @@ id = "cs-major-autochess" author = "jwebot" -description = "CS自走棋" -help = ["/自走棋开始|报名"] +description = "自走棋" +help = ["/自走棋 开始|报名|选"] version = "1.0.0" api_version = "1" entrypoint = "cs_major_autochess:plugin" diff --git a/extensions/jwebot/cs-major-autochess/src/cs_major_autochess.py b/extensions/jwebot/cs-major-autochess/src/cs_major_autochess.py index 66cef99..b50af17 100644 --- a/extensions/jwebot/cs-major-autochess/src/cs_major_autochess.py +++ b/extensions/jwebot/cs-major-autochess/src/cs_major_autochess.py @@ -5,8 +5,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext PROFILE = 'cs-major-autochess' AUTHOR = 'jwebot' -DESCRIPTION = 'CS自走棋' -HELP_ENTRIES = ('/自走棋开始|报名',) +DESCRIPTION = '自走棋' +HELP_ENTRIES = ('/自走棋 开始|报名|选',) COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合') diff --git a/extensions/jwebot/cs-major-autochess/tests/test_plugin.py b/extensions/jwebot/cs-major-autochess/tests/test_plugin.py index aec2120..9d3bcd9 100644 --- a/extensions/jwebot/cs-major-autochess/tests/test_plugin.py +++ b/extensions/jwebot/cs-major-autochess/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, 'CS自走棋') - self.assertEqual(HELP_ENTRIES, ('/自走棋开始|报名',)) + self.assertEqual(DESCRIPTION, '自走棋') + self.assertEqual(HELP_ENTRIES, ('/自走棋 开始|报名|选',)) self.assertIn('/自走棋', registered) self.assertEqual(len(registered), 14) diff --git a/packages/jwebot-config/pyproject.toml b/packages/jwebot-config/pyproject.toml index 790c2a1..a36b613 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.6a0+1015" +version = "0.6.7a0+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 e899f8e..5d502ef 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.6a0+1015" +__version__ = "0.6.7a0+1015" diff --git a/packages/jwebot-extension-host/pyproject.toml b/packages/jwebot-extension-host/pyproject.toml index 6d9e7c1..eb8ddc3 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.6a0+1015" +version = "0.6.7a0+1015" description = "Single-release, single-invocation process host for JWeBot extensions" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.6a0+1015", - "jwebot-kernel==0.6.6a0+1015", + "jwebot-sdk==0.6.7a0+1015", + "jwebot-kernel==0.6.7a0+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 afa4c4d..6f9cd85 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.6a0+1015" +__version__ = "0.6.7a0+1015" diff --git a/packages/jwebot-kernel/pyproject.toml b/packages/jwebot-kernel/pyproject.toml index 998e039..348de3e 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.6a0+1015" +version = "0.6.7a0+1015" description = "Stable routing and extension authority for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-config==0.6.6a0+1015", - "jwebot-sdk==0.6.6a0+1015", - "jwebot-official-extensions==0.6.6a0+1015", + "jwebot-config==0.6.7a0+1015", + "jwebot-sdk==0.6.7a0+1015", + "jwebot-official-extensions==0.6.7a0+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 ba077a1..6d20ca5 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.6a0+1015" +__version__ = "0.6.7a0+1015" diff --git a/packages/jwebot-official-extensions/pyproject.toml b/packages/jwebot-official-extensions/pyproject.toml index 1ebf785..6b3edfa 100644 --- a/packages/jwebot-official-extensions/pyproject.toml +++ b/packages/jwebot-official-extensions/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-official-extensions" -version = "0.6.6a0+1015" +version = "0.6.7a0+1015" description = "Reviewed local command adapters and broker implementation for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.6a0+1015", + "jwebot-sdk==0.6.7a0+1015", ] [tool.setuptools.packages.find] 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 e8f8cf2..a6252aa 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.6a0+1015" +__version__ = "0.6.7a0+1015" diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/cs_major_autochess.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/cs_major_autochess.py index 66cef99..b50af17 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/cs_major_autochess.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/cs_major_autochess.py @@ -5,8 +5,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext PROFILE = 'cs-major-autochess' AUTHOR = 'jwebot' -DESCRIPTION = 'CS自走棋' -HELP_ENTRIES = ('/自走棋开始|报名',) +DESCRIPTION = '自走棋' +HELP_ENTRIES = ('/自走棋 开始|报名|选',) COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合') 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 9eb019e..4cdcfe1 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 @@ -145,6 +145,62 @@ PLAYER_POOL = ( _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), ) CARD_BY_ID = {card["id"]: card for card in PLAYER_POOL} @@ -182,16 +238,180 @@ COACH_POOL = ( _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_BY_ID = {coach["id"]: coach for coach in COACH_POOL} -TRAINING_OPPONENTS = ( - ("Team Falcons", 292), - ("Natus Vincere", 306), - ("Team Vitality", 316), - ("FaZe Clan", 300), - ("G2 Esports", 286), - ("The MongolZ", 278), -) +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": ( + "媒体日问题很尖锐,队伍需要在镜头前保持状态。", + "赞助商临时加码,要求队伍用指定战术打出观赏性。", + "主办方安排了签名会,状态管理成了额外考验。", + "训练馆网络短暂波动,队伍必须快速适应备用机器。", + "赛前采访拖长,指挥需要把注意力重新拉回比赛。", + "赞助商想看激进打法,但稳住节奏的人会拿到更高回报。", + "媒体日不是战斗回合,却会影响队伍的运营资源。", + "队伍如果阵容完整,就更容易把这次插曲处理成奖金。", + "这次挑战只出现一次,处理完就回到小组赛主线。", + "主舞台彩排占用了热身时间,教练的管理能力开始生效。", + "赞助商要求展示新战术,选错方向也不会淘汰,只会少拿金币。", + "现场采访提到了前一场失利,队员心态需要稳住。", + "这类挑战不扣生命,目的只是给运营阶段增加变数。", + "处理好这次插曲的队伍,会在下一场前多一点经济余地。", + ), +} def _hash_int(seed: str) -> int: @@ -201,6 +421,18 @@ def _hash_int(seed: str) -> int: ) +def _pick_fragment(category: str, seed: str, **values: Any) -> str: + fragments = tuple(NARRATIVE_FRAGMENTS.get(category, ())) + if not fragments: + return "" + template = fragments[_hash_int(f"{category}|{seed}") % len(fragments)] + return template.format(**values) + + +def _narrative_inventory_size() -> int: + return sum(len(items) for items in NARRATIVE_FRAGMENTS.values()) + + def _pack_response_parts(parts: list[str]) -> str: cleaned = [part.strip() for part in parts if part.strip()] if len(cleaned) <= 1: @@ -918,21 +1150,152 @@ def _auto_manage_ai( return stories -MAP_POOL = ("Mirage", "Inferno", "Nuke", "Ancient", "Dust2") +MAP_POOL = ("Mirage", "Inferno", "Nuke", "Ancient", "Dust2", "Anubis", "Overpass") -def _map_score(margin: float, seed: str) -> tuple[str, str, int]: - if margin >= 0.18: - return "16-6", "碾压", 0 - if margin >= 0.11: - return "16-9", "明显优势", 0 - if margin >= 0.055: - return "16-12", "小胜", 0 - if margin >= 0.025: - return "16-14", "险胜", 0 - overtime_count = 1 + _hash_int(seed + "|overtime") % 2 - winner_score = 16 + overtime_count * 3 - return f"{winner_score}-{winner_score - 2}", "加时险胜", overtime_count +def _round_probability( + adjusted_a: float, + adjusted_b: float, + seed: str, + round_index: int, + score_a: int, + 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: + 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)) + + +def _simulate_map( + state: dict[str, Any], + key_a: str, + key_b: str, + seed: str, + map_index: int, + adjusted_a: float, + adjusted_b: float, +) -> dict[str, Any]: + score_a = 0 + score_b = 0 + rounds: list[str] = [] + current_key = "" + current_run = 0 + best_run_key = "" + best_run = 0 + + def record_round(round_index: int) -> None: + nonlocal score_a, score_b, current_key, current_run + nonlocal best_run_key, best_run + probability_a = _round_probability( + adjusted_a, + adjusted_b, + seed, + round_index, + score_a, + score_b, + ) + roll = (_hash_int(f"{seed}|round|{round_index}") % 10_000) / 10_000 + winner_key = key_a if roll < probability_a else key_b + if winner_key == key_a: + score_a += 1 + else: + score_b += 1 + rounds.append(winner_key) + if winner_key == current_key: + current_run += 1 + else: + current_key = winner_key + current_run = 1 + if current_run > best_run: + best_run = current_run + best_run_key = winner_key + + for round_index in range(1, 31): + record_round(round_index) + if score_a >= 16 or score_b >= 16: + 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: + round_index += 1 + record_round(round_index) + if round_index > 72: + 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 + if overtime_count: + band = "加时险胜" + elif loser_score <= 6: + band = "压制" + elif loser_score <= 10: + band = "优势" + elif loser_score <= 13: + band = "小胜" + else: + band = "险胜" + 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]) + best_run_team = ( + _team_name(state["players"][best_run_key]) if best_run_key else winner_team + ) + opening_team = _team_name( + state["players"][key_a if _hash_int(seed + "|open") % 2 == 0 else key_b] + ) + actor = _key_player_name(state, winner_key, map_index) + pause = _pick_fragment("pauses", f"{seed}|pause") + run_text = ( + _pick_fragment( + "runs", + f"{seed}|run", + team=best_run_team, + count=best_run, + ) + if best_run >= 4 + else pause + ) + overtime_story = "" + if overtime_count: + overtime_story = ( + f"{map_name} 常规时间战成 15-15。{winner_team} 在加时第 " + f"{overtime_count} 段抓住 {loser_team} 的转点空隙,以 " + f"{winner_score}-{loser_score} 收下地图。" + ) + return { + "map_index": map_index, + "map_name": map_name, + "winner_key": winner_key, + "winner": state["players"][winner_key]["display_name"], + "loser_key": loser_key, + "loser": state["players"][loser_key]["display_name"], + "score": f"{winner_score}-{loser_score}", + "score_a": score_a, + "score_b": score_b, + "band": band, + "overtime_count": overtime_count, + "overtime_story": overtime_story, + "rounds_played": len(rounds), + "best_run_key": best_run_key, + "best_run": best_run, + "opening_text": _pick_fragment("openings", f"{seed}|opening", team=opening_team), + "run_text": run_text, + "pause_text": pause, + "clutch_text": _pick_fragment("clutches", f"{seed}|clutch", player=actor), + "economy_text": _pick_fragment("economy", f"{seed}|economy", team=loser_team), + "closer_text": _pick_fragment("closers", f"{seed}|closer", team=winner_team), + "adjusted_a": round(adjusted_a, 2), + "adjusted_b": round(adjusted_b, 2), + } def _battle( @@ -969,51 +1332,23 @@ def _battle( * (10_000 + fluct_b) / 10_000 ) - if adjusted_a == adjusted_b: - adjusted_a += ( - 0.01 if _hash_int(f"{seed}|map|{map_index}|tie") % 2 == 0 else -0.01 - ) - winner_key = key_a if adjusted_a > adjusted_b else key_b + item = _simulate_map( + state, + key_a, + key_b, + f"{seed}|map|{map_index}", + map_index, + adjusted_a, + adjusted_b, + ) + winner_key = str(item["winner_key"]) if winner_key == key_a: wins_a += 1 else: wins_b += 1 - high = max(adjusted_a, adjusted_b) - low = min(adjusted_a, adjusted_b) - margin = (high - low) / max(high, 1) - score, band, overtime_count = _map_score( - margin, - f"{seed}|map|{map_index}", - ) - winner_name = state["players"][winner_key]["display_name"] - loser_key = key_b if winner_key == key_a else key_a - loser_name = state["players"][loser_key]["display_name"] - map_name = MAP_POOL[map_index - 1] - overtime_story = "" - if overtime_count: - overtime_story = ( - f"{map_name} 常规时间战成 15-15。" - f"{winner_name} 在第 {overtime_count} 轮加时抓住" - f"{loser_name} 的转点空隙,以 {score} 收下地图。" - ) - maps.append( - { - "map_index": map_index, - "map_name": map_name, - "winner_key": winner_key, - "winner": winner_name, - "loser_key": loser_key, - "loser": loser_name, - "score": score, - "band": band, - "overtime_count": overtime_count, - "overtime_story": overtime_story, - "adjusted_a": round(adjusted_a, 2), - "adjusted_b": round(adjusted_b, 2), - "fluctuation_a_bps": fluct_a, - "fluctuation_b_bps": fluct_b, - } - ) + item["fluctuation_a_bps"] = fluct_a + item["fluctuation_b_bps"] = fluct_b + maps.append(item) if wins_a == target_maps or wins_b == target_maps: break winner_key, loser_key = ( @@ -1113,11 +1448,15 @@ def _battle_story(result: dict[str, Any]) -> str: for item in result["maps"] ) matchup = result["matchup"] + tactic_note = ( + "战术克制明显" + if abs(float(matchup["a_total_percent"])) >= 5.5 + else "战术拉扯接近" + ) return _host_text( f"{result['winner']} 在 {result['series_format']} 以 {result['score']} " - f"击败 {result['loser']}({map_scores});" - f"战术 {matchup['tactic_a']} 对 {matchup['tactic_b']}," - f"{result['player_a']} 克制修正 {matchup['a_total_percent']:+.2f}%。" + f"击败 {result['loser']}({map_scores})。" + f"战术面是 {matchup['tactic_a']} 对 {matchup['tactic_b']},{tactic_note}。" ) @@ -1684,7 +2023,7 @@ def _draft_candidate_cards(state: dict[str, Any], round_number: int) -> list[str def _start_draft_round(state: dict[str, Any], round_number: int) -> None: draft_type = "coach" if round_number == 1 else "player" candidates = ( - [coach["id"] for coach in COACH_POOL] + [coach["id"] for coach in COACH_POOL[:10]] if draft_type == "coach" else _draft_candidate_cards(state, round_number) ) @@ -1740,8 +2079,13 @@ def _draft_prompt_part(state: dict[str, Any]) -> str: return f"现在轮到 @{name} 做出选择,使用 {command}。" -def _add_unit_to_roster(player: dict[str, Any], card_id: str) -> None: - unit = _new_unit(card_id) +def _add_unit_to_roster( + player: dict[str, Any], + card_id: str, + *, + copies: int = 1, +) -> None: + unit = _new_unit(card_id, copies=copies) if len(player["board"]) < STARTER_COUNT: player["board"].append(unit) else: @@ -1771,13 +2115,15 @@ def _apply_draft_choice( ) else: card = CARD_BY_ID[choice_id] - if int(state["pool"].get(choice_id, 0)) <= 0: + draft_copies = 3 + if int(state["pool"].get(choice_id, 0)) < draft_copies: raise ValueError("这名选手已经被选走") - state["pool"][choice_id] -= 1 - _add_unit_to_roster(player, choice_id) + state["pool"][choice_id] -= draft_copies + _add_unit_to_roster(player, choice_id, copies=draft_copies) text = ( f"{player['display_name']} 选择了 {card['name']}!" - f"{card['cost']}费 {card['role']},{card['team']}/{card['nation']}。" + f"{card['cost']}费 {card['role']},{card['team']}/{card['nation']}," + "以 2 星素材加入阵容。" ) candidates.pop(slot - 1) draft["candidates"] = candidates @@ -1876,7 +2222,10 @@ def _ensure_full_rosters(state: dict[str, Any]) -> None: ] candidates.sort( key=lambda card_id: ( - CARD_BY_ID[card_id]["cost"] if player.get("is_ai") else -CARD_BY_ID[card_id]["cost"], + CARD_BY_ID[card_id]["cost"] + if player.get("is_ai") + else abs(int(CARD_BY_ID[card_id]["cost"]) - 2), + int(CARD_BY_ID[card_id]["cost"]), _hash_int(f"{state['run_id']}|fill|{key}|{card_id}"), ) ) @@ -1954,62 +2303,172 @@ def _shop_status_text(state: dict[str, Any], player: dict[str, Any]) -> str: ) + "\n操作:/选1~/选6;/刷新;/锁定;/阵容;/战术 火力|稳定|残局;/结束回合" -def _training_parts(state: dict[str, Any], event_id: str) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]: - parts = [ - f"欢迎来到{state['event_title']}!你们已经到达酒店宾馆,即将开始训练赛任务。每队会连续完成三场训练赛,结束后可以 D 牌、调阵容,再用 /结束回合 进入小组赛。" +def _training_schedule(state: dict[str, Any]) -> list[dict[str, Any]]: + rounds: list[dict[str, Any]] = [] + keys = list(state["seed_order"]) + for round_index, pairs in enumerate(_round_robin_rounds(keys), start=1): + paired = {key for pair in pairs for key in pair} + bye = next((key for key in keys if key not in paired), "") + rounds.append( + { + "round": round_index, + "pairs": [[str(pair[0]), str(pair[1])] for pair in pairs], + "bye": bye, + } + ) + return rounds + + +def _ensure_training_schedule(state: dict[str, Any]) -> None: + if state.get("training_rounds"): + return + state["training_rounds"] = _training_schedule(state) + state["training_round_cursor"] = 0 + + +def _training_pairing_line(state: dict[str, Any], round_item: dict[str, Any]) -> str: + rendered = [ + f"{_team_name(state['players'][pair[0]])} vs {_team_name(state['players'][pair[1]])}" + for pair in round_item.get("pairs", []) ] + bye = str(round_item.get("bye") or "") + if bye: + rendered.append(f"{_team_name(state['players'][bye])} vs 本地陪练队") + return ";".join(rendered) + + +def _training_match_part( + state: dict[str, Any], + result: dict[str, Any], + round_number: int, +) -> str: + item = result["maps"][0] + winner_key = str(result["winner_key"]) + loser_key = str(result["loser_key"]) + winner = state["players"][winner_key] + loser = state["players"][loser_key] + return "\n".join( + [ + ( + f"训练赛第 {round_number} 轮:{_team_name(winner)} " + f"vs {_team_name(loser)}" + ), + ( + f"{item['map_name']}:{item['opening_text']}" + f"{item['run_text']}" + ), + ( + f"{item['clutch_text']}{item['closer_text']}" + f"{_team_name(winner)} 以 {item['score']} 拿下本图。" + ), + ( + f"金币:{_team_name(winner)} +{TRAINING_WIN_GOLD}," + f"{_team_name(loser)} +{TRAINING_LOSS_GOLD}。" + ), + ] + ) + + +def _training_bye_part(state: dict[str, Any], key: str, round_number: int) -> str: + player = state["players"][key] + player["gold"] = int(player["gold"]) + TRAINING_BYE_GOLD + hook = _pick_fragment( + "training_hooks", + f"{state['run_id']}|training|bye|{round_number}|{key}", + ) + return ( + f"训练赛第 {round_number} 轮:{_team_name(player)} 与本地陪练队完成热身。\n" + f"{hook}\n金币:{_team_name(player)} +{TRAINING_BYE_GOLD}。" + ) + + +def _settle_training_round( + state: dict[str, Any], + event_id: str, +) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]: + _ensure_training_schedule(state) + rounds = list(state.get("training_rounds", [])) + cursor = int(state.get("training_round_cursor", 0)) + if cursor >= len(rounds): + state["phase"] = "training_complete_manage" + _prepare_new_shops(state, "training-complete") + _reset_ready(state, _human_keys(state)) + return [ + "训练赛已经全部结束。可以最后 D 牌、调阵容;所有真人发送 /结束回合 后进入小组赛。" + ], [], [] + + round_item = rounds[cursor] + round_number = int(round_item["round"]) + state["round_number"] = round_number + parts: list[str] = [] stories: list[dict[str, Any]] = [] battles: list[dict[str, Any]] = [] - for key in state["join_order"]: - player = state["players"][key] - power = lineup_power(player)["power"] - lines = [f"{_team_name(player)} 训练赛:"] - total_reward = 0 - for index in range(3): - opponent, target = TRAINING_OPPONENTS[ - (_hash_int(f"{state['run_id']}|train|{key}|{index}") + index) - % len(TRAINING_OPPONENTS) - ] - fluctuation = _hash_int( - f"{state['run_id']}|{event_id}|train|{key}|{index}" - ) % 401 - 200 - adjusted = power * (10_000 + fluctuation) / 10_000 - won = adjusted >= target - reward = 3 if won else 1 - total_reward += reward - score = "16-12" if won else "13-16" - if abs(adjusted - target) < 12: - score = "19-17" if won else "17-19" - lines.append( - f"{index + 1}. vs {opponent}(目标 {target},发挥 {adjusted:.1f}):" - f"{'胜' if won else '负'} {score},获得 {reward} 金币" - ) - result = { - "opponent": opponent, - "target_power": target, - "player_power": round(adjusted, 1), - "score": score, - "won": won, - "reward": reward, - } - battles.append( - _battle_record( - state, - battle_type="training", - player_a=key, - player_b="training", - result=result, - ) - ) - player["gold"] = int(player["gold"]) + total_reward - lines.append(f"训练赛合计收入 {total_reward} 金币;当前金币 {player['gold']}。") - text = "\n".join(lines) + if cursor == 0: + intro = ( + f"欢迎来到{state['event_title']}!你们已经到达酒店宾馆," + f"训练赛共 {len(rounds)} 轮。训练赛不扣生命,只产出运营金币。" + ) + parts.append(intro) + stories.append(_story(state, "training_open", intro)) + hook = _pick_fragment( + "training_hooks", + f"{state['run_id']}|training|hook|{round_number}", + ) + parts.append( + f"训练赛第 {round_number}/{len(rounds)} 轮对阵:" + f"{_training_pairing_line(state, round_item)}\n{hook}" + ) + for match_index, pair in enumerate(round_item.get("pairs", []), start=1): + key_a, key_b = str(pair[0]), str(pair[1]) + result = _battle( + state, + key_a, + key_b, + f"{state['run_id']}|training|{round_number}|{match_index}|{event_id}", + best_of=1, + ) + winner_key = str(result["winner_key"]) + loser_key = str(result["loser_key"]) + state["players"][winner_key]["gold"] = ( + int(state["players"][winner_key]["gold"]) + TRAINING_WIN_GOLD + ) + state["players"][loser_key]["gold"] = ( + int(state["players"][loser_key]["gold"]) + TRAINING_LOSS_GOLD + ) + result["training_rewards"] = { + winner_key: TRAINING_WIN_GOLD, + loser_key: TRAINING_LOSS_GOLD, + } + battle = _battle_record( + state, + battle_type="training_bo1", + player_a=key_a, + player_b=key_b, + result=result, + ) + text = _training_match_part(state, result, round_number) parts.append(text) - stories.append(_story(state, "training_result", text, {"person_key": key})) - state["phase"] = "training_manage" - _prepare_new_shops(state, "training") + stories.append(_story(state, "training_result", text, result)) + battles.append(battle) + bye = str(round_item.get("bye") or "") + if bye: + text = _training_bye_part(state, bye, round_number) + parts.append(text) + stories.append(_story(state, "training_bye", text, {"person_key": bye})) + state["training_round_cursor"] = cursor + 1 + _prepare_new_shops(state, f"training-{round_number}") _reset_ready(state, _human_keys(state)) - parts.append("训练赛阶段结束。可以 /刷新、/选1~/选6、/上阵、/战术;所有真人发送 /结束回合 后进入小组赛。") + if cursor + 1 < len(rounds): + state["phase"] = "training_manage" + parts.append( + "本轮训练结束。现在是运营窗口:/刷新 是 D 牌,/选1~/选6 是买店内选手," + "/锁定 可保留商店,/上阵 可调整首发;所有真人发送 /结束回合 后进入下一轮训练。" + ) + else: + state["phase"] = "training_complete_manage" + parts.append( + "训练赛全部结束。可以最后 D 牌、调阵容;所有真人发送 /结束回合 后进入小组赛。" + ) return parts, stories, battles @@ -2120,38 +2579,42 @@ def _series_report_parts( result: dict[str, Any], ) -> list[str]: key_a, key_b = match["pair"] - team_a = _team_name(state["players"][key_a]) - team_b = _team_name(state["players"][key_b]) + matchup = result["matchup"] parts = [ - f"{_match_title(state, match)} 开始。{team_a} 战力 {result['power_a']:.1f},{team_b} 战力 {result['power_b']:.1f}。" + ( + f"{_match_title(state, match)} 开始。" + f"战术面是 {matchup['tactic_a']} 对 {matchup['tactic_b']}," + "本场按逐回合模拟推进。" + ) ] wins: dict[str, int] = {key_a: 0, key_b: 0} for index, item in enumerate(result["maps"], start=1): winner_key = str(item["winner_key"]) loser_key = str(item["loser_key"]) wins[winner_key] += 1 - actor = _key_player_name(state, winner_key, index) - closer = _key_player_name(state, loser_key, index + 2) lead_text = ( f"{_team_name(state['players'][winner_key])} 在系列赛中 " f"{wins[winner_key]}-{wins[loser_key]} {'领先' if wins[winner_key] > wins[loser_key] else '追平'}" ) - overtime = "常规时间 15-15 后进入加时," if int(item["overtime_count"]) else "" parts.append( - f"图{index} {item['map_name']}:{overtime}{actor} 在关键回合打开局面," - f"{closer} 的残局反扑没能改写结果。" + f"图{index} {item['map_name']}:{item['opening_text']}" + f"{item['run_text']}{item['clutch_text']}" + f"{item['economy_text']}{item['closer_text']}" f"{_team_name(state['players'][winner_key])} 以 {item['score']} 拿下地图;{lead_text}。" ) winner_key = str(result["winner_key"]) loser_key = str(result["loser_key"]) reward = 5 if match["stage"] == "group" else 6 state["players"][winner_key]["gold"] = int(state["players"][winner_key]["gold"]) + reward - parts.append( + final_text = ( f"系列赛结束,{_team_name(state['players'][winner_key])} 以 {result['score']} 击败 " f"{_team_name(state['players'][loser_key])},获得 {reward} 金币。" - "可用于 /刷新 和 /选1~/选6。" + "这笔钱可以马上用于 /刷新、/选1~/选6 或锁店等下一轮运营。" ) - return parts[:5] + parts[-1] = parts[-1] + "\n" + final_text + if len(parts) <= 5: + return parts + return [parts[0], parts[1], parts[2], "\n".join(parts[3:-1]), parts[-1]] def _settle_current_match( @@ -2214,9 +2677,13 @@ def _special_event_parts( won = power >= target reward = 4 if won else 1 player["gold"] = int(player["gold"]) + reward + hook = _pick_fragment( + "challenges", + f"{state['run_id']}|{event_kind}|{event_id}|{key}", + ) text = ( f"{_team_name(player)} {'完成' if won else '勉强应付'}{event_kind}:" - f"表现 {power:.1f}/{target},获得 {reward} 金币。" + f"{hook}获得 {reward} 金币。" ) parts.append(text) stories.append(_story(state, "special_event", text, {"person_key": key})) @@ -2289,7 +2756,8 @@ class CsMajorAutochess: "选秀完成后每队 5 名首发、1 名教练、3 个备战席。\n" "/选1~/选6;/阵容;/刷新;/锁定;/上阵 备战编号 [首发位];/出售 备战编号。" "/战术 火力|稳定|残局,稳定克火力、火力克残局、残局克稳定。\n" - "三场训练赛后进入 BO3 单循环小组赛;半决赛/决赛 BO5。" + "训练赛按参赛人数生成多轮真实对阵,每轮结束后可 D 牌和调阵容;" + "随后进入 BO3 单循环小组赛,半决赛/决赛 BO5。" "所有需要推进的阶段由相关真人发送 /结束回合,满足条件后自动进入下一步。" ) @@ -2373,7 +2841,7 @@ class CsMajorAutochess: ) season = next_season state = { - "schema_version": 2, + "schema_version": 3, "run_id": f"major_{season:04d}", "season_number": season, "event_title": f"第 {season} 届 IEM 盐田 Major", @@ -2403,6 +2871,8 @@ class CsMajorAutochess: "story_sequence": 0, "battle_sequence": 0, "special_event_done": False, + "training_rounds": [], + "training_round_cursor": 0, } text = ( f"{state['event_title']} 开放报名;主持人 {display_name} 已自动加入。" @@ -2485,7 +2955,7 @@ class CsMajorAutochess: f"参赛者:{names}。", ( f"种子顺序:{_seed_lines(state)}。本届采用 {mode}:" - "先进行三轮选秀,随后训练赛、小组赛 BO3 单循环," + "先进行三轮选秀,随后按轮次训练赛、小组赛 BO3 单循环," "淘汰赛 BO5;单图 MR15,常规时间 15-15 进入加时。" ), _draft_board_part(state), @@ -2667,10 +3137,10 @@ class CsMajorAutochess: phase = str(state.get("phase")) stories: list[dict[str, Any]] = [] battles: list[dict[str, Any]] = [] - if phase == "lineup_prep": - parts, stories, battles = _training_parts(state, event_id) + if phase in {"lineup_prep", "training_manage"}: + parts, stories, battles = _settle_training_round(state, event_id) return respond(state, parts, stories=stories, battles=battles) - if phase == "training_manage": + if phase == "training_complete_manage": parts = _start_group_parts(state) return respond(state, parts) if phase == "group_ready": 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 3c2b984..0971519 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/descriptors.py @@ -158,8 +158,10 @@ LOCAL_EXTENSIONS = ( LocalExtensionDescriptor( extension_id="cs-major-autochess", author=JWEBOT_AUTHOR, - description="CS自走棋", - help_entries=("/自走棋开始|报名",), + description="自走棋", + help_entries=( + "/自走棋 开始|报名|选", + ), command_kinds=("cs_autochess",), ), LocalExtensionDescriptor( diff --git a/packages/jwebot-sdk/pyproject.toml b/packages/jwebot-sdk/pyproject.toml index 110640c..801fb92 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.6a0+1015" +version = "0.6.7a0+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 dd1eaf5..de1fa51 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.6a0+1015" +__version__ = "0.6.7a0+1015" diff --git a/pyproject.toml b/pyproject.toml index ecc3306..192d8ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.jwebot] -release = "alpha 0.6.6.1015" +release = "alpha 0.6.7.1015" production_deployments_enabled_by_default = false [tool.jwebot.workspace] diff --git a/tests/test_release_064.py b/tests/test_release_064.py index 80252a2..be18a7e 100644 --- a/tests/test_release_064.py +++ b/tests/test_release_064.py @@ -77,12 +77,15 @@ class Release064AutochessTests(unittest.TestCase): return replies def test_static_pool_parser_and_coach_pool(self): - self.assertEqual(len(AUTOCHESS.PLAYER_POOL), 60) - self.assertEqual(len({card["id"] for card in AUTOCHESS.PLAYER_POOL}), 60) - self.assertEqual(len(AUTOCHESS.COACH_POOL), 10) + self.assertGreaterEqual(len(AUTOCHESS.PLAYER_POOL), 100) + self.assertEqual( + len({card["id"] for card in AUTOCHESS.PLAYER_POOL}), + len(AUTOCHESS.PLAYER_POOL), + ) + self.assertGreaterEqual(len(AUTOCHESS.COACH_POOL), 18) self.assertEqual( len({coach["id"] for coach in AUTOCHESS.COACH_POOL}), - 10, + len(AUTOCHESS.COACH_POOL), ) self.assertEqual( AUTOCHESS.parse_autochess_action("/自走棋开始"), @@ -114,7 +117,7 @@ class Release064AutochessTests(unittest.TestCase): self.assertEqual(player["board"], []) self.assertEqual(player["coach_id"], "") - def test_three_round_draft_training_and_group_intro(self): + def test_three_round_draft_training_rounds_and_group_intro(self): self.start_two_human_two_ai() final_draft = self.finish_draft() state = self.state() @@ -126,14 +129,21 @@ class Release064AutochessTests(unittest.TestCase): self.assertEqual(len(player["bench"]), 3) self.assertTrue(player["coach_id"]) - self.command("/结束回合") - training = self.command("/结束回合", sender="Bob") - self.assertIn("Team Falcons", "\n".join(training)) - self.assertIn("Natus Vincere", "\n".join(training)) + training = self.ready_all() + training_text = "\n".join(training) + self.assertIn("训练赛第 1/3 轮", training_text) + self.assertIn("Team", training_text) + self.assertNotIn("目标", training_text) + self.assertNotIn("发挥", training_text) self.assertEqual(self.state()["phase"], "training_manage") - self.command("/结束回合") - group = self.command("/结束回合", sender="Bob") + round_two = "\n".join(self.ready_all()) + self.assertIn("训练赛第 2/3 轮", round_two) + round_three = "\n".join(self.ready_all()) + self.assertIn("训练赛第 3/3 轮", round_three) + self.assertEqual(self.state()["phase"], "training_complete_manage") + + group = self.ready_all() self.assertIn("正赛小组赛", "\n".join(group)) state = self.state() self.assertEqual(state["phase"], "group_ready") diff --git a/tests/test_release_067.py b/tests/test_release_067.py new file mode 100644 index 0000000..5358efb --- /dev/null +++ b/tests/test_release_067.py @@ -0,0 +1,138 @@ +"""Behavioral coverage for the CS Major autochess 0.6.7 pacing rewrite.""" + +from __future__ import annotations + +import json +import re +import tempfile +import unittest +from pathlib import Path + +from test_jwebot_edge import MODULE, config +from jwebot_official_extensions import cs_autochess as AUTOCHESS + + +class Release067AutochessTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.engine = MODULE.EdgeApplication(config(Path(self.temp.name))) + self.counter = 0 + + def tearDown(self): + self.engine.close() + self.temp.cleanup() + + def command(self, question, *, sender="Alice", event_id=None): + self.counter += 1 + response = self.engine.accept( + { + "event_id": event_id or f"autochess-067-{self.counter}", + "group_fingerprint": "group-a", + "sender": sender, + "text": "@JWeBot " + question, + "mention_bot": True, + "confidence": 1.0, + "sender_confidence": 1.0, + "body_confidence": 1.0, + } + ) + self.assertEqual(response["status"], "reply") + return list(response.get("texts") or [response["text"]]) + + def state(self): + row = self.engine.ledger.connection.execute( + """ + SELECT state_json FROM autochess_runs + WHERE group_fingerprint = 'group-a' + ORDER BY season_number DESC + LIMIT 1 + """ + ).fetchone() + self.assertIsNotNone(row) + return json.loads(row[0]) + + def ready_all(self): + state = self.state() + replies = [] + for key in list(state.get("ready_keys") or []): + replies.extend( + self.command( + "/结束回合", + sender=state["players"][key]["display_name"], + ) + ) + return replies + + def start_two_human_two_ai(self): + self.command("/自走棋开始") + self.command("/自走棋报名", sender="Bob") + self.command("/自走棋加AI战队") + self.command("/自走棋开赛") + + def finish_draft(self): + self.command("/选1") + self.command("/选1", sender="Bob") + self.command("/选1", sender="Bob") + self.command("/选1") + self.command("/选1") + self.command("/选1", sender="Bob") + + def test_narrative_inventory_and_training_round_robin(self): + self.assertGreaterEqual(AUTOCHESS._narrative_inventory_size(), 120) + + self.start_two_human_two_ai() + self.finish_draft() + training = "\n".join(self.ready_all()) + state = self.state() + + self.assertEqual(len(state["training_rounds"]), 3) + self.assertEqual(state["training_round_cursor"], 1) + self.assertIn("训练赛第 1/3 轮对阵", training) + self.assertIn("Team", training) + self.assertIn("AI 战队", training) + self.assertNotIn("目标", training) + self.assertNotIn("发挥", training) + + def test_round_simulation_produces_natural_score_variety(self): + self.start_two_human_two_ai() + self.finish_draft() + state = self.state() + key_a, key_b = state["seed_order"][:2] + + scores = set() + for index in range(12): + result = AUTOCHESS._battle( + state, + key_a, + key_b, + f"score-variety-{index}", + best_of=1, + ) + score = result["maps"][0]["score"] + self.assertRegex(score, r"^\d+-\d+$") + self.assertGreaterEqual(result["maps"][0]["rounds_played"], 16) + scores.add(score) + + old_fixed = {"16-6", "16-9", "16-12", "16-14", "19-17"} + self.assertGreaterEqual(len(scores), 3) + self.assertTrue(scores - old_fixed) + + def test_group_report_is_paced_and_hides_raw_calculation_terms(self): + self.start_two_human_two_ai() + self.finish_draft() + self.ready_all() + self.ready_all() + self.ready_all() + self.ready_all() + + report = "\n".join(self.ready_all()) + self.assertIn("图1", report) + self.assertIn("系列赛结束", report) + self.assertNotIn("目标", report) + self.assertNotIn("发挥", report) + self.assertNotIn("克制修正", report) + self.assertIsNotNone(re.search(r"\b\d{2}-\d{1,2}\b", report)) + + +if __name__ == "__main__": + unittest.main()