From a74147e7f1b413969722d22597ae81157289a1f3 Mon Sep 17 00:00:00 2001 From: JWeBot Contributors Date: Thu, 15 Oct 2026 17:10:28 +0800 Subject: [PATCH] Release alpha 0.6.6 autochess flow --- CHANGELOG.md | 19 + README.md | 23 +- VERSION | 2 +- docs/cs-major-autochess-design.md | 87 +- extensions/jwebot/coin/src/coin.py | 8 + .../src/cs_major_autochess.py | 8 + .../jwebot/daily-fortune/src/daily_fortune.py | 8 + extensions/jwebot/dice/src/dice.py | 8 + extensions/jwebot/draw-lot/src/draw_lot.py | 8 + .../jwebot/guess-number/src/guess_number.py | 8 + .../history-search/src/history_search.py | 8 + .../jwebot/random-number/src/random_number.py | 8 + .../repeat-ranking/src/repeat_ranking.py | 8 + .../src/rock_paper_scissors.py | 8 + .../jwebot/scan-ranking/src/scan_ranking.py | 8 + .../src/single_player_games.py | 8 + .../steam-presence/src/steam_presence.py | 8 + .../context-summary/src/context_summary.py | 8 + .../debug-telemetry/src/debug_telemetry.py | 8 + .../jwebot-version/src/jwebot_version.py | 8 + .../person-directory/src/person_directory.py | 8 + .../person-memory/src/person_memory.py | 8 + .../server-status/src/server_status.py | 8 + 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 +- .../src/jwebot_kernel/edge_runtime.py | 47 +- .../jwebot-official-extensions/pyproject.toml | 4 +- .../jwebot_official_extensions/__init__.py | 8 +- .../adapters/coin.py | 8 + .../adapters/context_summary.py | 8 + .../adapters/cs_major_autochess.py | 8 + .../adapters/daily_fortune.py | 8 + .../adapters/debug_telemetry.py | 8 + .../adapters/dice.py | 8 + .../adapters/draw_lot.py | 8 + .../adapters/guess_number.py | 8 + .../adapters/history_search.py | 8 + .../adapters/jwebot_version.py | 8 + .../adapters/person_directory.py | 8 + .../adapters/person_memory.py | 8 + .../adapters/random_number.py | 8 + .../adapters/repeat_ranking.py | 8 + .../adapters/rock_paper_scissors.py | 8 + .../adapters/scan_ranking.py | 8 + .../adapters/server_status.py | 8 + .../adapters/single_player_games.py | 8 + .../adapters/steam_presence.py | 8 + .../cs_autochess.py | 1504 +++++++++++------ .../local_commands.py | 37 +- .../src/jwebot_official_extensions/runtime.py | 10 +- packages/jwebot-sdk/pyproject.toml | 2 +- .../jwebot-sdk/src/jwebot_sdk/__init__.py | 2 +- .../jwebot-sdk/src/jwebot_sdk/contracts.py | 40 +- pyproject.toml | 2 +- tests/test_release_064.py | 542 ++---- tests/test_release_065.py | 111 +- tests/test_sdk_contracts.py | 10 + tests/test_wechat_adaptive_recorder.py | 33 +- tools/sync-local-extensions.py | 8 + wechat_adaptive_recorder.py | 81 + 64 files changed, 1838 insertions(+), 1060 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b53f9..fc9b3b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## alpha 0.6.6.1015 + +- Rebuild CS Major autochess around multi-message hosting: tournament + welcomes, seed order, draft boards, status panels, training results, match + reports, standings, brackets, and awards are emitted as separate bounded + replies. +- Replace instant full rosters with three draft rounds: round one selects from + a fixed 10-coach pool, rounds two and three draft players, then each team is + completed to five starters, one coach, and three bench slots. +- Run the full event as training camp, BO3 single-round-robin group stage, + one media/sponsor challenge after group round one or two, BO5 semifinals, + BO5 final, and final awards. +- Let readiness advance phases automatically. Group matches wait for all real + players, while knockout matches only wait for real players still in the + current pairing, so eliminated players are not prompted to prepare. +- Add local multi-text reply support across the SDK, official extension + adapter, Edge response contract, and recorder GUI sender while preserving + single-message compatibility. + ## alpha 0.6.5.1015 - Extend the AI test path for CS Major autochess to support two-human games: diff --git a/README.md b/README.md index e75a31f..eef37a6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 并通过带视觉确认的 GUI 发送链路回复。 -当前版本:`alpha 0.6.5.1015`。 +当前版本:`alpha 0.6.6.1015`。 0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加 两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见 @@ -20,14 +20,16 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 0.6.3 将彩票改为群内多人轮次,加入报名、状态、主持人开奖和取消; 抽奖、彩票、21 点统一使用 `Jacky #N:...` 局号并支持 `/游戏记录`。 完整玩法见[多人彩票与游戏记录](docs/group-lottery-0.6.3.md)。 -0.6.4 新增 2~8 人 CS Major 自走棋:固定五人首发与教练、每轮 6 人 -公开商店、属性/定位/战术克制、小组单循环、1v4 与 2v3 淘汰赛,以及 -BO3/BO5 的 MR15 加时赛。支持三名真人加入一支确定性 AI 测试战队; -Taffy 负责赛制播报、顺序准备、交棒和全程剧情主持。所有选秀、战斗和 -加时剧情均在本地事务内落档。 +0.6.4 新增 2~8 人 CS Major 自走棋:职业选手卡池、属性/定位/战术 +克制、小组单循环、1v4 与 2v3 淘汰赛,以及 BO3/BO5 的 MR15 加时赛。 完整规则见[CS Major 自走棋设计稿](docs/cs-major-autochess-design.md)。 0.6.5 将 AI 测试模式扩展到双人:两名真人可一键补入 AI 战队 A、B, -按四队种子运行 1v4、2v3 半决赛;两支 AI 独立运营且不占真人准备顺序。 +两支 AI 独立运营。 +0.6.6 重构自走棋流程:开赛后三轮选秀,第一轮从固定 10 人教练池中 +选教练,第二、三轮选选手;随后进入三场训练赛、BO3 单循环小组赛、 +一次媒体日或赞助挑战、BO5 半决赛和总决赛。主持消息会拆分为多条 +回复;所有需要推进的阶段由相关真人发送 `/结束回合` 自动进入下一步, +已淘汰玩家不会再收到赛前准备提示。 ### 运行流程 @@ -98,9 +100,10 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh /21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 /游戏记录 [页码](/gamehistory) /游戏帮助(/games) -/自走棋开始|报名|开赛|状态|结算|档案 +/自走棋开始|报名|开赛|状态|档案 /自走棋加AI战队(两名或三名真人测试模式) -/选1~6;/战术 火力|稳定|残局;/阵容;/结束回合 +/选1~6(教练轮为 /选1~10);/刷新;/锁定 +/战术 火力|稳定|残局;/阵容;/上阵 备战编号 [首发位];/出售 备战编号;/结束回合 /丢骰子 [NdM] /猜数字 开始 [上限] /石头剪刀布 [开始|加入|出|状态|结算|结束] @@ -269,7 +272,7 @@ visually confirmed GUI bridge. Current version: ```text -alpha 0.6.5.1015 +alpha 0.6.6.1015 ``` ## Runtime Flow diff --git a/VERSION b/VERSION index 911eb06..f3625ef 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -alpha 0.6.5.1015 +alpha 0.6.6.1015 diff --git a/docs/cs-major-autochess-design.md b/docs/cs-major-autochess-design.md index 8746500..6ed4fe2 100644 --- a/docs/cs-major-autochess-design.md +++ b/docs/cs-major-autochess-design.md @@ -16,12 +16,13 @@ 一句话定义: -`公开 6 人选秀 -> 凑羁绊与升星 -> 打 PvE 拿资源 -> 打 PvP 跑 Major 赛程 -> 保存每轮赛报与剧情` +`三轮赛前选秀 -> 训练赛拿资源 -> D 牌与布阵 -> BO3 小组单循环 -> BO5 淘汰赛 -> 保存每轮赛报与剧情` 玩家感受到的乐趣应当主要来自: -- 抢 6 人选秀里的关键卡; -- 决定要不要升教练等级、保经济还是强行 D 牌; +- 第一轮从固定 10 人教练池里抢体系教练; +- 第二、三轮公开选手池里抢关键卡; +- 决定要不要保经济还是强行 D 牌; - 赌阵容成型时点; - 看小组单循环和淘汰赛赛报; - 看最终冠军剧情与本届 Major 档案。 @@ -36,9 +37,11 @@ - 主持人可在报名阶段发送 `/自走棋加AI战队`: - 两名真人时加入 AI 战队 A、B; - 三名真人时加入一支 AI 战队。 -- 两种测试模式都补满四队,直接生成 `1v4`、`2v3` 半决赛。 +- 两种测试模式都补满四队,先进行 BO3 小组单循环,再按排名生成 + `1v4`、`2v3` 半决赛。 - AI 占一个参赛席位,但不对应群成员,也不调用在线模型。 -- AI 每回合以确定性规则买牌、升级教练、调整首发和选择克制战术。 +- AI 使用确定性规则选秀、买牌、调整首发和选择克制战术;为便于测试, + AI 阵容战力会有明确折扣。 ### 3.2 玩家初始状态 @@ -46,16 +49,15 @@ - 生命值:`100` - 金币:`10` -- 教练等级:`1` -- 完整首发:`5 名一星选手 + 1 名教练` +- 教练:赛前第一轮选秀决定 +- 初始选手:赛前第二、三轮选秀决定,系统随后补齐阵容 - 阵容位:固定 `5 个选手位 + 1 个教练位`,比赛时必须全部上场 -- 备战席:`8 格` +- 备战席:`3 格` - 连胜/连败计数:`0` - 本局独立事件档案编号 -开赛时系统从低费池为每位玩家确定性分配 5 名首发选手,保证第一场 -比赛就是完整的五人阵容。后续选秀用于替换、强化和升星,而不是解锁 -上场人口。 +三轮选秀完成后,系统按已选核心确定性补齐每队 `5 名首发 + 3 名备战` +和常驻教练,保证训练赛开始时就是完整阵容。 ## 4. 选手池设计 @@ -195,12 +197,11 @@ - 4 级:全队战力 `+8%` - 5 级:全队战力 `+10%` -### 7.2 教练升级 +### 7.2 教练获取 -玩家可花金币升级教练经验。 - -建议升级费用为 `6 / 10 / 16 / 24` 金币。升级只提高上述战术加成。 -高费选手的商店概率改为随赛事回合推进,不再绑定教练等级。 +教练由第一轮公共选秀决定,本届赛事内不能用金币升级教练。不同教练 +自带等级、属性倾向和全队加成。 +高费选手的商店概率随赛事进程推进,不再绑定教练等级。 ## 8. 每轮 6 人选秀 @@ -242,12 +243,14 @@ /选4 /选5 /选6 +/选7 +/选8 +/选9 +/选10 /刷新 /锁定 -/升级教练 /战术 火力|稳定|残局 /上阵 备战编号 [首发位] -/下阵 首发位 备战编号 /出售 备战编号 /阵容 /赛程 @@ -258,15 +261,16 @@ ### 8.3 刷新与锁定 -- 每轮默认展示一组 6 人。 +- 第一轮教练选秀展示固定 10 名教练;第二、三轮选手选秀展示 6 名选手。 +- 训练赛和正赛运营阶段默认展示一组 6 人商店。 - 玩家可支付金币执行刷新。 - 玩家可锁定本轮 6 人,使下一轮仍保留同一组候选。 ### 8.4 顺位 -- 开局首轮选秀顺位随机。 -- 后续普通回合不再做蛇形公共抢位,而是每位玩家各自面对同一组 6 人池。 -- 如果后续想加竞技性,可在第二阶段引入“共享卡池抢空”机制。 +- 开赛后三轮公共选秀,第一轮选教练,第二、三轮选选手。 +- 第二轮使用反向顺序,避免种子靠前者连续先手。 +- AI 轮次会自动选择,真人只在轮到自己时使用 `/选N`。 第一期建议先做“公开同池、各自结算”,避免群聊交互过重。 @@ -558,22 +562,23 @@ PvE 对手是模板化敌方阵容或阈值型事件,不需要复杂 AI。 建议所有回复都遵循现有小游戏风格: -`用户名 #局号:内容` +多段回复优先。一次主持动作可以拆成欢迎语、参赛名单、种子顺序、候选池、 +状态面板、操作指引等多条消息,每条保持在发送上限内。 例如: ```text -Jacky #3:第 4 回合开始,你当前 32 金币,教练 3 级。 -本轮选秀如下: -1. donk 5费 步枪手 Spirit 俄罗斯 火力96 稳定88 残局86 -2. apEX 3费 指挥 Vitality 法国 火力68 稳定81 指挥95 +欢迎来到第一轮选秀,本轮选教练。 +选择顺序:Jacky、Alice、AI 战队 A、AI 战队 B +1. zonic|5级教练|冠军体系|火78 稳92 残88|全队+9% ... -发送“选1”到“选6”,或发送“刷新”“锁定”“升级教练”。 +现在轮到 @Jacky 做出选择,使用 /选1~/选10。 ``` ### 16.1 Taffy 主持状态机 -Taffy 必须承担完整赛事主持,而不是只在结算时出现。 +主持人必须承担完整赛事主持,而不是只在结算时出现;输出不需要每句都带 +`Taffy:` 前缀。 开赛时必须包含: @@ -581,27 +586,29 @@ Taffy 必须承担完整赛事主持,而不是只在结算时出现。 - 本届参赛者和种子 - 当前人数对应的完整赛制 - 小组赛 BO3、淘汰赛 BO5、单图 MR15 和加时规则 -- 当前回合内容 -- 第一位需要准备的玩家 +- 三轮选秀规则和当前选择玩家 +- 训练赛、小组赛和淘汰赛的当前阶段 -每回合按真人玩家生成确定性的准备顺序。AI 自动准备,不占真人操作轮次。 -只有当前玩家可以购买、刷新、选择战术和调整阵容。提交后必须明确交棒: +赛前选秀按顺序逐人选择。进入阵容准备、训练赛运营和小组赛运营后, +所有相关真人发送 `/结束回合` 即可自动推进,不需要主持人手动结算。 +淘汰赛只等待当前对阵中的真人,已经淘汰的玩家不会再被提示准备。 ```text -Taffy:Jacky,请做好赛前准备;完成后发送 /结束回合。 -Taffy:Jacky 准备完成!到 Alice 做准备了。 -Taffy:Alice 准备完成!全员准备完毕,主持人可以结算。 -``` +Jacky 选择了 zonic! +现在轮到 @Alice 做出选择,使用 /选1~/选10。 -下一回合轮换首位准备者,避免固定由同一个人总是先操作。 +训练赛阶段结束。可以 /刷新、/选1~/选6、/上阵、/战术; +所有真人发送 /结束回合 后进入小组赛。 +``` 以下节点都必须由 Taffy 主持并落档: - 报名开启和新选手加入 - AI 测试战队加入 - 赛制与种子公布 -- 每位玩家开始/完成准备 -- 每轮开始和结算 +- 三轮选秀候选池、选择结果和选秀完成 +- 三场训练赛结果 +- 媒体日或赞助挑战 - 每场 BO3/BO5 对阵与结果 - 每张加时地图 - 小组排名和前四晋级 diff --git a/extensions/jwebot/coin/src/coin.py b/extensions/jwebot/coin/src/coin.py index 164f750..caf95c6 100644 --- a/extensions/jwebot/coin/src/coin.py +++ b/extensions/jwebot/coin/src/coin.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") 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 6e3371e..66cef99 100644 --- a/extensions/jwebot/cs-major-autochess/src/cs_major_autochess.py +++ b/extensions/jwebot/cs-major-autochess/src/cs_major_autochess.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/jwebot/daily-fortune/src/daily_fortune.py b/extensions/jwebot/daily-fortune/src/daily_fortune.py index 37152b6..00c9738 100644 --- a/extensions/jwebot/daily-fortune/src/daily_fortune.py +++ b/extensions/jwebot/daily-fortune/src/daily_fortune.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/jwebot/dice/src/dice.py b/extensions/jwebot/dice/src/dice.py index b4e1f8f..1e4806f 100644 --- a/extensions/jwebot/dice/src/dice.py +++ b/extensions/jwebot/dice/src/dice.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/jwebot/draw-lot/src/draw_lot.py b/extensions/jwebot/draw-lot/src/draw_lot.py index 08d6f76..5f8efb6 100644 --- a/extensions/jwebot/draw-lot/src/draw_lot.py +++ b/extensions/jwebot/draw-lot/src/draw_lot.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/jwebot/guess-number/src/guess_number.py b/extensions/jwebot/guess-number/src/guess_number.py index a3a561d..8b7252f 100644 --- a/extensions/jwebot/guess-number/src/guess_number.py +++ b/extensions/jwebot/guess-number/src/guess_number.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/jwebot/history-search/src/history_search.py b/extensions/jwebot/history-search/src/history_search.py index fb14f32..1deffff 100644 --- a/extensions/jwebot/history-search/src/history_search.py +++ b/extensions/jwebot/history-search/src/history_search.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/jwebot/random-number/src/random_number.py b/extensions/jwebot/random-number/src/random_number.py index 2103ce9..c8e47a3 100644 --- a/extensions/jwebot/random-number/src/random_number.py +++ b/extensions/jwebot/random-number/src/random_number.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/jwebot/repeat-ranking/src/repeat_ranking.py b/extensions/jwebot/repeat-ranking/src/repeat_ranking.py index b971fb2..daaeb8b 100644 --- a/extensions/jwebot/repeat-ranking/src/repeat_ranking.py +++ b/extensions/jwebot/repeat-ranking/src/repeat_ranking.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/jwebot/rock-paper-scissors/src/rock_paper_scissors.py b/extensions/jwebot/rock-paper-scissors/src/rock_paper_scissors.py index c03c149..ba94576 100644 --- a/extensions/jwebot/rock-paper-scissors/src/rock_paper_scissors.py +++ b/extensions/jwebot/rock-paper-scissors/src/rock_paper_scissors.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/jwebot/scan-ranking/src/scan_ranking.py b/extensions/jwebot/scan-ranking/src/scan_ranking.py index 082f31c..51f6c9d 100644 --- a/extensions/jwebot/scan-ranking/src/scan_ranking.py +++ b/extensions/jwebot/scan-ranking/src/scan_ranking.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") 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 b5af704..6114e2b 100644 --- a/extensions/jwebot/single-player-games/src/single_player_games.py +++ b/extensions/jwebot/single-player-games/src/single_player_games.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/jwebot/steam-presence/src/steam_presence.py b/extensions/jwebot/steam-presence/src/steam_presence.py index 5c8b69d..75a7dcf 100644 --- a/extensions/jwebot/steam-presence/src/steam_presence.py +++ b/extensions/jwebot/steam-presence/src/steam_presence.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/official/context-summary/src/context_summary.py b/extensions/official/context-summary/src/context_summary.py index bc95305..ec5e678 100644 --- a/extensions/official/context-summary/src/context_summary.py +++ b/extensions/official/context-summary/src/context_summary.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/official/debug-telemetry/src/debug_telemetry.py b/extensions/official/debug-telemetry/src/debug_telemetry.py index 1a98a9b..d29cf30 100644 --- a/extensions/official/debug-telemetry/src/debug_telemetry.py +++ b/extensions/official/debug-telemetry/src/debug_telemetry.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/official/jwebot-version/src/jwebot_version.py b/extensions/official/jwebot-version/src/jwebot_version.py index 8867577..871eec1 100644 --- a/extensions/official/jwebot-version/src/jwebot_version.py +++ b/extensions/official/jwebot-version/src/jwebot_version.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/official/person-directory/src/person_directory.py b/extensions/official/person-directory/src/person_directory.py index e7681d9..9176253 100644 --- a/extensions/official/person-directory/src/person_directory.py +++ b/extensions/official/person-directory/src/person_directory.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/official/person-memory/src/person_memory.py b/extensions/official/person-memory/src/person_memory.py index 7936d11..f03b86f 100644 --- a/extensions/official/person-memory/src/person_memory.py +++ b/extensions/official/person-memory/src/person_memory.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/extensions/official/server-status/src/server_status.py b/extensions/official/server-status/src/server_status.py index dfaaf76..9131f98 100644 --- a/extensions/official/server-status/src/server_status.py +++ b/extensions/official/server-status/src/server_status.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-config/pyproject.toml b/packages/jwebot-config/pyproject.toml index 3ce0018..790c2a1 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.5a0+1015" +version = "0.6.6a0+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 b77b468..e899f8e 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.5a0+1015" +__version__ = "0.6.6a0+1015" diff --git a/packages/jwebot-extension-host/pyproject.toml b/packages/jwebot-extension-host/pyproject.toml index 456be8d..6d9e7c1 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.5a0+1015" +version = "0.6.6a0+1015" description = "Single-release, single-invocation process host for JWeBot extensions" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.5a0+1015", - "jwebot-kernel==0.6.5a0+1015", + "jwebot-sdk==0.6.6a0+1015", + "jwebot-kernel==0.6.6a0+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 80fd60c..afa4c4d 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.5a0+1015" +__version__ = "0.6.6a0+1015" diff --git a/packages/jwebot-kernel/pyproject.toml b/packages/jwebot-kernel/pyproject.toml index 2d33488..998e039 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.5a0+1015" +version = "0.6.6a0+1015" description = "Stable routing and extension authority for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-config==0.6.5a0+1015", - "jwebot-sdk==0.6.5a0+1015", - "jwebot-official-extensions==0.6.5a0+1015", + "jwebot-config==0.6.6a0+1015", + "jwebot-sdk==0.6.6a0+1015", + "jwebot-official-extensions==0.6.6a0+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 02d118f..ba077a1 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.5a0+1015" +__version__ = "0.6.6a0+1015" diff --git a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py index fadf01d..517159b 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py @@ -61,6 +61,8 @@ from jwebot_official_extensions import ( extension_for_kind, local_command_parts, local_extension_command_token, + pack_reply_parts, + unpack_reply_parts, ) from jwebot_official_extensions.steam import ( DEFAULT_STEAM_MIHOMO_PROXY_URL, @@ -3760,7 +3762,16 @@ class Ledger: or not isinstance(state.get("players"), dict) ): raise LocalCommandError("自走棋赛事结构无效") - if status in {"active", "completed"}: + phase = str(state.get("phase", "")) + roster_required = ( + status == "completed" + or ( + status == "active" + and not phase.startswith("draft_") + and phase != "registration" + ) + ) + if roster_required: players = state["players"].values() if any( not isinstance(player, dict) @@ -4979,7 +4990,11 @@ class EdgeApplication: error_type=type(error).__name__, ) raise EdgeError("隔离插件暂时不可用") from error - if result.reply.kind is not PlanKind.TEXT: + if result.reply.kind is PlanKind.MULTI_TEXT: + reply_message = pack_reply_parts(list(result.reply.messages)) + elif result.reply.kind is PlanKind.TEXT: + reply_message = result.reply.message + else: raise EdgeError("隔离插件没有返回可见回复") self.telemetry.elapsed( "edge.extension.execute", @@ -4989,7 +5004,7 @@ class EdgeApplication: plugin_id=owner.extension_id, mode="host", ) - return result.reply.message + return reply_message if ( self.config.extension_mode == "host-only" or ( @@ -5078,13 +5093,18 @@ class EdgeApplication: "duplicate": True, } if row["response_text"]: - return { + parts = unpack_reply_parts(row["response_text"]) + text = parts[0] if parts is not None else row["response_text"] + response = { "status": "reply", "route": row["route"], "execution_path": stored_execution_path, - "text": row["response_text"], + "text": text, "duplicate": True, } + if parts is not None: + response["texts"] = parts + return response return { "status": "fallback_cloud", "reason": "existing_incomplete_event", @@ -5206,6 +5226,13 @@ class EdgeApplication: status="local_completed", response_text=reply, ) + parts = unpack_reply_parts(reply) + visible_reply = parts[0] if parts is not None else reply + reply_chars = ( + sum(len(part) for part in parts) + if parts is not None + else len(reply) + ) self.telemetry.elapsed( "edge.local.complete", started, @@ -5214,14 +5241,18 @@ class EdgeApplication: route=route, reason=reason, execution_path=execution_path, - chars=len(reply), + chars=reply_chars, + messages=len(parts) if parts is not None else 1, ) - return { + response = { "status": "reply", "route": route, "execution_path": execution_path, - "text": reply, + "text": visible_reply, } + if parts is not None: + response["texts"] = parts + return response alias_reply = self.ledger.local_alias_identity_reply( str(row["group_fingerprint"]), str(row["question"]), diff --git a/packages/jwebot-official-extensions/pyproject.toml b/packages/jwebot-official-extensions/pyproject.toml index 3698ddf..1ebf785 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.5a0+1015" +version = "0.6.6a0+1015" description = "Reviewed local command adapters and broker implementation for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==0.6.5a0+1015", + "jwebot-sdk==0.6.6a0+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 8fcf9b5..e8f8cf2 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py @@ -3,6 +3,7 @@ from .cs_autochess import ( CARD_BY_ID, COACH_BONUSES, + COACH_POOL, MAX_PLAYERS, MIN_PLAYERS, PLAYER_POOL, @@ -32,6 +33,8 @@ from .local_commands import ( LocalCommandError, deterministic_int, local_command_parts, + pack_reply_parts, + unpack_reply_parts, ) from .runtime import ( LocalExtensionError, @@ -49,6 +52,7 @@ from .steam import ( __all__ = [ "CARD_BY_ID", "COACH_BONUSES", + "COACH_POOL", "CsMajorAutochess", "DICE_CHALLENGE_TIMEOUT_SECONDS", "DEFAULT_STEAM_MIHOMO_PROXY_URL", @@ -79,9 +83,11 @@ __all__ = [ "local_extension_command_token", "lineup_power", "matchup_modifier", + "pack_reply_parts", "parse_autochess_action", + "unpack_reply_parts", "extension_for_kind", "render_feature_list", ] -__version__ = "0.6.5a0+1015" +__version__ = "0.6.6a0+1015" diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin.py index 164f750..caf95c6 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py index bc95305..ec5e678 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") 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 6e3371e..66cef99 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 @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/daily_fortune.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/daily_fortune.py index 37152b6..00c9738 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/daily_fortune.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/daily_fortune.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/debug_telemetry.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/debug_telemetry.py index 1a98a9b..d29cf30 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/debug_telemetry.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/debug_telemetry.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/dice.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/dice.py index b4e1f8f..1e4806f 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/dice.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/dice.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/draw_lot.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/draw_lot.py index 08d6f76..5f8efb6 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/draw_lot.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/draw_lot.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/guess_number.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/guess_number.py index a3a561d..8b7252f 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/guess_number.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/guess_number.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/history_search.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/history_search.py index fb14f32..1deffff 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/history_search.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/history_search.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/jwebot_version.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/jwebot_version.py index 8867577..871eec1 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/jwebot_version.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/jwebot_version.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_directory.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_directory.py index e7681d9..9176253 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_directory.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_directory.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_memory.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_memory.py index 7936d11..f03b86f 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_memory.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_memory.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/random_number.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/random_number.py index 2103ce9..c8e47a3 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/random_number.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/random_number.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/repeat_ranking.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/repeat_ranking.py index b971fb2..daaeb8b 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/repeat_ranking.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/repeat_ranking.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/rock_paper_scissors.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/rock_paper_scissors.py index c03c149..ba94576 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/rock_paper_scissors.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/rock_paper_scissors.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/scan_ranking.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/scan_ranking.py index 082f31c..51f6c9d 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/scan_ranking.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/scan_ranking.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/server_status.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/server_status.py index dfaaf76..9131f98 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/server_status.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/server_status.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") 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 b5af704..6114e2b 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 @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/steam_presence.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/steam_presence.py index 5c8b69d..75a7dcf 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/steam_presence.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/steam_presence.py @@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str): "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") 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 9184570..9eb019e 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 @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import json import re from typing import Any @@ -17,9 +18,11 @@ HOST_NAME = "Taffy" STARTING_GOLD = 10 STARTING_HP = 100 STARTER_COUNT = 5 -BENCH_LIMIT = 8 +BENCH_LIMIT = 3 SHOP_SIZE = 6 REFRESH_COST = 2 +AI_POWER_MULTIPLIER = 0.82 +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} @@ -146,6 +149,51 @@ 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_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), +) + + def _hash_int(seed: str) -> int: return int.from_bytes( hashlib.sha256(seed.encode("utf-8", "replace")).digest()[:8], @@ -153,6 +201,30 @@ def _hash_int(seed: str) -> int: ) +def _pack_response_parts(parts: list[str]) -> str: + cleaned = [part.strip() for part in parts if part.strip()] + if len(cleaned) <= 1: + return cleaned[0] if cleaned else "没有可发送内容" + return MULTI_REPLY_MARKER + json.dumps( + cleaned, + ensure_ascii=False, + separators=(",", ":"), + ) + + +def _unpack_response_parts(value: str) -> list[str]: + if not value.startswith(MULTI_REPLY_MARKER): + return [value] + try: + payload = json.loads(value[len(MULTI_REPLY_MARKER) :]) + except json.JSONDecodeError: + return [value] + if not isinstance(payload, list): + return [value] + parts = [str(item).strip() for item in payload if str(item).strip()] + return parts or [value] + + def _round_robin_rounds(keys: list[str]) -> list[list[list[str]]]: rotation: list[str | None] = list(keys) if len(rotation) % 2: @@ -180,24 +252,11 @@ def build_round_plan(keys: list[str]) -> list[dict[str, Any]]: "name": PVE_EVENTS["pve_training"][0], } ] - if len(keys) in {2, 4}: - for phase in ("pve_media", "pve_sponsor"): - plan.append( - { - "kind": "pve", - "phase": phase, - "name": PVE_EVENTS[phase][0], - } - ) - else: - group_rounds = _round_robin_rounds(keys) - media_after = max(1, (len(group_rounds) + 2) // 3) - sponsor_after = max( - media_after + 1, - (len(group_rounds) * 2 + 2) // 3, - ) - for day, pairs in enumerate(group_rounds, start=1): - item = { + group_rounds = _round_robin_rounds(keys) + challenge_after = 1 if len(group_rounds) <= 2 else 2 + for day, pairs in enumerate(group_rounds, start=1): + plan.append( + { "kind": "group", "phase": f"group_{day}", "name": f"小组赛第 {day} 轮", @@ -205,54 +264,25 @@ def build_round_plan(keys: list[str]) -> list[dict[str, Any]]: "pairs": pairs, "last_group_day": day == len(group_rounds), } - plan.append(item) - if day == media_after: - plan.append( - { - "kind": "pve", - "phase": "pve_media", - "name": PVE_EVENTS["pve_media"][0], - } - ) - if day == sponsor_after: - plan.append( - { - "kind": "pve", - "phase": "pve_sponsor", - "name": PVE_EVENTS["pve_sponsor"][0], - } - ) - existing_pve = { - str(item["phase"]) - for item in plan - if item["kind"] == "pve" - } - for phase in ("pve_media", "pve_sponsor"): - if phase not in existing_pve: - plan.append( - { - "kind": "pve", - "phase": phase, - "name": PVE_EVENTS[phase][0], - } - ) - if len(keys) == 4: - plan.append( - { - "kind": "semifinal", - "phase": "semifinal", - "name": "淘汰赛半决赛(1v4、2v3)", - "pairs": [[keys[0], keys[3]], [keys[1], keys[2]]], - "pairing": "seed", - } ) - elif len(keys) > 4: + if day == challenge_after: + phase = "pve_media" if len(keys) % 2 == 0 else "pve_sponsor" + plan.append( + { + "kind": "pve", + "phase": phase, + "name": PVE_EVENTS[phase][0], + } + ) + if len(keys) >= 4: plan.append( { "kind": "semifinal", "phase": "semifinal", "name": "淘汰赛半决赛(1v4、2v3)", - "pairs": [], + "pairs": [[keys[0], keys[3]], [keys[1], keys[2]]] + if len(keys) == 4 + else [], "pairing": "standings", } ) @@ -417,12 +447,25 @@ def lineup_power(player: dict[str, Any]) -> dict[str, Any]: nation_values.append((bonus, count, name)) nation_values.sort(reverse=True) nation_bonus = nation_values[0][0] if nation_values else 0 - coach_bonus = COACH_BONUSES[int(player["coach_level"])] + coach_id = str(player.get("coach_id", "")) + coach = COACH_BY_ID.get(coach_id) + if coach is not None: + axes["火力"] += float(coach["firepower"]) + axes["稳定"] += float(coach["stability"]) + axes["残局"] += float(coach["clutch"]) + coach_bonus = int(coach["bonus"]) + coach_label = f"{coach['name']}+{coach_bonus}%" + else: + coach_level = int(player.get("coach_level") or 1) + coach_bonus = COACH_BONUSES.get(coach_level, 0) + coach_label = f"教练+{coach_bonus}%" total = base * (100 + team_bonus + nation_bonus + coach_bonus) / 100 + if player.get("is_ai"): + total *= AI_POWER_MULTIPLIER labels = [ *(f"{name}{count}" for _, count, name in team_values[:2]), *(f"{name}{count}" for _, count, name in nation_values[:1]), - f"教练+{coach_bonus}%", + coach_label, ] return { "power": round(total, 1), @@ -541,13 +584,13 @@ def _story( facts: dict[str, Any] | None = None, ) -> dict[str, Any]: state["story_sequence"] = int(state.get("story_sequence", 0)) + 1 - hosted_text = _host_text(text) + story_text = str(text).strip() item = { "story_id": f"story_{state['story_sequence']:04d}", "round_number": int(state.get("round_number", 0)), "phase": str(state.get("phase", "registration")), "story_type": story_type, - "text": hosted_text, + "text": story_text, "facts": facts or {}, } state.setdefault("stories", []).append(item) @@ -602,7 +645,11 @@ def _participant( "display_name": display_name, "hp": STARTING_HP, "gold": STARTING_GOLD, - "coach_level": 1, + "team_name": f"Team {display_name}" if not is_ai else display_name, + "coach_id": "", + "coach_level": 0, + "coach_bonus": 0, + "coach_style": "", "board": [], "bench": [], "shop": [], @@ -1570,6 +1617,662 @@ def parse_autochess_action(question: str) -> tuple[str, str]: return "unknown", raw +def _coach_line(slot: int, coach_id: str) -> str: + coach = COACH_BY_ID[coach_id] + return ( + f"{slot}. {coach['name']}|{coach['level']}级教练|{coach['style']}|" + f"火{coach['firepower']} 稳{coach['stability']} 残{coach['clutch']}|" + f"全队+{coach['bonus']}%" + ) + + +def _team_name(player: dict[str, Any]) -> str: + if player.get("is_ai"): + return str(player["display_name"]) + return f"Team {player['display_name']}" + + +def _seed_lines(state: dict[str, Any]) -> str: + return "、".join( + f"{state['players'][key]['seed']}号{state['players'][key]['display_name']}" + for key in state["seed_order"] + ) + + +def _draft_order(state: dict[str, Any], round_number: int) -> list[str]: + order = list(state["seed_order"]) + if round_number == 2: + order.reverse() + return order + + +def _draft_candidate_cards(state: dict[str, Any], round_number: int) -> list[str]: + cards = [ + card["id"] + for card in PLAYER_POOL + if int(state["pool"].get(card["id"], 0)) > 0 + ] + cards.sort( + key=lambda card_id: _hash_int( + f"{state['run_id']}|draft|{round_number}|{card_id}" + ) + ) + selected: list[str] = [] + preferred_costs = ( + (2, 3, 1, 4, 5) + if round_number == 2 + else (3, 2, 4, 1, 5) + ) + 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 + ): + selected.append(card_id) + if len(selected) >= SHOP_SIZE: + break + for card_id in cards: + if len(selected) >= SHOP_SIZE: + break + if card_id not in selected: + selected.append(card_id) + return selected[:SHOP_SIZE] + + +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] + if draft_type == "coach" + else _draft_candidate_cards(state, round_number) + ) + state["phase"] = f"draft_{draft_type}_{round_number}" + state["draft"] = { + "round": round_number, + "type": draft_type, + "order": _draft_order(state, round_number), + "cursor": 0, + "candidates": candidates, + } + + +def _draft_round_title(draft: dict[str, Any]) -> str: + number = int(draft["round"]) + return "第一轮选秀,本轮选教练" if number == 1 else f"第 {number} 轮选秀,本轮选选手" + + +def _draft_board_part(state: dict[str, Any]) -> str: + draft = state["draft"] + order_names = "、".join( + state["players"][key]["display_name"] + for key in draft["order"] + ) + lines = [ + f"欢迎来到{_draft_round_title(draft)}。", + f"选择顺序:{order_names}", + ] + if draft["type"] == "coach": + lines.extend( + _coach_line(index, coach_id) + for index, coach_id in enumerate(draft["candidates"], start=1) + ) + lines.append("操作:轮到你时发送 /选1~/选10。") + else: + lines.extend( + _card_line(index, card_id) + for index, card_id in enumerate(draft["candidates"], start=1) + ) + lines.append("操作:轮到你时发送 /选1~/选6。") + return "\n".join(lines) + + +def _draft_prompt_part(state: dict[str, Any]) -> str: + draft = state["draft"] + cursor = int(draft.get("cursor", 0)) + order = list(draft.get("order", [])) + if cursor >= len(order): + return "本轮选秀已完成。" + key = str(order[cursor]) + name = state["players"][key]["display_name"] + command = "/选1~/选10" if draft["type"] == "coach" else "/选1~/选6" + return f"现在轮到 @{name} 做出选择,使用 {command}。" + + +def _add_unit_to_roster(player: dict[str, Any], card_id: str) -> None: + unit = _new_unit(card_id) + if len(player["board"]) < STARTER_COUNT: + player["board"].append(unit) + else: + player["bench"].append(unit) + + +def _apply_draft_choice( + state: dict[str, Any], + person_key: str, + slot: int, +) -> str: + draft = state["draft"] + candidates = list(draft["candidates"]) + player = state["players"][person_key] + if not 1 <= slot <= len(candidates): + raise ValueError("选秀编号无效") + choice_id = str(candidates[slot - 1]) + if draft["type"] == "coach": + coach = COACH_BY_ID[choice_id] + player["coach_id"] = choice_id + player["coach_level"] = int(coach["level"]) + player["coach_bonus"] = int(coach["bonus"]) + player["coach_style"] = str(coach["style"]) + text = ( + f"{player['display_name']} 选择了教练 {coach['name']}!" + f"{coach['level']}级,{coach['style']},全队+{coach['bonus']}%。" + ) + else: + card = CARD_BY_ID[choice_id] + if int(state["pool"].get(choice_id, 0)) <= 0: + raise ValueError("这名选手已经被选走") + state["pool"][choice_id] -= 1 + _add_unit_to_roster(player, choice_id) + text = ( + f"{player['display_name']} 选择了 {card['name']}!" + f"{card['cost']}费 {card['role']},{card['team']}/{card['nation']}。" + ) + candidates.pop(slot - 1) + draft["candidates"] = candidates + draft["cursor"] = int(draft.get("cursor", 0)) + 1 + return text + + +def _auto_pick_slot(state: dict[str, Any], ai_key: str) -> int: + draft = state["draft"] + candidates = list(draft["candidates"]) + if draft["type"] == "coach": + values = [ + ( + int(COACH_BY_ID[str(coach_id)]["bonus"]), + -int(COACH_BY_ID[str(coach_id)]["level"]), + index, + ) + for index, coach_id in enumerate(candidates, start=1) + ] + return min(values)[2] + values = [] + for index, card_id in enumerate(candidates, start=1): + card = CARD_BY_ID[str(card_id)] + values.append( + ( + int(card["cost"]), + int(card["firepower"]) + int(card["stability"]) + int(card["clutch"]), + _hash_int(f"{state['run_id']}|ai|{ai_key}|{card_id}"), + index, + ) + ) + return min(values)[3] + + +def _auto_advance_draft(state: dict[str, Any]) -> list[str]: + parts: list[str] = [] + while state.get("phase", "").startswith("draft_"): + draft = state["draft"] + order = list(draft["order"]) + cursor = int(draft.get("cursor", 0)) + if cursor >= len(order): + break + key = str(order[cursor]) + if not state["players"][key].get("is_ai"): + break + slot = _auto_pick_slot(state, key) + parts.append(_apply_draft_choice(state, key, slot)) + return parts + + +def _finish_draft_round_if_needed(state: dict[str, Any]) -> list[str]: + parts: list[str] = [] + while state.get("phase", "").startswith("draft_"): + draft = state["draft"] + if int(draft.get("cursor", 0)) < len(draft.get("order", [])): + break + round_number = int(draft["round"]) + if round_number < 3: + _start_draft_round(state, round_number + 1) + parts.append(_draft_board_part(state)) + parts.extend(_auto_advance_draft(state)) + continue + _ensure_full_rosters(state) + state["phase"] = "lineup_prep" + state.pop("draft", None) + _reset_ready(state, _human_keys(state)) + parts.append( + "三轮选秀结束。每队已经整理为 5 名首发、1 名教练和 3 个备战席。" + ) + parts.append( + "请各位玩家使用 /阵容 查看队伍,使用 /上阵 备战编号 [首发位] 调整首发;准备完成后发送 /结束回合。" + ) + break + return parts + + +def _ensure_full_rosters(state: dict[str, Any]) -> None: + for key in state["join_order"]: + player = state["players"][key] + if not player.get("coach_id"): + fallback = COACH_POOL[-1] if player.get("is_ai") else COACH_POOL[0] + player["coach_id"] = fallback["id"] + player["coach_level"] = int(fallback["level"]) + player["coach_bonus"] = int(fallback["bonus"]) + player["coach_style"] = str(fallback["style"]) + owned = { + str(unit["card_id"]) + for unit in [*player.get("board", []), *player.get("bench", [])] + } + target_total = STARTER_COUNT + BENCH_LIMIT + while len(player["board"]) + len(player["bench"]) < target_total: + candidates = [ + card["id"] + for card in PLAYER_POOL + if card["id"] not in owned and int(state["pool"].get(card["id"], 0)) > 0 + ] + candidates.sort( + key=lambda card_id: ( + CARD_BY_ID[card_id]["cost"] if player.get("is_ai") else -CARD_BY_ID[card_id]["cost"], + _hash_int(f"{state['run_id']}|fill|{key}|{card_id}"), + ) + ) + if not candidates: + break + card_id = candidates[0] + state["pool"][card_id] -= 1 + owned.add(card_id) + _add_unit_to_roster(player, card_id) + units = [*player.get("board", []), *player.get("bench", [])] + units.sort(key=_unit_power, reverse=True) + player["board"] = units[:STARTER_COUNT] + player["bench"] = units[STARTER_COUNT : STARTER_COUNT + BENCH_LIMIT] + player["shop"] = _draw_shop( + state, + f"{state['run_id']}|lineup|{key}", + ) + + +def _reset_ready(state: dict[str, Any], keys: list[str]) -> None: + ready_keys = set(keys) + state["ready_keys"] = list(keys) + for key in state["join_order"]: + player = state["players"][key] + player["ready"] = bool(player.get("is_ai") or key not in ready_keys) + + +def _ready_waiting(state: dict[str, Any]) -> list[str]: + return [ + key + for key in state.get("ready_keys", []) + if not state["players"][key].get("ready") + ] + + +def _team_power_line(state: dict[str, Any], key: str) -> str: + player = state["players"][key] + power = lineup_power(player) + coach = COACH_BY_ID.get(str(player.get("coach_id", ""))) + coach_text = ( + f"{coach['name']} {coach['level']}级" + if coach is not None + else f"{player.get('coach_level', 1)}级" + ) + return ( + f"{player['display_name']} #{state['season_number']}:金币 {player['gold']};" + f"生命 {player['hp']};教练 {coach_text};战术{player['tactic']};" + f"阵容 {power['power']:.1f};商店{'已锁' if player['shop_locked'] else '未锁'}" + ) + + +def _full_lineup_text(state: dict[str, Any], player: dict[str, Any]) -> str: + if len(player.get("board", [])) != STARTER_COUNT: + drafted = [ + CARD_BY_ID[str(unit["card_id"])]["name"] + for unit in [*player.get("board", []), *player.get("bench", [])] + ] + coach = COACH_BY_ID.get(str(player.get("coach_id", ""))) + return "\n".join( + [ + f"{player['display_name']} #{state['season_number']}:选秀中阵容", + f"教练:{coach['name'] if coach else '未选择'}", + "已选选手:" + ("、".join(drafted) if drafted else "暂无"), + ] + ) + return _lineup_text(state, player) + + +def _shop_status_text(state: dict[str, Any], player: dict[str, Any]) -> str: + if len(player.get("board", [])) != STARTER_COUNT: + return _full_lineup_text(state, player) + return _team_power_line(state, player) + "\n" + "\n".join( + _card_line(index, card_id) + for index, card_id in enumerate(player.get("shop", []), start=1) + ) + "\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 牌、调阵容,再用 /结束回合 进入小组赛。" + ] + 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) + parts.append(text) + stories.append(_story(state, "training_result", text, {"person_key": key})) + state["phase"] = "training_manage" + _prepare_new_shops(state, "training") + _reset_ready(state, _human_keys(state)) + parts.append("训练赛阶段结束。可以 /刷新、/选1~/选6、/上阵、/战术;所有真人发送 /结束回合 后进入小组赛。") + return parts, stories, battles + + +def _prepare_new_shops(state: dict[str, Any], reason: str) -> None: + for key in state["join_order"]: + player = state["players"][key] + if not player.get("shop_locked"): + player["shop"] = _draw_shop( + state, + f"{state['run_id']}|{reason}|{key}|{state.get('match_sequence', 0)}", + ) + + +def _group_schedule(state: dict[str, Any]) -> list[dict[str, Any]]: + matches: list[dict[str, Any]] = [] + for round_index, pairs in enumerate(_round_robin_rounds(list(state["seed_order"])), start=1): + paired = {key for pair in pairs for key in pair} + bye = next((key for key in state["seed_order"] if key not in paired), "") + for match_index, pair in enumerate(pairs, start=1): + matches.append( + { + "stage": "group", + "round": round_index, + "match": match_index, + "pair": [str(pair[0]), str(pair[1])], + "best_of": 3, + "bye": bye, + } + ) + return matches + + +def _match_title(state: dict[str, Any], match: dict[str, Any]) -> str: + key_a, key_b = match["pair"] + player_a = state["players"][key_a] + player_b = state["players"][key_b] + if match["stage"] == "group": + return ( + f"小组赛第 {match['round']} 轮第 {match['match']} 场:" + f"{_team_name(player_a)} vs {_team_name(player_b)}(BO3)" + ) + label = "淘汰赛半决赛" if match["stage"] == "semifinal" else "总决赛" + return f"{label}:{_team_name(player_a)} vs {_team_name(player_b)}(BO5)" + + +def _match_ready_keys(state: dict[str, Any], match: dict[str, Any]) -> list[str]: + if match.get("stage") == "group": + return _human_keys(state) + return [ + key + for key in match.get("pair", []) + if not state["players"][key].get("is_ai") + ] + + +def _set_current_match(state: dict[str, Any], match: dict[str, Any]) -> list[str]: + state["current_match"] = match + state["phase"] = f"{match['stage']}_ready" + keys = _match_ready_keys(state, match) + _reset_ready(state, keys) + parts = [_match_title(state, match)] + if keys: + names = "、".join(state["players"][key]["display_name"] for key in keys) + parts.append(f"{names},请确认阵容;准备完成发送 /结束回合。") + else: + parts.append("本场为 AI 内战,将自动结算。") + return parts + + +def _start_group_parts(state: dict[str, Any]) -> list[str]: + state["group_matches"] = _group_schedule(state) + state["group_match_cursor"] = 0 + state["match_sequence"] = 0 + pair_lines = [] + rounds = _round_robin_rounds(list(state["seed_order"])) + for round_index, pairs in enumerate(rounds, start=1): + rendered = [] + paired = {key for pair in pairs for key in pair} + bye = next((key for key in state["seed_order"] if key not in paired), "") + for match_index, pair in enumerate(pairs, start=1): + rendered.append( + f"第 {round_index} 轮第 {match_index} 场 " + f"{_team_name(state['players'][pair[0]])} vs {_team_name(state['players'][pair[1]])}" + ) + if bye: + rendered.append(f"{state['players'][bye]['display_name']} 轮空") + pair_lines.append(";".join(rendered)) + parts = [ + f"欢迎来到{state['event_title']}正赛小组赛!小组赛采用单循环 BO3。", + "本阶段对阵:\n" + "\n".join(pair_lines), + ] + if state["group_matches"]: + parts.extend(_set_current_match(state, state["group_matches"][0])) + return parts + + +def _key_player_name(state: dict[str, Any], key: str, offset: int) -> str: + board = state["players"][key].get("board", []) + if not board: + return state["players"][key]["display_name"] + unit = board[offset % len(board)] + return str(CARD_BY_ID[str(unit["card_id"])]["name"]) + + +def _series_report_parts( + state: dict[str, Any], + match: dict[str, Any], + 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]) + parts = [ + f"{_match_title(state, match)} 开始。{team_a} 战力 {result['power_a']:.1f},{team_b} 战力 {result['power_b']:.1f}。" + ] + 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"{_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( + f"系列赛结束,{_team_name(state['players'][winner_key])} 以 {result['score']} 击败 " + f"{_team_name(state['players'][loser_key])},获得 {reward} 金币。" + "可用于 /刷新 和 /选1~/选6。" + ) + return parts[:5] + + +def _settle_current_match( + state: dict[str, Any], + event_id: str, +) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]: + match = dict(state["current_match"]) + key_a, key_b = match["pair"] + state["match_sequence"] = int(state.get("match_sequence", 0)) + 1 + result = _battle( + state, + key_a, + key_b, + f"{state['run_id']}|{match['stage']}|{state['match_sequence']}|{event_id}", + best_of=int(match["best_of"]), + ) + _apply_match_result( + state, + key_a, + key_b, + result, + group_stage=match["stage"] == "group", + ) + battle_type = "group_bo3" if match["stage"] == "group" else f"{match['stage']}_bo5" + battle = _battle_record( + state, + battle_type=battle_type, + player_a=key_a, + player_b=key_b, + result=result, + ) + parts = _series_report_parts(state, match, result) + story = _story(state, f"{match['stage']}_result", "\n".join(parts), result) + return parts, [story], [battle] + + +def _standings_part(state: dict[str, Any]) -> str: + lines = ["小组赛排名:"] + for index, key in enumerate(_rankings(state), start=1): + player = state["players"][key] + total = max(1, int(player["wins"]) + int(player["losses"])) + rate = int(player["wins"]) / total + lines.append( + f"{index}. {_team_name(player)} {player['wins']}胜{player['losses']}负 胜率{rate:.0%}" + ) + return "\n".join(lines) + + +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 + 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 + player["gold"] = int(player["gold"]) + reward + text = ( + f"{_team_name(player)} {'完成' if won else '勉强应付'}{event_kind}:" + f"表现 {power:.1f}/{target},获得 {reward} 金币。" + ) + parts.append(text) + stories.append(_story(state, "special_event", text, {"person_key": key})) + state["special_event_done"] = True + state["phase"] = "special_manage" + _prepare_new_shops(state, event_kind) + _reset_ready(state, _human_keys(state)) + parts.append("挑战结束。可以继续 D 牌和调整阵容;所有真人发送 /结束回合 后继续小组赛。") + return parts, stories + + +def _playoff_matches_from_rankings(state: dict[str, Any]) -> list[dict[str, Any]]: + ranking = _rankings(state) + if len(ranking) >= 4: + 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}, + ] + finalists = ranking[:2] + state["finalists"] = finalists + return [ + {"stage": "final", "round": 1, "match": 1, "pair": finalists, "best_of": 5} + ] + + +def _playoff_pairings_part(state: dict[str, Any]) -> str: + lines = ["淘汰赛对阵:"] + for match in state.get("playoff_matches", []): + key_a, key_b = match["pair"] + seed_a = state["players"][key_a].get("seed", "?") + seed_b = state["players"][key_b].get("seed", "?") + label = "半决赛" if match["stage"] == "semifinal" else "总决赛" + lines.append( + f"{label}:{_team_name(state['players'][key_a])}({seed_a}) vs {_team_name(state['players'][key_b])}({seed_b})" + ) + return "\n".join(lines) + + +def _finish_tournament_parts(state: dict[str, Any]) -> list[str]: + champion_key = str(state["champion_key"]) + champion = state["players"][champion_key] + power = lineup_power(champion) + state["status"] = "completed" + state["phase"] = "completed" + return [ + f"{state['event_title']} 全部比赛结束!", + ( + f"冠军:{_team_name(champion)}。最终阵容战力 {power['power']:.1f}," + f"小组赛 {champion['wins']}胜{champion['losses']}负。" + ), + "颁奖:MVP 给到本届决赛关键先生;所有赛报已经写入自走棋档案,可用 /自走棋档案 查看。", + ] + + class CsMajorAutochess: """Command facade; persistence remains behind the Kernel Ledger service.""" @@ -1582,15 +2285,18 @@ class CsMajorAutochess: "CS自走棋:/自走棋开始 创建报名;/自走棋报名;主持人 /自走棋开赛。\n" "两名或三名真人报名后,主持人可用 /自走棋加AI战队," "分别开启 2 人+2 AI 或 3 人+1 AI 测试模式。\n" - "每人固定 5 名首发 + 1 名教练,开赛即发完整首发;每轮公开 6 人商店。" - "/选1~/选6 购买;/阵容;/上阵 备战编号 [首发位];/出售 备战编号。\n" - "教练只提供全队 +2%~+10% 加成;/升级教练;/刷新;/锁定。" + "开赛后三轮选秀:第一轮固定 10 名教练,第二、三轮选手。" + "选秀完成后每队 5 名首发、1 名教练、3 个备战席。\n" + "/选1~/选6;/阵容;/刷新;/锁定;/上阵 备战编号 [首发位];/出售 备战编号。" "/战术 火力|稳定|残局,稳定克火力、火力克残局、残局克稳定。\n" - "小组赛 BO3,半决赛/决赛 BO5;单图 MR15,15-15 进入加时。" - "玩家 /结束回合,主持人 /自走棋结算;/赛程;/自走棋档案 [页码]。" + "三场训练赛后进入 BO3 单循环小组赛;半决赛/决赛 BO5。" + "所有需要推进的阶段由相关真人发送 /结束回合,满足条件后自动进入下一步。" ) def handle(self, row: dict[str, Any]) -> str: + return "\n".join(self.handle_parts(row)) + + def handle_parts(self, row: dict[str, Any]) -> list[str]: action_kind, arguments = parse_autochess_action(str(row["question"])) group = str(row["group_fingerprint"]) sender = str(row["sender"]) @@ -1610,24 +2316,21 @@ class CsMajorAutochess: arguments, event_id, ) - new_state = result.get("state") - response = result.get("response") - if isinstance(new_state, dict) and isinstance(response, str): - prefix = ( - f"{person['display_name']} " - f"#{new_state['season_number']}:" + parts = result.pop("response_parts", None) + if isinstance(parts, list): + result["response"] = _pack_response_parts( + [str(part) for part in parts] ) - if not response.startswith(prefix): - result["response"] = prefix + response return result - return self.ledger.apply_autochess_action( + packed = self.ledger.apply_autochess_action( group, sender, event_id, command, execute, ) + return _unpack_response_parts(packed) def _execute( self, @@ -1640,28 +2343,46 @@ class CsMajorAutochess: ) -> dict[str, Any]: person_key = str(person["person_key"]) display_name = str(person["display_name"]) + + def respond( + current: dict[str, Any] | None, + parts: list[str] | str, + *, + stories: list[dict[str, Any]] | None = None, + battles: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + normalized = [parts] if isinstance(parts, str) else parts + normalized = [str(part) for part in normalized if str(part).strip()] + if not normalized: + normalized = ["没有可发送内容"] + return { + "state": current, + "response": normalized[0], + "response_parts": normalized, + "stories": stories or [], + "battles": battles or [], + } + if action == "unknown": - return _result(state, self.rules()) + return respond(state, self.rules()) if action == "create": if state is not None and state.get("status") in {"registration", "active"}: - return _result( + return respond( state, f"{state['event_title']} 已在进行中;发送 /自走棋状态 查看", ) season = next_season state = { - "schema_version": 1, + "schema_version": 2, "run_id": f"major_{season:04d}", "season_number": season, "event_title": f"第 {season} 届 IEM 盐田 Major", "status": "registration", "host_person_key": person_key, "host_display_name": display_name, - "round_number": 0, + "round_number": 1, "phase": "registration", - "players": { - person_key: _participant(person_key, display_name) - }, + "players": {person_key: _participant(person_key, display_name)}, "join_order": [person_key], "pool": { card["id"]: COST_POOL_COPIES[int(card["cost"])] @@ -1673,8 +2394,7 @@ class CsMajorAutochess: "group_results": [], "ai_test_mode": False, "ai_test_variant": "", - "preparation_order": [], - "preparation_cursor": 0, + "ready_keys": [], "qualifiers": [], "finalists": [], "champion_key": "", @@ -1682,244 +2402,154 @@ class CsMajorAutochess: "battles": [], "story_sequence": 0, "battle_sequence": 0, + "special_event_done": False, } - text = _host_text( + text = ( f"{state['event_title']} 开放报名;主持人 {display_name} 已自动加入。" - "支持 2~8 人;发送 /自走棋报名,主持人发送 /自走棋开赛" + "支持 2~8 人;发送 /自走棋报名,主持人发送 /自走棋开赛。" ) - story = _story( - state, - "registration_open", - text, - {"host_person_key": person_key}, - ) - return _result(state, f"{display_name} #{season}:{text}", stories=[story]) + story = _story(state, "registration_open", text, {"host_person_key": person_key}) + return respond(state, text, stories=[story]) if state is None: - return _result(None, "当前没有自走棋赛事;发送 /自走棋开始") + return respond(None, "当前没有自走棋赛事;发送 /自走棋开始") if action == "join": if state["status"] != "registration": - return _result(state, "本届赛事已停止报名") + return respond(state, "本届赛事已停止报名") if state.get("ai_test_mode"): - return _result( - state, - _host_text( - "AI 测试模式已经锁定参赛名单,不能再加入真人。" - ), - ) + return respond(state, "AI 测试模式已经锁定参赛名单,不能再加入真人。") if person_key in state["players"]: - return _result( - state, - f"{_player_prefix(state, state['players'][person_key])}你已报名", - ) + return respond(state, f"{display_name} 已经报名。") if len(state["join_order"]) >= MAX_PLAYERS: - return _result(state, f"本届赛事已满 {MAX_PLAYERS} 人") + return respond(state, f"本届赛事已满 {MAX_PLAYERS} 人") state["players"][person_key] = _participant(person_key, display_name) state["join_order"].append(person_key) - text = _host_text( - f"{display_name} 加入赛事,当前 {len(state['join_order'])}/{MAX_PLAYERS} 人。" - ) - story = _story( - state, - "player_joined", - text, - {"person_key": person_key}, - ) - return _result(state, f"{display_name} #{state['season_number']}:{text}", stories=[story]) + text = f"{display_name} 加入赛事,当前 {len(state['join_order'])}/{MAX_PLAYERS} 人。" + story = _story(state, "player_joined", text, {"person_key": person_key}) + return respond(state, text, stories=[story]) if action == "add_ai": if state["status"] != "registration": - return _result(state, "AI 战队只能在报名阶段加入") + return respond(state, "AI 战队只能在报名阶段加入") if person_key != state["host_person_key"]: - return _result( - state, - f"只有主持人 {state['host_display_name']} 可以加入 AI 战队", - ) - if any( - player.get("is_ai") - for player in state["players"].values() - ): - return _result(state, "AI 测试战队已经加入") + return respond(state, f"只有主持人 {state['host_display_name']} 可以加入 AI 战队") + if any(player.get("is_ai") for player in state["players"].values()): + return respond(state, "AI 测试战队已经加入") human_keys = [ - key - for key in state["join_order"] + key for key in state["join_order"] if not state["players"][key].get("is_ai") ] if len(human_keys) not in {2, 3}: - return _result( - state, - "AI 测试模式要求先有且仅有 2 名或 3 名真人报名", - ) + return respond(state, "AI 测试模式要求先有且仅有 2 名或 3 名真人报名") if len(human_keys) == 2: ai_teams = ( (AI_PERSON_KEY, "AI 战队 A"), (AI_SECOND_PERSON_KEY, AI_SECOND_DISPLAY_NAME), ) variant = "two_plus_two" - mode_label = "2 人+2 AI" - join_text = ( - "AI 战队 A、AI 战队 B 已作为第 3、4 种子加入测试赛;" - ) + text = "AI 战队 A、AI 战队 B 已作为第 3、4 种子加入;2 人+2 AI 测试赛已锁定。" else: ai_teams = ((AI_PERSON_KEY, AI_DISPLAY_NAME),) variant = "three_plus_one" - mode_label = "3 人+1 AI" - join_text = "AI 战队已作为第 4 种子加入测试赛;" + text = "AI 战队已作为第 4 种子加入;3 人+1 AI 测试赛已锁定。" for ai_key, ai_name in ai_teams: - state["players"][ai_key] = _participant( - ai_key, - ai_name, - is_ai=True, - ) + state["players"][ai_key] = _participant(ai_key, ai_name, is_ai=True) state["join_order"].append(ai_key) state["ai_test_mode"] = True state["ai_test_variant"] = variant - text = _host_text( - f"{join_text}{mode_label} 编制已锁定;" - "开赛后固定进行 1v4、2v3 半决赛,AI 将自动运营。" - ) - story = _story( - state, - "ai_team_joined", - text, - { - "person_keys": [ - ai_key for ai_key, _ in ai_teams - ], - "seeds": list( - range( - len(human_keys) + 1, - len(human_keys) + len(ai_teams) + 1, - ) - ), - "variant": variant, - "test_mode": True, - }, - ) - return _result(state, text, stories=[story]) + story = _story(state, "ai_team_joined", text, {"variant": variant}) + return respond(state, text, stories=[story]) if action == "launch": if state["status"] != "registration": - return _result(state, "本届赛事已经开赛或结束") + return respond(state, "本届赛事已经开赛或结束") if person_key != state["host_person_key"]: - return _result( - state, - f"只有主持人 {state['host_display_name']} 可以开赛", - ) + return respond(state, f"只有主持人 {state['host_display_name']} 可以开赛") if len(state["join_order"]) < MIN_PLAYERS: - return _result(state, f"至少需要 {MIN_PLAYERS} 人才能开赛") + return respond(state, f"至少需要 {MIN_PLAYERS} 人才能开赛") state["status"] = "active" - state["round_number"] = 1 state["seed_order"] = list(state["join_order"]) state["round_plan"] = build_round_plan(list(state["join_order"])) - state["phase"] = str(_current_stage(state)["phase"]) - for key in state["join_order"]: - _assign_starters(state, state["players"][key]) for seed, key in enumerate(state["seed_order"], start=1): state["players"][key]["seed"] = seed - _reset_preparation(state) - public_shop = _draw_shop(state, f"{state['run_id']}|round|1|public") - state["public_shop"] = public_shop - for key in state["join_order"]: - state["players"][key]["shop"] = list(public_shop) + _start_draft_round(state, 1) names = "、".join( state["players"][key]["display_name"] for key in state["join_order"] ) - seeds = "、".join( - f"{state['players'][key]['seed']}号" - f"{state['players'][key]['display_name']}" - for key in state["seed_order"] - ) - text = _host_text( - f"欢迎大家来到{state['event_title']}!参赛者:{names}。" - f"种子顺序:{seeds}。{_format_description(state)};" - "小组赛 BO3,淘汰赛 BO5,单图采用 MR15," - "常规时间 15-15 进入加时。" - "每队已获得完整 5 人首发与常驻教练;" - f"共 {len(state['round_plan'])} 回合,第 1 回合训练赛开始。" + mode = ( + "2 人+2 AI 测试赛" + if state.get("ai_test_variant") == "two_plus_two" + else "3 人+1 AI 测试赛" + if state.get("ai_test_variant") == "three_plus_one" + else f"{len(state['join_order'])} 队赛" ) + parts = [ + f"欢迎大家来到{state['event_title']}!", + f"参赛者:{names}。", + ( + f"种子顺序:{_seed_lines(state)}。本届采用 {mode}:" + "先进行三轮选秀,随后训练赛、小组赛 BO3 单循环," + "淘汰赛 BO5;单图 MR15,常规时间 15-15 进入加时。" + ), + _draft_board_part(state), + ] + parts.extend(_auto_advance_draft(state)) + parts.extend(_finish_draft_round_if_needed(state)) + if state.get("phase", "").startswith("draft_"): + parts.append(_draft_prompt_part(state)) story = _story( state, "tournament_started", - text, - { - "players": list(state["join_order"]), - "public_shop": public_shop, - "round_plan": list(state["round_plan"]), - }, + "\n".join(parts), + {"players": list(state["join_order"])}, ) - preparation_story = _story( - state, - "preparation_prompt", - _preparation_prompt(state), - { - "person_key": _current_preparer(state), - "order": list(state["preparation_order"]), - }, - ) - response = ( - text - + "\n" - + _shop_text(state, state["players"][person_key]) - + "\n" - + _preparation_prompt(state) - ) - return _result( - state, - response, - stories=[story, preparation_story], - ) - player = state["players"].get(person_key) + return respond(state, parts, stories=[story]) + if action == "archive": page_values = _parse_indices(arguments) - return _result(state, _archive_text(state, page_values[0] if page_values else 1)) + return respond(state, _archive_text(state, page_values[0] if page_values else 1)) if action == "schedule": - return _result(state, _schedule_text(state)) + return respond(state, _schedule_text(state)) if state["status"] == "registration": - return _result(state, _schedule_text(state)) + return respond(state, _schedule_text(state)) if state["status"] in {"completed", "cancelled"}: if action in {"status", "lineup"}: - return _result(state, _schedule_text(state)) - return _result( - state, - f"{state['event_title']} 已{('结束' if state['status'] == 'completed' else '取消')};" - "发送 /自走棋档案 查看记录,或 /自走棋开始 创建下一届", - ) + return respond(state, _schedule_text(state)) + return respond(state, f"{state['event_title']} 已结束;发送 /自走棋开始 创建下一届") + player = state["players"].get(person_key) if player is None: - return _result(state, "你没有参加本届赛事,只能查看 /自走棋赛程") + return respond(state, "你没有参加本届赛事,只能查看 /自走棋赛程") if action == "status": - return _result( - state, - _shop_text(state, player) + "\n" + _preparation_prompt(state), - ) + return respond(state, _shop_status_text(state, player)) if action == "lineup": - return _result(state, _lineup_text(state, player)) - preparation_actions = { - "buy", - "refresh", - "lock", - "tactic", - "coach", - "field", - "bench", - "sell", - "ready", - } - if action in preparation_actions: - current_preparer = _current_preparer(state) - if current_preparer is None: - return _result( + return respond(state, _full_lineup_text(state, player)) + + if state.get("phase", "").startswith("draft_"): + if action != "buy": + return respond(state, _draft_prompt_part(state)) + draft = state["draft"] + order = list(draft["order"]) + cursor = int(draft.get("cursor", 0)) + if cursor >= len(order): + return respond(state, "本轮选秀已经完成,请稍等主持进入下一轮。") + current_key = str(order[cursor]) + if current_key != person_key: + return respond( state, - _host_text( - "本回合全员已经准备完成,请等待主持人结算。" - ), - ) - if person_key != current_preparer: - current_name = state["players"][current_preparer]["display_name"] - return _result( - state, - _host_text( - f"当前轮到 {current_name} 做赛前准备;" - f"{display_name} 请等待交棒。" - ), + f"当前选择的是 @{state['players'][current_key]['display_name']},请等待。", ) + values = _parse_indices(arguments) + if len(values) != 1: + return respond(state, "用法:/选1~/选10(教练)或 /选1~/选6(选手)") + try: + text = _apply_draft_choice(state, person_key, values[0]) + except ValueError as error: + return respond(state, str(error)) + parts = [text] + parts.extend(_auto_advance_draft(state)) + parts.extend(_finish_draft_round_if_needed(state)) + if state.get("phase", "").startswith("draft_"): + parts.append(_draft_prompt_part(state)) + story = _story(state, "draft_pick", text, {"person_key": person_key}) + return respond(state, parts, stories=[story]) + if action == "tactic": value = re.sub(r"\s+", "", arguments.casefold()) aliases = { @@ -1931,276 +2561,206 @@ class CsMajorAutochess: "clutch": "残局", } if not value: - return _result( - state, - f"当前战术 {player['tactic']};用法:/战术 火力|稳定|残局", - ) + return respond(state, f"当前战术 {player['tactic']};用法:/战术 火力|稳定|残局") tactic = aliases.get(value) if tactic is None: - return _result(state, "战术只能选择:火力、稳定、残局") + return respond(state, "战术只能选择:火力、稳定、残局") player["tactic"] = tactic - text = ( - f"{display_name} 将本回合战术设为 {tactic};" - "克制环为稳定>火力>残局>稳定。" - ) - story = _story( - state, - "tactic_selected", - text, - {"person_key": person_key, "tactic": tactic}, - ) - return _result(state, text, stories=[story]) + text = f"{display_name} 将战术设为 {tactic}。" + story = _story(state, "tactic_selected", text, {"person_key": person_key, "tactic": tactic}) + return respond(state, text, stories=[story]) + if action == "coach": + return respond(state, "教练现在由第一轮选秀决定,本届不能用金币升级教练。") if action == "buy": values = _parse_indices(arguments) if len(values) != 1 or not 1 <= values[0] <= SHOP_SIZE: - return _result(state, "用法:/选1 到 /选6") + return respond(state, "用法:/选1 到 /选6") slot = values[0] - 1 - card_id = player["shop"][slot] if slot < len(player["shop"]) else None + card_id = player["shop"][slot] if slot < len(player.get("shop", [])) else None if card_id is None: - return _result(state, "这个商店位置已经购买或没有候选") + return respond(state, "这个商店位置已经购买或没有候选") card = CARD_BY_ID[str(card_id)] if int(player["gold"]) < int(card["cost"]): - return _result( - state, - f"金币不足:{card['name']} 需要 {card['cost']},当前 {player['gold']}", - ) + return respond(state, f"金币不足:{card['name']} 需要 {card['cost']},当前 {player['gold']}") existing = any( unit["card_id"] == card_id for unit in [*player["board"], *player["bench"]] ) if not existing and len(player["bench"]) >= BENCH_LIMIT: - return _result(state, f"备战席已满 {BENCH_LIMIT} 格,请先出售") + return respond(state, f"备战席已满 {BENCH_LIMIT} 格,请先出售") if int(state["pool"].get(card_id, 0)) <= 0: - return _result(state, "共享卡池中的这名选手已被拿完,请刷新") + return respond(state, "共享卡池中的这名选手已被拿完,请刷新") player["gold"] -= int(card["cost"]) state["pool"][card_id] -= 1 star, upgraded = _merge_purchase(player, str(card_id)) player["shop"][slot] = None text = ( - f"{display_name} 选中 {card['name']},花费 {card['cost']} 金币;" + f"{display_name} 签下 {card['name']},花费 {card['cost']} 金币;" f"当前 {star} 星,余额 {player['gold']}。" ) if upgraded: text += f"{card['name']} 完成升星。" - story = _story( - state, - "shop_purchase", - text, - { - "person_key": person_key, - "card_id": card_id, - "star": star, - "upgraded": upgraded, - "gold": player["gold"], - }, - ) - return _result(state, f"{_player_prefix(state, player)}{text}", stories=[story]) + story = _story(state, "shop_purchase", text, {"person_key": person_key, "card_id": card_id}) + return respond(state, text, stories=[story]) if action == "refresh": if int(player["gold"]) < REFRESH_COST: - return _result(state, f"刷新需要 {REFRESH_COST} 金币,当前 {player['gold']}") + return respond(state, f"刷新需要 {REFRESH_COST} 金币,当前 {player['gold']}") player["gold"] -= REFRESH_COST player["shop"] = _draw_shop( state, - f"{state['run_id']}|{state['round_number']}|{event_id}|{person_key}|refresh", + f"{state['run_id']}|{state.get('phase')}|{event_id}|{person_key}|refresh", ) - text = f"{display_name} 支付 {REFRESH_COST} 金币刷新商店。" - story = _story( - state, - "shop_refresh", - text, - {"person_key": person_key, "shop": list(player["shop"])}, - ) - return _result(state, _shop_text(state, player), stories=[story]) + return respond(state, _shop_status_text(state, player)) if action == "lock": player["shop_locked"] = not bool(player["shop_locked"]) - text = ( - f"{display_name} {'锁定' if player['shop_locked'] else '解锁'}了商店;" - + ("下一回合保留当前 6 人。" if player["shop_locked"] else "下一回合将自动换新。") - ) - story = _story( - state, - "shop_lock", - text, - {"person_key": person_key, "locked": player["shop_locked"]}, - ) - return _result(state, f"{_player_prefix(state, player)}{text}", stories=[story]) - if action == "coach": - level = int(player["coach_level"]) - if level >= 5: - return _result(state, "教练已满 5 级,全队战力加成 +10%") - cost = COACH_UPGRADE_COSTS[level] - if int(player["gold"]) < cost: - return _result(state, f"教练升级需要 {cost} 金币,当前 {player['gold']}") - player["gold"] -= cost - player["coach_level"] = level + 1 - bonus = COACH_BONUSES[level + 1] - text = ( - f"{display_name} 将教练升到 {level + 1} 级;" - f"全队加成 +{bonus}%,余额 {player['gold']}。" - ) - story = _story( - state, - "coach_upgrade", - text, - { - "person_key": person_key, - "coach_level": level + 1, - "coach_bonus": bonus, - }, - ) - return _result(state, f"{_player_prefix(state, player)}{text}", stories=[story]) - if action == "field": + return respond(state, f"{display_name} {'锁定' if player['shop_locked'] else '解锁'}了商店。") + if action in {"field", "bench"}: values = _parse_indices(arguments) if not values: - return _result(state, "用法:/上阵 备战编号 [首发位],例如 /上阵 6 2") + return respond(state, "用法:/上阵 备战编号 [首发位],例如 /上阵 6 2") found = _find_unit(player, values[0]) if found is None or found[0] != "备战": - return _result(state, "第一个编号必须是 /阵容 中的备战席编号") + return respond(state, "第一个编号必须是 /阵容 中的备战席编号") bench_index = found[1] board_index = ( values[1] - 1 if len(values) >= 2 and 1 <= values[1] <= STARTER_COUNT - else min( - range(STARTER_COUNT), - key=lambda index: _unit_power(player["board"][index]), - ) + else min(range(STARTER_COUNT), key=lambda index: _unit_power(player["board"][index])) ) incoming = player["bench"][bench_index] outgoing = player["board"][board_index] player["board"][board_index] = incoming player["bench"][bench_index] = outgoing - in_name = CARD_BY_ID[str(incoming["card_id"])]["name"] - out_name = CARD_BY_ID[str(outgoing["card_id"])]["name"] text = ( - f"{display_name} 用 {in_name} 替换首发 {board_index + 1} 的 {out_name};" - "首发仍为完整 5 人。" - ) - story = _story( - state, - "lineup_change", - text, - { - "person_key": person_key, - "incoming": incoming["card_id"], - "outgoing": outgoing["card_id"], - "board_slot": board_index + 1, - }, - ) - return _result(state, f"{_player_prefix(state, player)}{text}", stories=[story]) - if action == "bench": - values = _parse_indices(arguments) - if len(values) < 2: - return _result( - state, - "固定五人阵容不能留空;用法:/下阵 首发位 备战编号,例如 /下阵 2 6", - ) - board_index = values[0] - 1 - found = _find_unit(player, values[1]) - if not 0 <= board_index < STARTER_COUNT or found is None or found[0] != "备战": - return _result(state, "首发位必须为 1~5,替补必须是 /阵容 中的备战编号") - bench_index = found[1] - incoming = player["bench"][bench_index] - outgoing = player["board"][board_index] - player["board"][board_index] = incoming - player["bench"][bench_index] = outgoing - text = ( - f"{display_name} 调整首发:{CARD_BY_ID[str(incoming['card_id'])]['name']} " - f"替换 {CARD_BY_ID[str(outgoing['card_id'])]['name']}。" + f"{display_name} 用 {CARD_BY_ID[str(incoming['card_id'])]['name']} " + f"替换首发 {board_index + 1} 的 {CARD_BY_ID[str(outgoing['card_id'])]['name']}。" ) story = _story(state, "lineup_change", text, {"person_key": person_key}) - return _result(state, f"{_player_prefix(state, player)}{text}", stories=[story]) + return respond(state, text, stories=[story]) if action == "sell": values = _parse_indices(arguments) if len(values) != 1: - return _result(state, "用法:/出售 备战编号") + return respond(state, "用法:/出售 备战编号") found = _find_unit(player, values[0]) if found is None: - return _result(state, "没有这个阵容编号") + return respond(state, "没有这个阵容编号") if found[0] == "首发": - return _result(state, "首发必须保持 5 人;先用 /上阵 备战编号 首发位 完成替换") + return respond(state, "首发必须保持 5 人;先用 /上阵 备战编号 首发位 完成替换") unit = player["bench"].pop(found[1]) card = CARD_BY_ID[str(unit["card_id"])] refund = int(card["cost"]) * int(unit["copies"]) player["gold"] += refund state["pool"][card["id"]] += int(unit["copies"]) - text = ( - f"{display_name} 出售 {card['name']} {unit['star']} 星×{unit['copies']}," - f"返还 {refund} 金币;余额 {player['gold']}。" - ) - story = _story( - state, - "unit_sold", - text, - { - "person_key": person_key, - "card_id": card["id"], - "copies": unit["copies"], - "refund": refund, - }, - ) - return _result(state, f"{_player_prefix(state, player)}{text}", stories=[story]) + return respond(state, f"{display_name} 出售 {card['name']},返还 {refund} 金币。") + if action == "ready": + if person_key not in state.get("ready_keys", []): + if state.get("phase") in {"semifinal_ready", "final_ready"}: + return respond(state, f"{display_name} 已不在当前淘汰赛对阵中,不需要准备。") + return respond(state, "当前阶段不需要你确认 /结束回合。") player["ready"] = True - state["preparation_cursor"] = ( - int(state["preparation_cursor"]) + 1 - ) - waiting = [ - state["players"][key]["display_name"] - for key in state["preparation_order"][ - int(state["preparation_cursor"]) : - ] - ] - next_preparer = _current_preparer(state) - if next_preparer is None: - text = _host_text( - f"{display_name} 准备完成!全员准备完毕," - "主持人可以发送 /自走棋结算。" - ) - else: - next_name = state["players"][next_preparer]["display_name"] - text = _host_text( - f"{display_name} 准备完成!到 {next_name} 做准备了。" - f"{next_name},请做好赛前准备;完成后发送 /结束回合。" - ) - story = _story( - state, - "round_ready", - text, - {"person_key": person_key, "waiting": waiting}, - ) - return _result(state, text, stories=[story]) - if action in {"settle", "force_settle"}: - if person_key != state["host_person_key"]: - return _result( - state, - f"只有主持人 {state['host_display_name']} 可以结算", - ) - waiting = [ - state["players"][key]["display_name"] - for key in state["join_order"] + waiting = _ready_waiting(state) + if waiting: + names = "、".join(state["players"][key]["display_name"] for key in waiting) + return respond(state, f"{display_name} 准备完成;仍在等待:{names}。") + + 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) + return respond(state, parts, stories=stories, battles=battles) + if phase == "training_manage": + parts = _start_group_parts(state) + return respond(state, parts) + if phase == "group_ready": + parts, stories, battles = _settle_current_match(state, event_id) + state["group_match_cursor"] = int(state.get("group_match_cursor", 0)) + 1 + state["last_completed_group_round"] = int(state["current_match"]["round"]) + state["phase"] = "group_manage" + _prepare_new_shops(state, "group") + _reset_ready(state, _human_keys(state)) + parts.append("本场结束。可以 D 牌、调阵容;所有真人发送 /结束回合 后进入下一场。") + return respond(state, parts, stories=stories, battles=battles) + if phase == "group_manage": + cursor = int(state.get("group_match_cursor", 0)) + matches = list(state.get("group_matches", [])) + last_round = int(state.get("last_completed_group_round", 0)) + next_round = int(matches[cursor]["round"]) if cursor < len(matches) else 999 if ( - not state["players"][key]["ready"] - and not state["players"][key].get("is_ai") + not state.get("special_event_done") + and last_round in {1, 2} + and next_round > last_round + ): + parts, stories = _special_event_parts(state, event_id) + return respond(state, parts, stories=stories) + if cursor < len(matches): + parts = _set_current_match(state, matches[cursor]) + return respond(state, parts) + state["playoff_matches"] = _playoff_matches_from_rankings(state) + state["playoff_cursor"] = 0 + parts = [_standings_part(state), _playoff_pairings_part(state)] + if state["playoff_matches"]: + parts.extend(_set_current_match(state, state["playoff_matches"][0])) + return respond(state, parts) + if phase == "special_manage": + cursor = int(state.get("group_match_cursor", 0)) + matches = list(state.get("group_matches", [])) + if cursor < len(matches): + return respond(state, _set_current_match(state, matches[cursor])) + return respond(state, "挑战结束,但没有后续小组赛对阵。") + if phase in {"semifinal_ready", "final_ready"}: + parts, stories, battles = _settle_current_match(state, event_id) + match = dict(state["current_match"]) + winner_key = str( + battles[0]["result"]["winner_key"] + if battles else state["current_match"]["pair"][0] ) - ] - if waiting and action != "force_settle": - return _result( - state, - _host_text( - f"仍在等待:{'、'.join(waiting)};" - "请按准备顺序完成,主持人也可用 /自走棋强制结算。" - ), - ) - lines, stories, battles = _settle_round(state, event_id) - return _result(state, "\n".join(lines), stories=stories, battles=battles) + if match["stage"] == "final": + state["champion_key"] = winner_key + parts.extend(_finish_tournament_parts(state)) + return respond(state, parts, stories=stories, battles=battles) + winners = list(state.get("semifinal_winners", [])) + winners.append(winner_key) + state["semifinal_winners"] = winners + state["playoff_cursor"] = int(state.get("playoff_cursor", 0)) + 1 + cursor = int(state["playoff_cursor"]) + playoff_matches = list(state.get("playoff_matches", [])) + if cursor < len(playoff_matches): + parts.extend(_set_current_match(state, playoff_matches[cursor])) + return respond(state, parts, stories=stories, battles=battles) + final_match = { + "stage": "final", + "round": 1, + "match": 1, + "pair": winners[:2], + "best_of": 5, + } + state["finalists"] = winners[:2] + parts.append("半决赛全部结束,胜者进入 BO5 总决赛。") + parts.extend(_set_current_match(state, final_match)) + if not state.get("ready_keys"): + final_parts, final_stories, final_battles = _settle_current_match( + state, + event_id + "-auto-final", + ) + final_winner = str(final_battles[0]["result"]["winner_key"]) + state["champion_key"] = final_winner + final_parts.extend(_finish_tournament_parts(state)) + parts.extend(final_parts) + stories.extend(final_stories) + battles.extend(final_battles) + return respond(state, parts, stories=stories, battles=battles) + return respond(state, "当前阶段已经准备完成,请等待下一步。") + + if action in {"settle", "force_settle"}: + return respond(state, "0.6.6 起赛事由 /结束回合 自动推进,不再需要主持人手动结算。") if action == "cancel": if person_key != state["host_person_key"]: - return _result( - state, - f"只有主持人 {state['host_display_name']} 可以取消赛事", - ) + return respond(state, f"只有主持人 {state['host_display_name']} 可以取消赛事") state["status"] = "cancelled" + state["phase"] = "cancelled" text = f"{state['event_title']} 已由主持人取消;已完成剧情仍保留在档案。" story = _story(state, "tournament_cancelled", text) - return _result(state, text, stories=[story]) - return _result(state, self.rules()) + return respond(state, text, stories=[story]) + return respond(state, self.rules()) 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 d4c992e..4922f81 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 @@ -25,6 +25,7 @@ MAX_RPS_PLAYERS = 8 BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai") DICE_PATTERN = re.compile(r"^(?:(\d{1,2})[dD](\d{1,3}))?$") RANDOM_NUMBER_PATTERN = re.compile(r"^(-?\d{1,6})\s+(-?\d{1,6})$") +MULTI_REPLY_MARKER = "\x1eJWEBOT_MULTI_REPLY_V1\x1e" class LocalCommandError(RuntimeError): @@ -53,6 +54,36 @@ def deterministic_int(seed: str, lower: int, upper: int) -> int: return lower + value % (upper - lower + 1) +def pack_reply_parts(parts: list[str] | tuple[str, ...]) -> str: + cleaned = [str(part).strip() for part in parts if str(part).strip()] + if not cleaned: + raise LocalCommandError("本地命令没有可发送回复") + if len(cleaned) == 1: + return cleaned[0] + return MULTI_REPLY_MARKER + json.dumps( + cleaned, + ensure_ascii=False, + separators=(",", ":"), + ) + + +def unpack_reply_parts(value: object) -> list[str] | None: + text = str(value or "") + if not text.startswith(MULTI_REPLY_MARKER): + return None + try: + payload = json.loads(text[len(MULTI_REPLY_MARKER) :]) + except json.JSONDecodeError: + return None + if ( + not isinstance(payload, list) + or not payload + or any(not isinstance(item, str) or not item.strip() for item in payload) + ): + return None + return [item.strip() for item in payload] + + def local_command_parts(question: str) -> tuple[str, str]: normalized = question.lstrip() if not normalized.startswith("/"): @@ -131,7 +162,11 @@ class LocalCommandEngine: }: return self.reply_cleaner(self.players.handle(row, kind, arguments)) if kind == "cs_autochess": - return self.reply_cleaner(self.autochess.handle(row)) + parts = [ + self.reply_cleaner(part) + for part in self.autochess.handle_parts(row) + ] + return pack_reply_parts(parts) if kind == "dice": return self.reply_cleaner(self.dice(row, arguments)) if kind == "dice_challenge": diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/runtime.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/runtime.py index 76e962c..c9f78ec 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/runtime.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/runtime.py @@ -14,6 +14,8 @@ from .local_commands import ( LocalCommandEngine, local_command_parts, utc_now, + pack_reply_parts, + unpack_reply_parts, ) @@ -74,7 +76,11 @@ class LocalServiceBroker: "question": f"{command} {arguments}".rstrip(), "created_at": str(event.get("observed_at") or utc_now()), } - return {"text": self.engine.handle(row, event)} + text = self.engine.handle(row, event) + parts = unpack_reply_parts(text) + if parts is not None: + return {"text": parts[0], "texts": parts} + return {"text": text} class _Context: @@ -162,6 +168,8 @@ class LocalExtensionRuntime: result = handler(sdk_event, arguments) if not isinstance(result, ExtensionResult): raise LocalExtensionError("local adapter returned invalid result") + if result.reply.kind is PlanKind.MULTI_TEXT: + return pack_reply_parts(list(result.reply.messages)) if result.reply.kind is not PlanKind.TEXT: raise LocalExtensionError("local command returned no visible reply") return result.reply.message diff --git a/packages/jwebot-sdk/pyproject.toml b/packages/jwebot-sdk/pyproject.toml index 1cd135d..110640c 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.5a0+1015" +version = "0.6.6a0+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 a45fd8b..dd1eaf5 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.5a0+1015" +__version__ = "0.6.6a0+1015" diff --git a/packages/jwebot-sdk/src/jwebot_sdk/contracts.py b/packages/jwebot-sdk/src/jwebot_sdk/contracts.py index 1585b06..3a1a5c2 100644 --- a/packages/jwebot-sdk/src/jwebot_sdk/contracts.py +++ b/packages/jwebot-sdk/src/jwebot_sdk/contracts.py @@ -20,6 +20,7 @@ class ContractViolation(ValueError): class PlanKind(str, Enum): TEXT = "text" + MULTI_TEXT = "multi_text" NO_REPLY = "no_reply" @@ -156,6 +157,7 @@ class ReplyPlan: kind: PlanKind message: str = "" + messages: tuple[str, ...] = () def __post_init__(self) -> None: if self.kind is PlanKind.TEXT: @@ -168,13 +170,40 @@ class ReplyPlan: limit=MAX_REPLY_CHARS, ), ) + if self.messages: + raise ContractViolation("text plan cannot contain multiple messages") + elif self.kind is PlanKind.MULTI_TEXT: + if self.message: + raise ContractViolation("multi_text plan cannot contain text") + if not self.messages: + raise ContractViolation("multi_text plan requires messages") + if len(self.messages) > 8: + raise ContractViolation("multi_text plan exceeds 8 messages") + object.__setattr__( + self, + "messages", + tuple( + _required_text( + message, + name="reply message", + limit=MAX_REPLY_CHARS, + ) + for message in self.messages + ), + ) elif self.message: raise ContractViolation("no_reply plan cannot contain text") + elif self.messages: + raise ContractViolation("no_reply plan cannot contain messages") @classmethod def text(cls, message: str) -> "ReplyPlan": return cls(kind=PlanKind.TEXT, message=message) + @classmethod + def multi_text(cls, messages: tuple[str, ...] | list[str]) -> "ReplyPlan": + return cls(kind=PlanKind.MULTI_TEXT, messages=tuple(messages)) + @classmethod def no_reply(cls) -> "ReplyPlan": return cls(kind=PlanKind.NO_REPLY) @@ -189,14 +218,23 @@ class ReplyPlan: raise ContractViolation("reply plan kind is invalid") from error if kind is PlanKind.TEXT: return cls.text(str(value.get("text", ""))) + if kind is PlanKind.MULTI_TEXT: + messages = value.get("texts", ()) + if not isinstance(messages, list): + raise ContractViolation("multi_text plan messages must be a list") + return cls.multi_text([str(message) for message in messages]) if value.get("text"): raise ContractViolation("no_reply plan cannot contain text") + if value.get("texts"): + raise ContractViolation("no_reply plan cannot contain messages") return cls.no_reply() - def to_mapping(self) -> dict[str, str]: + def to_mapping(self) -> dict[str, Any]: value = {"kind": self.kind.value} if self.kind is PlanKind.TEXT: value["text"] = self.message + if self.kind is PlanKind.MULTI_TEXT: + value["texts"] = list(self.messages) return value diff --git a/pyproject.toml b/pyproject.toml index c461a7a..ecc3306 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.jwebot] -release = "alpha 0.6.5.1015" +release = "alpha 0.6.6.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 e906afb..80252a2 100644 --- a/tests/test_release_064.py +++ b/tests/test_release_064.py @@ -1,15 +1,11 @@ -"""Behavioral coverage for the CS Major autochess extension.""" +"""Behavioral coverage for the CS Major autochess 0.6.6 flow.""" from __future__ import annotations -import copy import json -import re import tempfile -import threading import unittest from pathlib import Path -from unittest import mock from test_jwebot_edge import MODULE, config from jwebot_official_extensions import cs_autochess as AUTOCHESS @@ -18,31 +14,31 @@ from jwebot_official_extensions import cs_autochess as AUTOCHESS class Release064AutochessTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() - self.root = Path(self.temp.name) - self.engine = MODULE.EdgeApplication(config(self.root)) + 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: str, - *, - sender: str = "Alice", - event_id: str | None = None, - ) -> str: + def command(self, question, *, sender="Alice", event_id=None): self.counter += 1 - row = { - "question": question, - "sender": sender, - "group_fingerprint": "group-a", - "event_id": event_id or f"autochess-{self.counter}", - } - return self.engine.local.handle(row, {}) + response = self.engine.accept( + { + "event_id": event_id or f"autochess-{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) -> dict: + def state(self): row = self.engine.ledger.connection.execute( """ SELECT state_json FROM autochess_runs @@ -54,56 +50,40 @@ class Release064AutochessTests(unittest.TestCase): self.assertIsNotNone(row) return json.loads(row[0]) - def save_state(self, state: dict) -> None: - self.engine.ledger.connection.execute( - """ - UPDATE autochess_runs SET state_json = ?, state = ? - WHERE group_fingerprint = 'group-a' AND run_id = ? - """, - ( - json.dumps(state, ensure_ascii=False, separators=(",", ":")), - state["status"], - state["run_id"], - ), - ) + def start_two_human_two_ai(self): + self.command("/自走棋开始") + self.command("/自走棋报名", sender="Bob") + self.command("/自走棋加AI战队") + return self.command("/自走棋开赛") - def launch(self, names: list[str]) -> dict: - self.command("/自走棋开始", sender=names[0]) - for name in names[1:]: - self.command("/自走棋报名", sender=name) - self.command("/自走棋开赛", sender=names[0]) - return self.state() + def finish_draft(self): + self.command("/选1") + self.command("/选1", sender="Bob") + self.command("/选1", sender="Bob") + self.command("/选1") + self.command("/选1") + return self.command("/选1", sender="Bob") - def settle_to_completion(self) -> dict: + def ready_all(self): state = self.state() - while state["status"] == "active": - self.command("/自走棋强制结算") - state = self.state() - return state + replies = [] + for key in list(state.get("ready_keys") or []): + replies.extend( + self.command( + "/结束回合", + sender=state["players"][key]["display_name"], + ) + ) + return replies - def test_static_pool_parser_and_attribute_ranges(self): + 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.assertEqual( - len({card["id"] for card in AUTOCHESS.PLAYER_POOL}), - 60, + len({coach["id"] for coach in AUTOCHESS.COACH_POOL}), + 10, ) - ranges = { - 1: (52, 68), - 2: (60, 76), - 3: (68, 84), - 4: (78, 92), - 5: (88, 98), - } - for card in AUTOCHESS.PLAYER_POOL: - with self.subTest(card=card["id"]): - lower, upper = ranges[card["cost"]] - for field in ("firepower", "stability", "clutch"): - self.assertGreaterEqual(card[field], lower) - self.assertLessEqual(card[field], upper) - self.assertIn(card["role"], {"步枪手", "狙击手", "指挥"}) - self.assertLessEqual(len(card["teams"]), 3) - if card["role"] == "指挥": - self.assertGreater(card["leadership"], 0) self.assertEqual( AUTOCHESS.parse_autochess_action("/自走棋开始"), ("create", ""), @@ -117,391 +97,81 @@ class Release064AutochessTests(unittest.TestCase): ("tactic", "残局"), ) - def test_round_plan_matches_player_count_without_duplicate_pairs(self): - for size in range(2, 9): - keys = [f"p{index}" for index in range(1, size + 1)] - plan = AUTOCHESS.build_round_plan(keys) - group_pairs = [ - tuple(sorted(pair)) - for stage in plan - if stage["kind"] == "group" - for pair in stage["pairs"] - ] - with self.subTest(size=size): - if size == 2: - self.assertFalse(group_pairs) - self.assertFalse( - any(stage["kind"] == "semifinal" for stage in plan) - ) - elif size == 4: - semifinal = next( - stage - for stage in plan - if stage["kind"] == "semifinal" - ) - self.assertEqual( - semifinal["pairs"], - [["p1", "p4"], ["p2", "p3"]], - ) - self.assertFalse(group_pairs) - else: - self.assertEqual( - len(group_pairs), - size * (size - 1) // 2, - ) - self.assertEqual(len(group_pairs), len(set(group_pairs))) - self.assertEqual(plan[-1]["kind"], "final") - - def test_launch_has_fixed_five_coach_shared_shop_and_complete_data(self): - response = self.command("/自走棋开始") - self.assertTrue(response.startswith("alice #1:")) - self.command("/自走棋报名", sender="Bob") - self.command("/自走棋报名", sender="Carol") - self.command("/自走棋报名", sender="Dave") - response = self.command("/自走棋开赛") + def test_launch_starts_multimessage_coach_draft_not_full_rosters(self): + response = self.start_two_human_two_ai() state = self.state() - self.assertEqual(len(state["round_plan"]), 5) + self.assertGreaterEqual(len(response), 5) + self.assertIn("第一轮选秀", "\n".join(response)) + self.assertIn("1. zonic", "\n".join(response)) + self.assertEqual(state["phase"], "draft_coach_1") + self.assertEqual(state["ai_test_variant"], "two_plus_two") self.assertEqual( - [stage["kind"] for stage in state["round_plan"]], - ["pve", "pve", "pve", "semifinal", "final"], + state["seed_order"][2:], + [AUTOCHESS.AI_PERSON_KEY, AUTOCHESS.AI_SECOND_PERSON_KEY], ) - shops = [] + for player in state["players"].values(): + self.assertEqual(player["board"], []) + self.assertEqual(player["coach_id"], "") + + def test_three_round_draft_training_and_group_intro(self): + self.start_two_human_two_ai() + final_draft = self.finish_draft() + state = self.state() + + self.assertIn("三轮选秀结束", "\n".join(final_draft)) + self.assertEqual(state["phase"], "lineup_prep") for player in state["players"].values(): self.assertEqual(len(player["board"]), 5) - self.assertEqual(player["coach_level"], 1) - self.assertEqual(player["tactic"], "稳定") - self.assertEqual(len(player["shop"]), 6) - shops.append(player["shop"]) - self.assertTrue(all(shop == shops[0] for shop in shops)) - for slot in range(1, 7): - line = next( - value - for value in response.splitlines() - if value.startswith(f"{slot}. ") - ) - self.assertRegex( - line, - r"\d费 (?:步枪手|狙击手|指挥).*历史 .*火\d+ 稳\d+ 残\d+", - ) - self.assertLessEqual(len(response), MODULE.MAX_REPLY_CHARS) + self.assertEqual(len(player["bench"]), 3) + self.assertTrue(player["coach_id"]) - def test_tactics_coach_and_lineup_changes_preserve_five_starters(self): - state = self.launch(["Alice", "Bob"]) - alice_key = state["seed_order"][0] - before_board = copy.deepcopy(state["players"][alice_key]["board"]) - before_power = AUTOCHESS.lineup_power( - state["players"][alice_key] - )["power"] + self.command("/结束回合") + training = self.command("/结束回合", sender="Bob") + self.assertIn("Team Falcons", "\n".join(training)) + self.assertIn("Natus Vincere", "\n".join(training)) + self.assertEqual(self.state()["phase"], "training_manage") - tactic = self.command("/战术残局") - self.assertIn("战术设为 残局", tactic) - coach = self.command("/升级教练") - self.assertIn("教练升到 2 级", coach) + self.command("/结束回合") + group = self.command("/结束回合", sender="Bob") + self.assertIn("正赛小组赛", "\n".join(group)) state = self.state() - alice = state["players"][alice_key] - self.assertEqual(alice["board"], before_board) - self.assertEqual(len(alice["board"]), 5) - self.assertGreater(AUTOCHESS.lineup_power(alice)["power"], before_power) + self.assertEqual(state["phase"], "group_ready") + self.assertEqual(len(state["group_matches"]), 6) + + def test_full_tournament_completes_and_only_active_playoff_players_ready(self): + self.start_two_human_two_ai() + self.finish_draft() + seen_playoff_prompt = False + + for _ in range(80): + state = self.state() + if state["status"] == "completed": + break + ready_keys = list(state.get("ready_keys") or []) + self.assertTrue(ready_keys, state["phase"]) + if state["phase"] in {"semifinal_ready", "final_ready"}: + pair = set(state["current_match"]["pair"]) + self.assertEqual(set(ready_keys), pair & set(AUTOCHESS._human_keys(state))) + seen_playoff_prompt = True + self.ready_all() + else: + self.fail("tournament did not complete") - starter_ids = {unit["card_id"] for unit in alice["board"]} - slot = next( - index - for index, card_id in enumerate(alice["shop"], start=1) - if card_id not in starter_ids - ) - self.command(f"/选{slot}") - self.assertIn("首发必须保持 5 人", self.command("/出售1")) - swapped = self.command("/上阵 6 2") - self.assertIn("首发仍为完整 5 人", swapped) state = self.state() - self.assertEqual(len(state["players"][alice_key]["board"]), 5) + self.assertTrue(seen_playoff_prompt) + self.assertEqual(state["status"], "completed") + self.assertEqual(state["phase"], "completed") + self.assertTrue(state["champion_key"]) - def test_replay_debits_one_purchase_and_invalid_archive_rolls_back(self): - state = self.launch(["Alice", "Bob"]) - alice_key = state["seed_order"][0] - card_id = state["players"][alice_key]["shop"][0] - cost = AUTOCHESS.CARD_BY_ID[card_id]["cost"] - old_gold = state["players"][alice_key]["gold"] - old_pool = state["pool"][card_id] - replies: list[str] = [] - errors: list[BaseException] = [] - - def buy() -> None: - try: - replies.append( - self.command("/选1", event_id="same-autochess-buy") - ) - except BaseException as error: - errors.append(error) - - threads = [threading.Thread(target=buy) for _ in range(8)] - for thread in threads: - thread.start() - for thread in threads: - thread.join() - self.assertFalse(errors) - self.assertEqual(len(set(replies)), 1) - state = self.state() - self.assertEqual(state["players"][alice_key]["gold"], old_gold - cost) - self.assertEqual(state["pool"][card_id], old_pool - 1) - - before_json = self.engine.ledger.connection.execute( - """ - SELECT state_json FROM autochess_runs - WHERE group_fingerprint = 'group-a' AND run_id = ? - """, - (state["run_id"],), - ).fetchone()[0] - before_stories = self.engine.ledger.connection.execute( - "SELECT COUNT(*) FROM autochess_story_logs" - ).fetchone()[0] - - def invalid_archive(current, person, next_season): - del person, next_season - current["players"][alice_key]["gold"] += 999 - return { - "state": current, - "response": "invalid", - "stories": [{"bad": True}], - "battles": [], - } - - with self.assertRaisesRegex( - MODULE.LocalCommandError, - "剧情归档无效", - ): - self.engine.ledger.apply_autochess_action( - "group-a", - "Alice", - "invalid-autochess-archive", - "invalid", - invalid_archive, - ) - after_json = self.engine.ledger.connection.execute( - """ - SELECT state_json FROM autochess_runs - WHERE group_fingerprint = 'group-a' AND run_id = ? - """, - (state["run_id"],), - ).fetchone()[0] - after_stories = self.engine.ledger.connection.execute( - "SELECT COUNT(*) FROM autochess_story_logs" - ).fetchone()[0] - self.assertEqual(after_json, before_json) - self.assertEqual(after_stories, before_stories) - - def test_four_team_seed_bracket_is_bo5_with_mr15_scores(self): - state = self.launch(["Alice", "Bob", "Carol", "Dave"]) - seed_order = list(state["seed_order"]) - completed = self.settle_to_completion() - self.assertEqual(completed["status"], "completed") - self.assertTrue(completed["champion_key"]) - rows = self.engine.ledger.connection.execute( - """ - SELECT battle_type, player_a, player_b, result_json - FROM autochess_battles - WHERE battle_type != 'pve' - ORDER BY battle_id - """ - ).fetchall() - semifinals = [row for row in rows if row["battle_type"] == "semifinal_bo5"] - finals = [row for row in rows if row["battle_type"] == "final_bo5"] - self.assertEqual( - { - frozenset((row["player_a"], row["player_b"])) - for row in semifinals - }, - { - frozenset((seed_order[0], seed_order[3])), - frozenset((seed_order[1], seed_order[2])), - }, - ) - self.assertEqual(len(finals), 1) - for row in [*semifinals, *finals]: - result = json.loads(row["result_json"]) - self.assertEqual(result["series_format"], "BO5") - self.assertRegex(result["score"], r"^3-[0-2]$") - self.assertTrue(3 <= len(result["maps"]) <= 5) - for item in result["maps"]: - self.assertRegex(item["score"], r"^(?:16-(?:6|9|12|14)|19-17|22-20)$") - self.assertLessEqual(abs(item["fluctuation_a_bps"]), 200) - self.assertLessEqual(abs(item["fluctuation_b_bps"]), 200) - - def test_five_team_round_robin_bo3_then_top_four(self): - completed = self.launch(["Alice", "Bob", "Carol", "Dave", "Eve"]) - self.assertEqual( - sum( - len(stage.get("pairs", [])) - for stage in completed["round_plan"] - if stage["kind"] == "group" - ), - 10, - ) - completed = self.settle_to_completion() - self.assertEqual(len(completed["qualifiers"]), 4) - rows = self.engine.ledger.connection.execute( - """ - SELECT battle_type, player_a, player_b, result_json - FROM autochess_battles - WHERE battle_type != 'pve' - ORDER BY battle_id - """ - ).fetchall() - groups = [row for row in rows if row["battle_type"] == "group_bo3"] - self.assertEqual(len(groups), 10) - self.assertEqual( - len( - { - frozenset((row["player_a"], row["player_b"])) - for row in groups - } - ), - 10, - ) - for row in groups: - result = json.loads(row["result_json"]) - self.assertEqual(result["series_format"], "BO3") - self.assertRegex(result["score"], r"^2-[01]$") - self.assertEqual( - sum(row["battle_type"] == "semifinal_bo5" for row in rows), - 2, - ) - self.assertEqual( - sum(row["battle_type"] == "final_bo5" for row in rows), - 1, - ) - - def test_three_humans_plus_ai_has_taffy_host_and_turn_handoffs(self): + def test_multimessage_response_replays_identically(self): self.command("/自走棋开始") - self.assertIn( - "2 名或 3 名真人", - self.command("/自走棋加AI战队"), - ) self.command("/自走棋报名", sender="Bob") - self.command("/自走棋报名", sender="Carol") - self.assertIn( - "只有主持人", - self.command("/自走棋加AI战队", sender="Bob"), - ) - self.assertIn("第 4 种子", self.command("/自走棋加AI战队")) - self.assertIn( - "已经锁定参赛名单", - self.command("/自走棋报名", sender="Dave"), - ) - launch = self.command("/自走棋开赛") - state = self.state() - - self.assertIn("Taffy:欢迎大家来到第 1 届 IEM 盐田 Major", launch) - self.assertIn("3 人+1 AI 测试赛", launch) - self.assertIn("1v4、2v3", launch) - self.assertIn("小组赛 BO3,淘汰赛 BO5", launch) - self.assertIn("常规时间 15-15 进入加时", launch) - self.assertIn("alice,请做好赛前准备", launch) - self.assertTrue(state["ai_test_mode"]) - self.assertEqual(state["seed_order"][-1], AUTOCHESS.AI_PERSON_KEY) - self.assertTrue( - state["players"][AUTOCHESS.AI_PERSON_KEY]["is_ai"] - ) - semifinal = state["round_plan"][-2] - self.assertEqual( - semifinal["pairs"], - [ - [state["seed_order"][0], AUTOCHESS.AI_PERSON_KEY], - [state["seed_order"][1], state["seed_order"][2]], - ], - ) - - self.assertIn( - "当前轮到 alice", - self.command("/选1", sender="Bob"), - ) - self.assertIn( - "alice 准备完成!到 bob 做准备了", - self.command("/结束回合"), - ) - self.assertIn( - "当前轮到 bob", - self.command("/结束回合", sender="Carol"), - ) - self.assertIn( - "bob 准备完成!到 carol 做准备了", - self.command("/结束回合", sender="Bob"), - ) - self.assertIn( - "全员准备完毕", - self.command("/结束回合", sender="Carol"), - ) - settled = self.command("/自走棋结算") - self.assertIn("Taffy:AI 战队 自动运营", settled) - self.assertIn("Taffy:bob,请做好赛前准备", settled) - state = self.state() - self.assertEqual( - state["preparation_order"][0], - state["seed_order"][1], - ) - self.assertEqual( - self.engine.ledger.connection.execute( - """ - SELECT COUNT(*) FROM autochess_story_logs - WHERE story_type = 'ai_operation' - """ - ).fetchone()[0], - 1, - ) - self.assertEqual( - self.engine.ledger.connection.execute( - """ - SELECT COUNT(*) FROM autochess_story_logs - WHERE story_text NOT LIKE 'Taffy:%' - """ - ).fetchone()[0], - 0, - ) - - def test_identical_final_creates_persisted_overtime_stories(self): - state = self.launch(["Alice", "Bob"]) - for _ in range(3): - self.command("/自走棋强制结算") - state = self.state() - self.assertEqual(AUTOCHESS._current_stage(state)["kind"], "final") - first, second = state["seed_order"] - state["players"][second]["board"] = copy.deepcopy( - state["players"][first]["board"] - ) - state["players"][first]["tactic"] = "稳定" - state["players"][second]["tactic"] = "稳定" - self.save_state(state) - - with mock.patch.object(AUTOCHESS, "_hash_int", return_value=0): - response = self.command( - "/自走棋强制结算", - event_id="forced-overtime-final", - ) - self.assertIn("19-17", response) - final = self.engine.ledger.connection.execute( - """ - SELECT result_json FROM autochess_battles - WHERE battle_type = 'final_bo5' - """ - ).fetchone() - result = json.loads(final["result_json"]) - self.assertEqual(result["score"], "3-0") - self.assertEqual( - [item["score"] for item in result["maps"]], - ["19-17", "19-17", "19-17"], - ) - overtime = self.engine.ledger.connection.execute( - """ - SELECT story_text, facts_json FROM autochess_story_logs - WHERE story_type = 'overtime' - ORDER BY story_id - """ - ).fetchall() - self.assertEqual(len(overtime), 3) - for row in overtime: - self.assertIn("常规时间战成 15-15", row["story_text"]) - self.assertEqual(json.loads(row["facts_json"])["overtime_count"], 1) + self.command("/自走棋加AI战队") + first = self.command("/自走棋开赛", event_id="same-launch") + replay = self.command("/自走棋开赛", event_id="same-launch") + self.assertEqual(first, replay) + self.assertGreater(len(first), 1) if __name__ == "__main__": diff --git a/tests/test_release_065.py b/tests/test_release_065.py index 49fc7a4..d171be4 100644 --- a/tests/test_release_065.py +++ b/tests/test_release_065.py @@ -1,4 +1,4 @@ -"""Behavioral coverage for the two-human plus two-AI test mode.""" +"""Behavioral coverage for AI team enrollment in CS autochess.""" from __future__ import annotations @@ -11,7 +11,7 @@ from test_jwebot_edge import MODULE, config from jwebot_official_extensions import cs_autochess as AUTOCHESS -class Release065TwoHumanAiTests(unittest.TestCase): +class Release065AiEnrollmentTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.engine = MODULE.EdgeApplication(config(Path(self.temp.name))) @@ -23,15 +23,20 @@ class Release065TwoHumanAiTests(unittest.TestCase): def command(self, question, *, sender="Alice", event_id=None): self.counter += 1 - return self.engine.local.handle( + response = self.engine.accept( { - "question": question, - "sender": sender, - "group_fingerprint": "group-a", "event_id": event_id or f"two-ai-{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( @@ -45,88 +50,50 @@ class Release065TwoHumanAiTests(unittest.TestCase): self.assertIsNotNone(row) return json.loads(row[0]) - def test_two_humans_add_two_ai_and_use_four_seed_bracket(self): + def test_two_humans_add_two_ai_and_lock_registration(self): self.command("/自走棋开始") self.command("/自走棋报名", sender="Bob") - added = self.command("/自走棋加AI战队") + added = "\n".join(self.command("/自走棋加AI战队")) + self.assertIn("AI 战队 A、AI 战队 B", added) - self.assertIn("第 3、4 种子", added) self.assertIn("2 人+2 AI", added) self.assertIn( - "已经锁定参赛名单", - self.command("/自走棋报名", sender="Carol"), + "已经锁定", + "\n".join(self.command("/自走棋报名", sender="Carol")), ) - - launch = self.command("/自走棋开赛") + launch = "\n".join(self.command("/自走棋开赛")) state = self.state() self.assertIn("2 人+2 AI 测试赛", launch) self.assertEqual(state["ai_test_variant"], "two_plus_two") self.assertEqual( state["seed_order"][2:], - [ - AUTOCHESS.AI_PERSON_KEY, - AUTOCHESS.AI_SECOND_PERSON_KEY, - ], - ) - self.assertEqual( - state["preparation_order"], - state["seed_order"][:2], - ) - self.assertTrue( - state["players"][AUTOCHESS.AI_PERSON_KEY]["is_ai"] - ) - self.assertTrue( - state["players"][AUTOCHESS.AI_SECOND_PERSON_KEY]["is_ai"] - ) - semifinal = state["round_plan"][-2] - self.assertEqual( - semifinal["pairs"], - [ - [state["seed_order"][0], state["seed_order"][3]], - [state["seed_order"][1], state["seed_order"][2]], - ], + [AUTOCHESS.AI_PERSON_KEY, AUTOCHESS.AI_SECOND_PERSON_KEY], ) - self.assertIn("到 bob 做准备了", self.command("/结束回合")) - self.assertIn( - "全员准备完毕", - self.command("/结束回合", sender="Bob"), - ) - settled = self.command("/自走棋结算") - self.assertIn("AI 战队 A 自动运营", settled) - self.assertIn("AI 战队 B 自动运营", settled) - self.assertEqual( - self.engine.ledger.connection.execute( - """ - SELECT COUNT(*) FROM autochess_story_logs - WHERE story_type = 'ai_operation' - """ - ).fetchone()[0], - 2, - ) - - def test_add_two_ai_is_idempotent_and_cannot_be_repeated(self): + def test_three_humans_add_one_ai(self): self.command("/自走棋开始") self.command("/自走棋报名", sender="Bob") - first = self.command( - "/自走棋加AI战队", - event_id="same-add-two-ai", - ) - replay = self.command( - "/自走棋加AI战队", - event_id="same-add-two-ai", - ) + self.command("/自走棋报名", sender="Carol") + added = "\n".join(self.command("/自走棋加AI战队")) + self.assertIn("3 人+1 AI", added) + + self.command("/自走棋开赛") + state = self.state() + self.assertEqual(state["ai_test_variant"], "three_plus_one") + self.assertEqual(state["seed_order"][-1], AUTOCHESS.AI_PERSON_KEY) + self.assertTrue(state["players"][AUTOCHESS.AI_PERSON_KEY]["is_ai"]) + + def test_add_ai_replay_is_idempotent(self): + self.command("/自走棋开始") + self.command("/自走棋报名", sender="Bob") + first = self.command("/自走棋加AI战队", event_id="same-add-two-ai") + replay = self.command("/自走棋加AI战队", event_id="same-add-two-ai") self.assertEqual(first, replay) - self.assertIn( - "已经加入", - self.command("/自走棋加AI战队"), - ) + self.assertIn("已经加入", "\n".join(self.command("/自走棋加AI战队"))) + state = self.state() self.assertEqual( - sum( - bool(player.get("is_ai")) - for player in state["players"].values() - ), + sum(bool(player.get("is_ai")) for player in state["players"].values()), 2, ) diff --git a/tests/test_sdk_contracts.py b/tests/test_sdk_contracts.py index efa8ea6..b4f19c2 100644 --- a/tests/test_sdk_contracts.py +++ b/tests/test_sdk_contracts.py @@ -61,6 +61,16 @@ class PlanContractTests(unittest.TestCase): self.assertEqual(plan.kind, PlanKind.TEXT) self.assertEqual(plan.to_mapping(), {"kind": "text", "text": "hello"}) + def test_multi_text_plan_has_bounded_parts(self): + plan = ReplyPlan.multi_text(["one", "two"]) + self.assertEqual(plan.kind, PlanKind.MULTI_TEXT) + self.assertEqual( + plan.to_mapping(), + {"kind": "multi_text", "texts": ["one", "two"]}, + ) + restored = ReplyPlan.from_mapping(plan.to_mapping()) + self.assertEqual(restored, plan) + def test_no_reply_plan_cannot_contain_text(self): with self.assertRaisesRegex(ContractViolation, "cannot contain text"): ReplyPlan(kind=PlanKind.NO_REPLY, message="unexpected") diff --git a/tests/test_wechat_adaptive_recorder.py b/tests/test_wechat_adaptive_recorder.py index 765dbec..e047f44 100644 --- a/tests/test_wechat_adaptive_recorder.py +++ b/tests/test_wechat_adaptive_recorder.py @@ -337,6 +337,37 @@ class LayoutScalingTests(unittest.TestCase): self.assertEqual(reply, "本地回复喵") legacy.assert_not_called() + def test_multi_edge_reply_is_sent_by_callback(self): + legacy = mock.Mock(return_value="legacy") + reply_sender = mock.Mock(return_value=True) + incoming = { + "event_id": "event-1", + "group_fingerprint": "group-a", + "text": "@JWeBot /自走棋开赛", + } + response = { + "status": "reply", + "route": "local", + "execution_path": "local_command", + "text": "第一段喵", + "texts": ["第一段喵", "第二段喵"], + "event_id": "event-1", + } + with mock.patch.object( + MODULE.jwebot_edge, + "request_edge", + return_value=response, + ): + reply = MODULE.edge_router_reply( + incoming, + legacy_router=legacy, + reply_sender=reply_sender, + ) + + self.assertEqual(reply, "NO_REPLY") + reply_sender.assert_called_once_with(incoming, response) + legacy.assert_not_called() + def test_fast_ack_is_sent_by_callback_without_upstream_duplicate(self): legacy = mock.Mock(return_value="legacy") fast_ack_sender = mock.Mock(return_value=True) @@ -400,7 +431,7 @@ class LayoutScalingTests(unittest.TestCase): side_effect=MODULE.jwebot_edge.EdgeError("unavailable"), ): reply = MODULE.edge_router_reply( - {"event_id": "event-1", "text": "@JWeBot #状态"}, + {"event_id": "event-1", "text": "#状态"}, legacy_router=legacy, ) diff --git a/tools/sync-local-extensions.py b/tools/sync-local-extensions.py index 28c3d6c..8af4898 100755 --- a/tools/sync-local-extensions.py +++ b/tools/sync-local-extensions.py @@ -89,6 +89,14 @@ def _handler(context: SingleGroupContext, command: str): "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") diff --git a/wechat_adaptive_recorder.py b/wechat_adaptive_recorder.py index a4625d9..071776d 100755 --- a/wechat_adaptive_recorder.py +++ b/wechat_adaptive_recorder.py @@ -644,6 +644,7 @@ def edge_router_reply( *, legacy_router: Any, fast_ack_sender: Callable[[dict[str, Any], dict[str, Any]], bool] | None = None, + reply_sender: Callable[[dict[str, Any], dict[str, Any]], bool] | None = None, ) -> str: """Use the local edge if healthy, otherwise preserve the existing cloud route.""" @@ -688,6 +689,34 @@ def edge_router_reply( return legacy_router(event) return "JWeBot Error: Local edge unavailable" if response.get("status") == "reply": + texts = response.get("texts") + if ( + isinstance(texts, list) + and len(texts) > 1 + and all(isinstance(item, str) and 0 < len(item) <= 900 for item in texts) + ): + if reply_sender is None: + logging.warning( + "multi reply sender is unavailable event_hash=%s", + hashlib.sha256( + str(event.get("event_id", "")).encode("utf-8", "replace") + ).hexdigest()[:12], + ) + return "JWeBot Error: Multi reply unavailable" + if not reply_sender(event, response): + return "JWeBot Error: Multi reply delivery failed" + TELEMETRY.elapsed( + "recorder.edge.request", + started, + event_id=event_id, + group_id=group_id, + route=str(response.get("route", "")), + execution_path=str(response.get("execution_path", "")), + chars=sum(len(item) for item in texts), + messages=len(texts), + outcome="multi_reply", + ) + return "NO_REPLY" reply = str(response.get("text", "")) if reply and len(reply) <= 900: if response.get("fast_ack") is True: @@ -986,10 +1015,62 @@ def run() -> None: ) return True + def send_multi_reply( + event: dict[str, Any], + response: dict[str, Any], + ) -> bool: + started = time.monotonic() + event_id = str(response.get("event_id") or event.get("event_id", "")) + expected_group = str(event.get("group_fingerprint", "")) + texts = response.get("texts") + if ( + not event_id + or not expected_group + or not isinstance(texts, list) + or not texts + or any(not isinstance(item, str) or not item or len(item) > 900 for item in texts) + ): + logging.error("edge multi reply response was invalid") + return False + try: + for index, text in enumerate(texts, start=1): + module.send_reply( + text, + expected_group, + event_id=event_id, + source=f"local-multi-{index}", + ) + except Exception as error: + logging.warning( + "multi reply GUI delivery failed event_hash=%s error_type=%s", + hashlib.sha256(event_id.encode("utf-8", "replace")).hexdigest()[:12], + type(error).__name__, + ) + TELEMETRY.elapsed( + "recorder.multi_reply.gui", + started, + status="error", + event_id=event_id, + group_id=expected_group, + messages=len(texts), + error_type=type(error).__name__, + ) + return False + TELEMETRY.elapsed( + "recorder.multi_reply.gui", + started, + event_id=event_id, + group_id=expected_group, + messages=len(texts), + delivered=True, + ) + return True + module.route_event = lambda event: edge_router_reply( event, legacy_router=legacy_router, fast_ack_sender=send_fast_ack, + reply_sender=send_multi_reply, ) wait_for_ready(module)