Release alpha 0.6.6 autochess flow
check / local-gate (push) Failing after 40s

This commit is contained in:
JWeBot Contributors
2026-10-15 17:10:28 +08:00
parent 9fff57134f
commit a74147e7f1
64 changed files with 1838 additions and 1060 deletions
+19
View File
@@ -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:
+13 -10
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
alpha 0.6.5.1015
alpha 0.6.6.1015
+47 -40
View File
@@ -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 对阵与结果
- 每张加时地图
- 小组排名和前四晋级
+8
View File
@@ -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")
@@ -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")
@@ -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")
+8
View File
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
+1 -1
View File
@@ -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 = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile",
]
__version__ = "0.6.5a0+1015"
__version__ = "0.6.6a0+1015"
@@ -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]
@@ -16,4 +16,4 @@ __all__ = [
"process_request",
]
__version__ = "0.6.5a0+1015"
__version__ = "0.6.6a0+1015"
+4 -4
View File
@@ -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'",
]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature",
]
__version__ = "0.6.5a0+1015"
__version__ = "0.6.6a0+1015"
@@ -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"]),
@@ -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]
@@ -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"
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
File diff suppressed because it is too large Load Diff
@@ -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":
@@ -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
+1 -1
View File
@@ -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 = []
@@ -38,4 +38,4 @@ __all__ = [
"SingleGroupContext",
]
__version__ = "0.6.5a0+1015"
__version__ = "0.6.6a0+1015"
@@ -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
+1 -1
View File
@@ -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]
+106 -436
View File
@@ -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__":
+39 -72
View File
@@ -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,
)
+10
View File
@@ -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")
+32 -1
View File
@@ -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,
)
+8
View File
@@ -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")
+81
View File
@@ -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)