This commit is contained in:
@@ -1,5 +1,24 @@
|
|||||||
# Changelog
|
# 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
|
## alpha 0.6.5.1015
|
||||||
|
|
||||||
- Extend the AI test path for CS Major autochess to support two-human games:
|
- Extend the AI test path for CS Major autochess to support two-human games:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
|
|||||||
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
||||||
并通过带视觉确认的 GUI 发送链路回复。
|
并通过带视觉确认的 GUI 发送链路回复。
|
||||||
|
|
||||||
当前版本:`alpha 0.6.5.1015`。
|
当前版本:`alpha 0.6.6.1015`。
|
||||||
|
|
||||||
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
|
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
|
||||||
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
|
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
|
||||||
@@ -20,14 +20,16 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
|
|||||||
0.6.3 将彩票改为群内多人轮次,加入报名、状态、主持人开奖和取消;
|
0.6.3 将彩票改为群内多人轮次,加入报名、状态、主持人开奖和取消;
|
||||||
抽奖、彩票、21 点统一使用 `Jacky #N:...` 局号并支持 `/游戏记录`。
|
抽奖、彩票、21 点统一使用 `Jacky #N:...` 局号并支持 `/游戏记录`。
|
||||||
完整玩法见[多人彩票与游戏记录](docs/group-lottery-0.6.3.md)。
|
完整玩法见[多人彩票与游戏记录](docs/group-lottery-0.6.3.md)。
|
||||||
0.6.4 新增 2~8 人 CS Major 自走棋:固定五人首发与教练、每轮 6 人
|
0.6.4 新增 2~8 人 CS Major 自走棋:职业选手卡池、属性/定位/战术
|
||||||
公开商店、属性/定位/战术克制、小组单循环、1v4 与 2v3 淘汰赛,以及
|
克制、小组单循环、1v4 与 2v3 淘汰赛,以及 BO3/BO5 的 MR15 加时赛。
|
||||||
BO3/BO5 的 MR15 加时赛。支持三名真人加入一支确定性 AI 测试战队;
|
|
||||||
Taffy 负责赛制播报、顺序准备、交棒和全程剧情主持。所有选秀、战斗和
|
|
||||||
加时剧情均在本地事务内落档。
|
|
||||||
完整规则见[CS Major 自走棋设计稿](docs/cs-major-autochess-design.md)。
|
完整规则见[CS Major 自走棋设计稿](docs/cs-major-autochess-design.md)。
|
||||||
0.6.5 将 AI 测试模式扩展到双人:两名真人可一键补入 AI 战队 A、B,
|
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点 要牌|停牌|状态|结束
|
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
|
||||||
/游戏记录 [页码](/gamehistory)
|
/游戏记录 [页码](/gamehistory)
|
||||||
/游戏帮助(/games)
|
/游戏帮助(/games)
|
||||||
/自走棋开始|报名|开赛|状态|结算|档案
|
/自走棋开始|报名|开赛|状态|档案
|
||||||
/自走棋加AI战队(两名或三名真人测试模式)
|
/自走棋加AI战队(两名或三名真人测试模式)
|
||||||
/选1~6;/战术 火力|稳定|残局;/阵容;/结束回合
|
/选1~6(教练轮为 /选1~10);/刷新;/锁定
|
||||||
|
/战术 火力|稳定|残局;/阵容;/上阵 备战编号 [首发位];/出售 备战编号;/结束回合
|
||||||
/丢骰子 [NdM]
|
/丢骰子 [NdM]
|
||||||
/猜数字 开始 [上限]
|
/猜数字 开始 [上限]
|
||||||
/石头剪刀布 [开始|加入|出|状态|结算|结束]
|
/石头剪刀布 [开始|加入|出|状态|结算|结束]
|
||||||
@@ -269,7 +272,7 @@ visually confirmed GUI bridge.
|
|||||||
Current version:
|
Current version:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
alpha 0.6.5.1015
|
alpha 0.6.6.1015
|
||||||
```
|
```
|
||||||
|
|
||||||
## Runtime Flow
|
## Runtime Flow
|
||||||
|
|||||||
@@ -16,12 +16,13 @@
|
|||||||
|
|
||||||
一句话定义:
|
一句话定义:
|
||||||
|
|
||||||
`公开 6 人选秀 -> 凑羁绊与升星 -> 打 PvE 拿资源 -> 打 PvP 跑 Major 赛程 -> 保存每轮赛报与剧情`
|
`三轮赛前选秀 -> 训练赛拿资源 -> D 牌与布阵 -> BO3 小组单循环 -> BO5 淘汰赛 -> 保存每轮赛报与剧情`
|
||||||
|
|
||||||
玩家感受到的乐趣应当主要来自:
|
玩家感受到的乐趣应当主要来自:
|
||||||
|
|
||||||
- 抢 6 人选秀里的关键卡;
|
- 第一轮从固定 10 人教练池里抢体系教练;
|
||||||
- 决定要不要升教练等级、保经济还是强行 D 牌;
|
- 第二、三轮公开选手池里抢关键卡;
|
||||||
|
- 决定要不要保经济还是强行 D 牌;
|
||||||
- 赌阵容成型时点;
|
- 赌阵容成型时点;
|
||||||
- 看小组单循环和淘汰赛赛报;
|
- 看小组单循环和淘汰赛赛报;
|
||||||
- 看最终冠军剧情与本届 Major 档案。
|
- 看最终冠军剧情与本届 Major 档案。
|
||||||
@@ -36,9 +37,11 @@
|
|||||||
- 主持人可在报名阶段发送 `/自走棋加AI战队`:
|
- 主持人可在报名阶段发送 `/自走棋加AI战队`:
|
||||||
- 两名真人时加入 AI 战队 A、B;
|
- 两名真人时加入 AI 战队 A、B;
|
||||||
- 三名真人时加入一支 AI 战队。
|
- 三名真人时加入一支 AI 战队。
|
||||||
- 两种测试模式都补满四队,直接生成 `1v4`、`2v3` 半决赛。
|
- 两种测试模式都补满四队,先进行 BO3 小组单循环,再按排名生成
|
||||||
|
`1v4`、`2v3` 半决赛。
|
||||||
- AI 占一个参赛席位,但不对应群成员,也不调用在线模型。
|
- AI 占一个参赛席位,但不对应群成员,也不调用在线模型。
|
||||||
- AI 每回合以确定性规则买牌、升级教练、调整首发和选择克制战术。
|
- AI 使用确定性规则选秀、买牌、调整首发和选择克制战术;为便于测试,
|
||||||
|
AI 阵容战力会有明确折扣。
|
||||||
|
|
||||||
### 3.2 玩家初始状态
|
### 3.2 玩家初始状态
|
||||||
|
|
||||||
@@ -46,16 +49,15 @@
|
|||||||
|
|
||||||
- 生命值:`100`
|
- 生命值:`100`
|
||||||
- 金币:`10`
|
- 金币:`10`
|
||||||
- 教练等级:`1`
|
- 教练:赛前第一轮选秀决定
|
||||||
- 完整首发:`5 名一星选手 + 1 名教练`
|
- 初始选手:赛前第二、三轮选秀决定,系统随后补齐阵容
|
||||||
- 阵容位:固定 `5 个选手位 + 1 个教练位`,比赛时必须全部上场
|
- 阵容位:固定 `5 个选手位 + 1 个教练位`,比赛时必须全部上场
|
||||||
- 备战席:`8 格`
|
- 备战席:`3 格`
|
||||||
- 连胜/连败计数:`0`
|
- 连胜/连败计数:`0`
|
||||||
- 本局独立事件档案编号
|
- 本局独立事件档案编号
|
||||||
|
|
||||||
开赛时系统从低费池为每位玩家确定性分配 5 名首发选手,保证第一场
|
三轮选秀完成后,系统按已选核心确定性补齐每队 `5 名首发 + 3 名备战`
|
||||||
比赛就是完整的五人阵容。后续选秀用于替换、强化和升星,而不是解锁
|
和常驻教练,保证训练赛开始时就是完整阵容。
|
||||||
上场人口。
|
|
||||||
|
|
||||||
## 4. 选手池设计
|
## 4. 选手池设计
|
||||||
|
|
||||||
@@ -195,12 +197,11 @@
|
|||||||
- 4 级:全队战力 `+8%`
|
- 4 级:全队战力 `+8%`
|
||||||
- 5 级:全队战力 `+10%`
|
- 5 级:全队战力 `+10%`
|
||||||
|
|
||||||
### 7.2 教练升级
|
### 7.2 教练获取
|
||||||
|
|
||||||
玩家可花金币升级教练经验。
|
教练由第一轮公共选秀决定,本届赛事内不能用金币升级教练。不同教练
|
||||||
|
自带等级、属性倾向和全队加成。
|
||||||
建议升级费用为 `6 / 10 / 16 / 24` 金币。升级只提高上述战术加成。
|
高费选手的商店概率随赛事进程推进,不再绑定教练等级。
|
||||||
高费选手的商店概率改为随赛事回合推进,不再绑定教练等级。
|
|
||||||
|
|
||||||
## 8. 每轮 6 人选秀
|
## 8. 每轮 6 人选秀
|
||||||
|
|
||||||
@@ -242,12 +243,14 @@
|
|||||||
/选4
|
/选4
|
||||||
/选5
|
/选5
|
||||||
/选6
|
/选6
|
||||||
|
/选7
|
||||||
|
/选8
|
||||||
|
/选9
|
||||||
|
/选10
|
||||||
/刷新
|
/刷新
|
||||||
/锁定
|
/锁定
|
||||||
/升级教练
|
|
||||||
/战术 火力|稳定|残局
|
/战术 火力|稳定|残局
|
||||||
/上阵 备战编号 [首发位]
|
/上阵 备战编号 [首发位]
|
||||||
/下阵 首发位 备战编号
|
|
||||||
/出售 备战编号
|
/出售 备战编号
|
||||||
/阵容
|
/阵容
|
||||||
/赛程
|
/赛程
|
||||||
@@ -258,15 +261,16 @@
|
|||||||
|
|
||||||
### 8.3 刷新与锁定
|
### 8.3 刷新与锁定
|
||||||
|
|
||||||
- 每轮默认展示一组 6 人。
|
- 第一轮教练选秀展示固定 10 名教练;第二、三轮选手选秀展示 6 名选手。
|
||||||
|
- 训练赛和正赛运营阶段默认展示一组 6 人商店。
|
||||||
- 玩家可支付金币执行刷新。
|
- 玩家可支付金币执行刷新。
|
||||||
- 玩家可锁定本轮 6 人,使下一轮仍保留同一组候选。
|
- 玩家可锁定本轮 6 人,使下一轮仍保留同一组候选。
|
||||||
|
|
||||||
### 8.4 顺位
|
### 8.4 顺位
|
||||||
|
|
||||||
- 开局首轮选秀顺位随机。
|
- 开赛后三轮公共选秀,第一轮选教练,第二、三轮选选手。
|
||||||
- 后续普通回合不再做蛇形公共抢位,而是每位玩家各自面对同一组 6 人池。
|
- 第二轮使用反向顺序,避免种子靠前者连续先手。
|
||||||
- 如果后续想加竞技性,可在第二阶段引入“共享卡池抢空”机制。
|
- AI 轮次会自动选择,真人只在轮到自己时使用 `/选N`。
|
||||||
|
|
||||||
第一期建议先做“公开同池、各自结算”,避免群聊交互过重。
|
第一期建议先做“公开同池、各自结算”,避免群聊交互过重。
|
||||||
|
|
||||||
@@ -558,22 +562,23 @@ PvE 对手是模板化敌方阵容或阈值型事件,不需要复杂 AI。
|
|||||||
|
|
||||||
建议所有回复都遵循现有小游戏风格:
|
建议所有回复都遵循现有小游戏风格:
|
||||||
|
|
||||||
`用户名 #局号:内容`
|
多段回复优先。一次主持动作可以拆成欢迎语、参赛名单、种子顺序、候选池、
|
||||||
|
状态面板、操作指引等多条消息,每条保持在发送上限内。
|
||||||
|
|
||||||
例如:
|
例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Jacky #3:第 4 回合开始,你当前 32 金币,教练 3 级。
|
欢迎来到第一轮选秀,本轮选教练。
|
||||||
本轮选秀如下:
|
选择顺序:Jacky、Alice、AI 战队 A、AI 战队 B
|
||||||
1. donk 5费 步枪手 Spirit 俄罗斯 火力96 稳定88 残局86
|
1. zonic|5级教练|冠军体系|火78 稳92 残88|全队+9%
|
||||||
2. apEX 3费 指挥 Vitality 法国 火力68 稳定81 指挥95
|
|
||||||
...
|
...
|
||||||
发送“选1”到“选6”,或发送“刷新”“锁定”“升级教练”。
|
现在轮到 @Jacky 做出选择,使用 /选1~/选10。
|
||||||
```
|
```
|
||||||
|
|
||||||
### 16.1 Taffy 主持状态机
|
### 16.1 Taffy 主持状态机
|
||||||
|
|
||||||
Taffy 必须承担完整赛事主持,而不是只在结算时出现。
|
主持人必须承担完整赛事主持,而不是只在结算时出现;输出不需要每句都带
|
||||||
|
`Taffy:` 前缀。
|
||||||
|
|
||||||
开赛时必须包含:
|
开赛时必须包含:
|
||||||
|
|
||||||
@@ -581,27 +586,29 @@ Taffy 必须承担完整赛事主持,而不是只在结算时出现。
|
|||||||
- 本届参赛者和种子
|
- 本届参赛者和种子
|
||||||
- 当前人数对应的完整赛制
|
- 当前人数对应的完整赛制
|
||||||
- 小组赛 BO3、淘汰赛 BO5、单图 MR15 和加时规则
|
- 小组赛 BO3、淘汰赛 BO5、单图 MR15 和加时规则
|
||||||
- 当前回合内容
|
- 三轮选秀规则和当前选择玩家
|
||||||
- 第一位需要准备的玩家
|
- 训练赛、小组赛和淘汰赛的当前阶段
|
||||||
|
|
||||||
每回合按真人玩家生成确定性的准备顺序。AI 自动准备,不占真人操作轮次。
|
赛前选秀按顺序逐人选择。进入阵容准备、训练赛运营和小组赛运营后,
|
||||||
只有当前玩家可以购买、刷新、选择战术和调整阵容。提交后必须明确交棒:
|
所有相关真人发送 `/结束回合` 即可自动推进,不需要主持人手动结算。
|
||||||
|
淘汰赛只等待当前对阵中的真人,已经淘汰的玩家不会再被提示准备。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Taffy:Jacky,请做好赛前准备;完成后发送 /结束回合。
|
Jacky 选择了 zonic!
|
||||||
Taffy:Jacky 准备完成!到 Alice 做准备了。
|
现在轮到 @Alice 做出选择,使用 /选1~/选10。
|
||||||
Taffy:Alice 准备完成!全员准备完毕,主持人可以结算。
|
|
||||||
```
|
|
||||||
|
|
||||||
下一回合轮换首位准备者,避免固定由同一个人总是先操作。
|
训练赛阶段结束。可以 /刷新、/选1~/选6、/上阵、/战术;
|
||||||
|
所有真人发送 /结束回合 后进入小组赛。
|
||||||
|
```
|
||||||
|
|
||||||
以下节点都必须由 Taffy 主持并落档:
|
以下节点都必须由 Taffy 主持并落档:
|
||||||
|
|
||||||
- 报名开启和新选手加入
|
- 报名开启和新选手加入
|
||||||
- AI 测试战队加入
|
- AI 测试战队加入
|
||||||
- 赛制与种子公布
|
- 赛制与种子公布
|
||||||
- 每位玩家开始/完成准备
|
- 三轮选秀候选池、选择结果和选秀完成
|
||||||
- 每轮开始和结算
|
- 三场训练赛结果
|
||||||
|
- 媒体日或赞助挑战
|
||||||
- 每场 BO3/BO5 对阵与结果
|
- 每场 BO3/BO5 对阵与结果
|
||||||
- 每张加时地图
|
- 每张加时地图
|
||||||
- 小组排名和前四晋级
|
- 小组排名和前四晋级
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-config"
|
name = "jwebot-config"
|
||||||
version = "0.6.5a0+1015"
|
version = "0.6.6a0+1015"
|
||||||
description = "Profile-based configuration compiler for JWeBot"
|
description = "Profile-based configuration compiler for JWeBot"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ __all__ = [
|
|||||||
"write_default_profile",
|
"write_default_profile",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.5a0+1015"
|
__version__ = "0.6.6a0+1015"
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-extension-host"
|
name = "jwebot-extension-host"
|
||||||
version = "0.6.5a0+1015"
|
version = "0.6.6a0+1015"
|
||||||
description = "Single-release, single-invocation process host for JWeBot extensions"
|
description = "Single-release, single-invocation process host for JWeBot extensions"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jwebot-sdk==0.6.5a0+1015",
|
"jwebot-sdk==0.6.6a0+1015",
|
||||||
"jwebot-kernel==0.6.5a0+1015",
|
"jwebot-kernel==0.6.6a0+1015",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ __all__ = [
|
|||||||
"process_request",
|
"process_request",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.5a0+1015"
|
__version__ = "0.6.6a0+1015"
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-kernel"
|
name = "jwebot-kernel"
|
||||||
version = "0.6.5a0+1015"
|
version = "0.6.6a0+1015"
|
||||||
description = "Stable routing and extension authority for JWeBot"
|
description = "Stable routing and extension authority for JWeBot"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jwebot-config==0.6.5a0+1015",
|
"jwebot-config==0.6.6a0+1015",
|
||||||
"jwebot-sdk==0.6.5a0+1015",
|
"jwebot-sdk==0.6.6a0+1015",
|
||||||
"jwebot-official-extensions==0.6.5a0+1015",
|
"jwebot-official-extensions==0.6.6a0+1015",
|
||||||
"tomli>=2; python_version < '3.11'",
|
"tomli>=2; python_version < '3.11'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -107,4 +107,4 @@ __all__ = [
|
|||||||
"verify_installed_release_signature",
|
"verify_installed_release_signature",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.5a0+1015"
|
__version__ = "0.6.6a0+1015"
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ from jwebot_official_extensions import (
|
|||||||
extension_for_kind,
|
extension_for_kind,
|
||||||
local_command_parts,
|
local_command_parts,
|
||||||
local_extension_command_token,
|
local_extension_command_token,
|
||||||
|
pack_reply_parts,
|
||||||
|
unpack_reply_parts,
|
||||||
)
|
)
|
||||||
from jwebot_official_extensions.steam import (
|
from jwebot_official_extensions.steam import (
|
||||||
DEFAULT_STEAM_MIHOMO_PROXY_URL,
|
DEFAULT_STEAM_MIHOMO_PROXY_URL,
|
||||||
@@ -3760,7 +3762,16 @@ class Ledger:
|
|||||||
or not isinstance(state.get("players"), dict)
|
or not isinstance(state.get("players"), dict)
|
||||||
):
|
):
|
||||||
raise LocalCommandError("自走棋赛事结构无效")
|
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()
|
players = state["players"].values()
|
||||||
if any(
|
if any(
|
||||||
not isinstance(player, dict)
|
not isinstance(player, dict)
|
||||||
@@ -4979,7 +4990,11 @@ class EdgeApplication:
|
|||||||
error_type=type(error).__name__,
|
error_type=type(error).__name__,
|
||||||
)
|
)
|
||||||
raise EdgeError("隔离插件暂时不可用") from error
|
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("隔离插件没有返回可见回复")
|
raise EdgeError("隔离插件没有返回可见回复")
|
||||||
self.telemetry.elapsed(
|
self.telemetry.elapsed(
|
||||||
"edge.extension.execute",
|
"edge.extension.execute",
|
||||||
@@ -4989,7 +5004,7 @@ class EdgeApplication:
|
|||||||
plugin_id=owner.extension_id,
|
plugin_id=owner.extension_id,
|
||||||
mode="host",
|
mode="host",
|
||||||
)
|
)
|
||||||
return result.reply.message
|
return reply_message
|
||||||
if (
|
if (
|
||||||
self.config.extension_mode == "host-only"
|
self.config.extension_mode == "host-only"
|
||||||
or (
|
or (
|
||||||
@@ -5078,13 +5093,18 @@ class EdgeApplication:
|
|||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
}
|
}
|
||||||
if row["response_text"]:
|
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",
|
"status": "reply",
|
||||||
"route": row["route"],
|
"route": row["route"],
|
||||||
"execution_path": stored_execution_path,
|
"execution_path": stored_execution_path,
|
||||||
"text": row["response_text"],
|
"text": text,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
}
|
}
|
||||||
|
if parts is not None:
|
||||||
|
response["texts"] = parts
|
||||||
|
return response
|
||||||
return {
|
return {
|
||||||
"status": "fallback_cloud",
|
"status": "fallback_cloud",
|
||||||
"reason": "existing_incomplete_event",
|
"reason": "existing_incomplete_event",
|
||||||
@@ -5206,6 +5226,13 @@ class EdgeApplication:
|
|||||||
status="local_completed",
|
status="local_completed",
|
||||||
response_text=reply,
|
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(
|
self.telemetry.elapsed(
|
||||||
"edge.local.complete",
|
"edge.local.complete",
|
||||||
started,
|
started,
|
||||||
@@ -5214,14 +5241,18 @@ class EdgeApplication:
|
|||||||
route=route,
|
route=route,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
execution_path=execution_path,
|
execution_path=execution_path,
|
||||||
chars=len(reply),
|
chars=reply_chars,
|
||||||
|
messages=len(parts) if parts is not None else 1,
|
||||||
)
|
)
|
||||||
return {
|
response = {
|
||||||
"status": "reply",
|
"status": "reply",
|
||||||
"route": route,
|
"route": route,
|
||||||
"execution_path": execution_path,
|
"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(
|
alias_reply = self.ledger.local_alias_identity_reply(
|
||||||
str(row["group_fingerprint"]),
|
str(row["group_fingerprint"]),
|
||||||
str(row["question"]),
|
str(row["question"]),
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-official-extensions"
|
name = "jwebot-official-extensions"
|
||||||
version = "0.6.5a0+1015"
|
version = "0.6.6a0+1015"
|
||||||
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jwebot-sdk==0.6.5a0+1015",
|
"jwebot-sdk==0.6.6a0+1015",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from .cs_autochess import (
|
from .cs_autochess import (
|
||||||
CARD_BY_ID,
|
CARD_BY_ID,
|
||||||
COACH_BONUSES,
|
COACH_BONUSES,
|
||||||
|
COACH_POOL,
|
||||||
MAX_PLAYERS,
|
MAX_PLAYERS,
|
||||||
MIN_PLAYERS,
|
MIN_PLAYERS,
|
||||||
PLAYER_POOL,
|
PLAYER_POOL,
|
||||||
@@ -32,6 +33,8 @@ from .local_commands import (
|
|||||||
LocalCommandError,
|
LocalCommandError,
|
||||||
deterministic_int,
|
deterministic_int,
|
||||||
local_command_parts,
|
local_command_parts,
|
||||||
|
pack_reply_parts,
|
||||||
|
unpack_reply_parts,
|
||||||
)
|
)
|
||||||
from .runtime import (
|
from .runtime import (
|
||||||
LocalExtensionError,
|
LocalExtensionError,
|
||||||
@@ -49,6 +52,7 @@ from .steam import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"CARD_BY_ID",
|
"CARD_BY_ID",
|
||||||
"COACH_BONUSES",
|
"COACH_BONUSES",
|
||||||
|
"COACH_POOL",
|
||||||
"CsMajorAutochess",
|
"CsMajorAutochess",
|
||||||
"DICE_CHALLENGE_TIMEOUT_SECONDS",
|
"DICE_CHALLENGE_TIMEOUT_SECONDS",
|
||||||
"DEFAULT_STEAM_MIHOMO_PROXY_URL",
|
"DEFAULT_STEAM_MIHOMO_PROXY_URL",
|
||||||
@@ -79,9 +83,11 @@ __all__ = [
|
|||||||
"local_extension_command_token",
|
"local_extension_command_token",
|
||||||
"lineup_power",
|
"lineup_power",
|
||||||
"matchup_modifier",
|
"matchup_modifier",
|
||||||
|
"pack_reply_parts",
|
||||||
"parse_autochess_action",
|
"parse_autochess_action",
|
||||||
|
"unpack_reply_parts",
|
||||||
"extension_for_kind",
|
"extension_for_kind",
|
||||||
"render_feature_list",
|
"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(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+8
@@ -21,6 +21,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
+1032
-472
File diff suppressed because it is too large
Load Diff
+36
-1
@@ -25,6 +25,7 @@ MAX_RPS_PLAYERS = 8
|
|||||||
BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||||
DICE_PATTERN = re.compile(r"^(?:(\d{1,2})[dD](\d{1,3}))?$")
|
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})$")
|
RANDOM_NUMBER_PATTERN = re.compile(r"^(-?\d{1,6})\s+(-?\d{1,6})$")
|
||||||
|
MULTI_REPLY_MARKER = "\x1eJWEBOT_MULTI_REPLY_V1\x1e"
|
||||||
|
|
||||||
|
|
||||||
class LocalCommandError(RuntimeError):
|
class LocalCommandError(RuntimeError):
|
||||||
@@ -53,6 +54,36 @@ def deterministic_int(seed: str, lower: int, upper: int) -> int:
|
|||||||
return lower + value % (upper - lower + 1)
|
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]:
|
def local_command_parts(question: str) -> tuple[str, str]:
|
||||||
normalized = question.lstrip()
|
normalized = question.lstrip()
|
||||||
if not normalized.startswith("/"):
|
if not normalized.startswith("/"):
|
||||||
@@ -131,7 +162,11 @@ class LocalCommandEngine:
|
|||||||
}:
|
}:
|
||||||
return self.reply_cleaner(self.players.handle(row, kind, arguments))
|
return self.reply_cleaner(self.players.handle(row, kind, arguments))
|
||||||
if kind == "cs_autochess":
|
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":
|
if kind == "dice":
|
||||||
return self.reply_cleaner(self.dice(row, arguments))
|
return self.reply_cleaner(self.dice(row, arguments))
|
||||||
if kind == "dice_challenge":
|
if kind == "dice_challenge":
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from .local_commands import (
|
|||||||
LocalCommandEngine,
|
LocalCommandEngine,
|
||||||
local_command_parts,
|
local_command_parts,
|
||||||
utc_now,
|
utc_now,
|
||||||
|
pack_reply_parts,
|
||||||
|
unpack_reply_parts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -74,7 +76,11 @@ class LocalServiceBroker:
|
|||||||
"question": f"{command} {arguments}".rstrip(),
|
"question": f"{command} {arguments}".rstrip(),
|
||||||
"created_at": str(event.get("observed_at") or utc_now()),
|
"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:
|
class _Context:
|
||||||
@@ -162,6 +168,8 @@ class LocalExtensionRuntime:
|
|||||||
result = handler(sdk_event, arguments)
|
result = handler(sdk_event, arguments)
|
||||||
if not isinstance(result, ExtensionResult):
|
if not isinstance(result, ExtensionResult):
|
||||||
raise LocalExtensionError("local adapter returned invalid result")
|
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:
|
if result.reply.kind is not PlanKind.TEXT:
|
||||||
raise LocalExtensionError("local command returned no visible reply")
|
raise LocalExtensionError("local command returned no visible reply")
|
||||||
return result.reply.message
|
return result.reply.message
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-sdk"
|
name = "jwebot-sdk"
|
||||||
version = "0.6.5a0+1015"
|
version = "0.6.6a0+1015"
|
||||||
description = "Typed, capability-limited extension API for JWeBot plugins"
|
description = "Typed, capability-limited extension API for JWeBot plugins"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
@@ -38,4 +38,4 @@ __all__ = [
|
|||||||
"SingleGroupContext",
|
"SingleGroupContext",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.6.5a0+1015"
|
__version__ = "0.6.6a0+1015"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class ContractViolation(ValueError):
|
|||||||
|
|
||||||
class PlanKind(str, Enum):
|
class PlanKind(str, Enum):
|
||||||
TEXT = "text"
|
TEXT = "text"
|
||||||
|
MULTI_TEXT = "multi_text"
|
||||||
NO_REPLY = "no_reply"
|
NO_REPLY = "no_reply"
|
||||||
|
|
||||||
|
|
||||||
@@ -156,6 +157,7 @@ class ReplyPlan:
|
|||||||
|
|
||||||
kind: PlanKind
|
kind: PlanKind
|
||||||
message: str = ""
|
message: str = ""
|
||||||
|
messages: tuple[str, ...] = ()
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.kind is PlanKind.TEXT:
|
if self.kind is PlanKind.TEXT:
|
||||||
@@ -168,13 +170,40 @@ class ReplyPlan:
|
|||||||
limit=MAX_REPLY_CHARS,
|
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:
|
elif self.message:
|
||||||
raise ContractViolation("no_reply plan cannot contain text")
|
raise ContractViolation("no_reply plan cannot contain text")
|
||||||
|
elif self.messages:
|
||||||
|
raise ContractViolation("no_reply plan cannot contain messages")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def text(cls, message: str) -> "ReplyPlan":
|
def text(cls, message: str) -> "ReplyPlan":
|
||||||
return cls(kind=PlanKind.TEXT, message=message)
|
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
|
@classmethod
|
||||||
def no_reply(cls) -> "ReplyPlan":
|
def no_reply(cls) -> "ReplyPlan":
|
||||||
return cls(kind=PlanKind.NO_REPLY)
|
return cls(kind=PlanKind.NO_REPLY)
|
||||||
@@ -189,14 +218,23 @@ class ReplyPlan:
|
|||||||
raise ContractViolation("reply plan kind is invalid") from error
|
raise ContractViolation("reply plan kind is invalid") from error
|
||||||
if kind is PlanKind.TEXT:
|
if kind is PlanKind.TEXT:
|
||||||
return cls.text(str(value.get("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"):
|
if value.get("text"):
|
||||||
raise ContractViolation("no_reply plan cannot contain 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()
|
return cls.no_reply()
|
||||||
|
|
||||||
def to_mapping(self) -> dict[str, str]:
|
def to_mapping(self) -> dict[str, Any]:
|
||||||
value = {"kind": self.kind.value}
|
value = {"kind": self.kind.value}
|
||||||
if self.kind is PlanKind.TEXT:
|
if self.kind is PlanKind.TEXT:
|
||||||
value["text"] = self.message
|
value["text"] = self.message
|
||||||
|
if self.kind is PlanKind.MULTI_TEXT:
|
||||||
|
value["texts"] = list(self.messages)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[tool.jwebot]
|
[tool.jwebot]
|
||||||
release = "alpha 0.6.5.1015"
|
release = "alpha 0.6.6.1015"
|
||||||
production_deployments_enabled_by_default = false
|
production_deployments_enabled_by_default = false
|
||||||
|
|
||||||
[tool.jwebot.workspace]
|
[tool.jwebot.workspace]
|
||||||
|
|||||||
+106
-436
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from test_jwebot_edge import MODULE, config
|
from test_jwebot_edge import MODULE, config
|
||||||
from jwebot_official_extensions import cs_autochess as AUTOCHESS
|
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):
|
class Release064AutochessTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.temp = tempfile.TemporaryDirectory()
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
self.root = Path(self.temp.name)
|
self.engine = MODULE.EdgeApplication(config(Path(self.temp.name)))
|
||||||
self.engine = MODULE.EdgeApplication(config(self.root))
|
|
||||||
self.counter = 0
|
self.counter = 0
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.engine.close()
|
self.engine.close()
|
||||||
self.temp.cleanup()
|
self.temp.cleanup()
|
||||||
|
|
||||||
def command(
|
def command(self, question, *, sender="Alice", event_id=None):
|
||||||
self,
|
|
||||||
question: str,
|
|
||||||
*,
|
|
||||||
sender: str = "Alice",
|
|
||||||
event_id: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
self.counter += 1
|
self.counter += 1
|
||||||
row = {
|
response = self.engine.accept(
|
||||||
"question": question,
|
{
|
||||||
"sender": sender,
|
"event_id": event_id or f"autochess-{self.counter}",
|
||||||
"group_fingerprint": "group-a",
|
"group_fingerprint": "group-a",
|
||||||
"event_id": event_id or f"autochess-{self.counter}",
|
"sender": sender,
|
||||||
}
|
"text": "@JWeBot " + question,
|
||||||
return self.engine.local.handle(row, {})
|
"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(
|
row = self.engine.ledger.connection.execute(
|
||||||
"""
|
"""
|
||||||
SELECT state_json FROM autochess_runs
|
SELECT state_json FROM autochess_runs
|
||||||
@@ -54,56 +50,40 @@ class Release064AutochessTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(row)
|
self.assertIsNotNone(row)
|
||||||
return json.loads(row[0])
|
return json.loads(row[0])
|
||||||
|
|
||||||
def save_state(self, state: dict) -> None:
|
def start_two_human_two_ai(self):
|
||||||
self.engine.ledger.connection.execute(
|
self.command("/自走棋开始")
|
||||||
"""
|
self.command("/自走棋报名", sender="Bob")
|
||||||
UPDATE autochess_runs SET state_json = ?, state = ?
|
self.command("/自走棋加AI战队")
|
||||||
WHERE group_fingerprint = 'group-a' AND run_id = ?
|
return self.command("/自走棋开赛")
|
||||||
""",
|
|
||||||
(
|
|
||||||
json.dumps(state, ensure_ascii=False, separators=(",", ":")),
|
|
||||||
state["status"],
|
|
||||||
state["run_id"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def launch(self, names: list[str]) -> dict:
|
def finish_draft(self):
|
||||||
self.command("/自走棋开始", sender=names[0])
|
self.command("/选1")
|
||||||
for name in names[1:]:
|
self.command("/选1", sender="Bob")
|
||||||
self.command("/自走棋报名", sender=name)
|
self.command("/选1", sender="Bob")
|
||||||
self.command("/自走棋开赛", sender=names[0])
|
self.command("/选1")
|
||||||
return self.state()
|
self.command("/选1")
|
||||||
|
return self.command("/选1", sender="Bob")
|
||||||
|
|
||||||
def settle_to_completion(self) -> dict:
|
def ready_all(self):
|
||||||
state = self.state()
|
state = self.state()
|
||||||
while state["status"] == "active":
|
replies = []
|
||||||
self.command("/自走棋强制结算")
|
for key in list(state.get("ready_keys") or []):
|
||||||
state = self.state()
|
replies.extend(
|
||||||
return state
|
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(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(
|
self.assertEqual(
|
||||||
len({card["id"] for card in AUTOCHESS.PLAYER_POOL}),
|
len({coach["id"] for coach in AUTOCHESS.COACH_POOL}),
|
||||||
60,
|
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(
|
self.assertEqual(
|
||||||
AUTOCHESS.parse_autochess_action("/自走棋开始"),
|
AUTOCHESS.parse_autochess_action("/自走棋开始"),
|
||||||
("create", ""),
|
("create", ""),
|
||||||
@@ -117,391 +97,81 @@ class Release064AutochessTests(unittest.TestCase):
|
|||||||
("tactic", "残局"),
|
("tactic", "残局"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_round_plan_matches_player_count_without_duplicate_pairs(self):
|
def test_launch_starts_multimessage_coach_draft_not_full_rosters(self):
|
||||||
for size in range(2, 9):
|
response = self.start_two_human_two_ai()
|
||||||
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("/自走棋开赛")
|
|
||||||
state = self.state()
|
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(
|
self.assertEqual(
|
||||||
[stage["kind"] for stage in state["round_plan"]],
|
state["seed_order"][2:],
|
||||||
["pve", "pve", "pve", "semifinal", "final"],
|
[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():
|
for player in state["players"].values():
|
||||||
self.assertEqual(len(player["board"]), 5)
|
self.assertEqual(len(player["board"]), 5)
|
||||||
self.assertEqual(player["coach_level"], 1)
|
self.assertEqual(len(player["bench"]), 3)
|
||||||
self.assertEqual(player["tactic"], "稳定")
|
self.assertTrue(player["coach_id"])
|
||||||
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)
|
|
||||||
|
|
||||||
def test_tactics_coach_and_lineup_changes_preserve_five_starters(self):
|
self.command("/结束回合")
|
||||||
state = self.launch(["Alice", "Bob"])
|
training = self.command("/结束回合", sender="Bob")
|
||||||
alice_key = state["seed_order"][0]
|
self.assertIn("Team Falcons", "\n".join(training))
|
||||||
before_board = copy.deepcopy(state["players"][alice_key]["board"])
|
self.assertIn("Natus Vincere", "\n".join(training))
|
||||||
before_power = AUTOCHESS.lineup_power(
|
self.assertEqual(self.state()["phase"], "training_manage")
|
||||||
state["players"][alice_key]
|
|
||||||
)["power"]
|
|
||||||
|
|
||||||
tactic = self.command("/战术残局")
|
self.command("/结束回合")
|
||||||
self.assertIn("战术设为 残局", tactic)
|
group = self.command("/结束回合", sender="Bob")
|
||||||
coach = self.command("/升级教练")
|
self.assertIn("正赛小组赛", "\n".join(group))
|
||||||
self.assertIn("教练升到 2 级", coach)
|
|
||||||
state = self.state()
|
state = self.state()
|
||||||
alice = state["players"][alice_key]
|
self.assertEqual(state["phase"], "group_ready")
|
||||||
self.assertEqual(alice["board"], before_board)
|
self.assertEqual(len(state["group_matches"]), 6)
|
||||||
self.assertEqual(len(alice["board"]), 5)
|
|
||||||
self.assertGreater(AUTOCHESS.lineup_power(alice)["power"], before_power)
|
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()
|
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):
|
def test_multimessage_response_replays_identically(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):
|
|
||||||
self.command("/自走棋开始")
|
self.command("/自走棋开始")
|
||||||
self.assertIn(
|
|
||||||
"2 名或 3 名真人",
|
|
||||||
self.command("/自走棋加AI战队"),
|
|
||||||
)
|
|
||||||
self.command("/自走棋报名", sender="Bob")
|
self.command("/自走棋报名", sender="Bob")
|
||||||
self.command("/自走棋报名", sender="Carol")
|
self.command("/自走棋加AI战队")
|
||||||
self.assertIn(
|
first = self.command("/自走棋开赛", event_id="same-launch")
|
||||||
"只有主持人",
|
replay = self.command("/自走棋开赛", event_id="same-launch")
|
||||||
self.command("/自走棋加AI战队", sender="Bob"),
|
self.assertEqual(first, replay)
|
||||||
)
|
self.assertGreater(len(first), 1)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+39
-72
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ from test_jwebot_edge import MODULE, config
|
|||||||
from jwebot_official_extensions import cs_autochess as AUTOCHESS
|
from jwebot_official_extensions import cs_autochess as AUTOCHESS
|
||||||
|
|
||||||
|
|
||||||
class Release065TwoHumanAiTests(unittest.TestCase):
|
class Release065AiEnrollmentTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.temp = tempfile.TemporaryDirectory()
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
self.engine = MODULE.EdgeApplication(config(Path(self.temp.name)))
|
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):
|
def command(self, question, *, sender="Alice", event_id=None):
|
||||||
self.counter += 1
|
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}",
|
"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):
|
def state(self):
|
||||||
row = self.engine.ledger.connection.execute(
|
row = self.engine.ledger.connection.execute(
|
||||||
@@ -45,88 +50,50 @@ class Release065TwoHumanAiTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(row)
|
self.assertIsNotNone(row)
|
||||||
return json.loads(row[0])
|
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("/自走棋开始")
|
||||||
self.command("/自走棋报名", sender="Bob")
|
self.command("/自走棋报名", sender="Bob")
|
||||||
added = self.command("/自走棋加AI战队")
|
added = "\n".join(self.command("/自走棋加AI战队"))
|
||||||
|
|
||||||
self.assertIn("AI 战队 A、AI 战队 B", added)
|
self.assertIn("AI 战队 A、AI 战队 B", added)
|
||||||
self.assertIn("第 3、4 种子", added)
|
|
||||||
self.assertIn("2 人+2 AI", added)
|
self.assertIn("2 人+2 AI", added)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"已经锁定参赛名单",
|
"已经锁定",
|
||||||
self.command("/自走棋报名", sender="Carol"),
|
"\n".join(self.command("/自走棋报名", sender="Carol")),
|
||||||
)
|
)
|
||||||
|
launch = "\n".join(self.command("/自走棋开赛"))
|
||||||
launch = self.command("/自走棋开赛")
|
|
||||||
state = self.state()
|
state = self.state()
|
||||||
self.assertIn("2 人+2 AI 测试赛", launch)
|
self.assertIn("2 人+2 AI 测试赛", launch)
|
||||||
self.assertEqual(state["ai_test_variant"], "two_plus_two")
|
self.assertEqual(state["ai_test_variant"], "two_plus_two")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
state["seed_order"][2:],
|
state["seed_order"][2:],
|
||||||
[
|
[AUTOCHESS.AI_PERSON_KEY, AUTOCHESS.AI_SECOND_PERSON_KEY],
|
||||||
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]],
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("到 bob 做准备了", self.command("/结束回合"))
|
def test_three_humans_add_one_ai(self):
|
||||||
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):
|
|
||||||
self.command("/自走棋开始")
|
self.command("/自走棋开始")
|
||||||
self.command("/自走棋报名", sender="Bob")
|
self.command("/自走棋报名", sender="Bob")
|
||||||
first = self.command(
|
self.command("/自走棋报名", sender="Carol")
|
||||||
"/自走棋加AI战队",
|
added = "\n".join(self.command("/自走棋加AI战队"))
|
||||||
event_id="same-add-two-ai",
|
self.assertIn("3 人+1 AI", added)
|
||||||
)
|
|
||||||
replay = self.command(
|
self.command("/自走棋开赛")
|
||||||
"/自走棋加AI战队",
|
state = self.state()
|
||||||
event_id="same-add-two-ai",
|
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.assertEqual(first, replay)
|
||||||
self.assertIn(
|
self.assertIn("已经加入", "\n".join(self.command("/自走棋加AI战队")))
|
||||||
"已经加入",
|
|
||||||
self.command("/自走棋加AI战队"),
|
|
||||||
)
|
|
||||||
state = self.state()
|
state = self.state()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sum(
|
sum(bool(player.get("is_ai")) for player in state["players"].values()),
|
||||||
bool(player.get("is_ai"))
|
|
||||||
for player in state["players"].values()
|
|
||||||
),
|
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ class PlanContractTests(unittest.TestCase):
|
|||||||
self.assertEqual(plan.kind, PlanKind.TEXT)
|
self.assertEqual(plan.kind, PlanKind.TEXT)
|
||||||
self.assertEqual(plan.to_mapping(), {"kind": "text", "text": "hello"})
|
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):
|
def test_no_reply_plan_cannot_contain_text(self):
|
||||||
with self.assertRaisesRegex(ContractViolation, "cannot contain text"):
|
with self.assertRaisesRegex(ContractViolation, "cannot contain text"):
|
||||||
ReplyPlan(kind=PlanKind.NO_REPLY, message="unexpected")
|
ReplyPlan(kind=PlanKind.NO_REPLY, message="unexpected")
|
||||||
|
|||||||
@@ -337,6 +337,37 @@ class LayoutScalingTests(unittest.TestCase):
|
|||||||
self.assertEqual(reply, "本地回复喵")
|
self.assertEqual(reply, "本地回复喵")
|
||||||
legacy.assert_not_called()
|
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):
|
def test_fast_ack_is_sent_by_callback_without_upstream_duplicate(self):
|
||||||
legacy = mock.Mock(return_value="legacy")
|
legacy = mock.Mock(return_value="legacy")
|
||||||
fast_ack_sender = mock.Mock(return_value=True)
|
fast_ack_sender = mock.Mock(return_value=True)
|
||||||
@@ -400,7 +431,7 @@ class LayoutScalingTests(unittest.TestCase):
|
|||||||
side_effect=MODULE.jwebot_edge.EdgeError("unavailable"),
|
side_effect=MODULE.jwebot_edge.EdgeError("unavailable"),
|
||||||
):
|
):
|
||||||
reply = MODULE.edge_router_reply(
|
reply = MODULE.edge_router_reply(
|
||||||
{"event_id": "event-1", "text": "@JWeBot #状态"},
|
{"event_id": "event-1", "text": "#状态"},
|
||||||
legacy_router=legacy,
|
legacy_router=legacy,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ def _handler(context: SingleGroupContext, command: str):
|
|||||||
"event": event.to_mapping(),
|
"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")
|
text = response.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
raise ValueError("local service returned invalid text")
|
raise ValueError("local service returned invalid text")
|
||||||
|
|||||||
@@ -644,6 +644,7 @@ def edge_router_reply(
|
|||||||
*,
|
*,
|
||||||
legacy_router: Any,
|
legacy_router: Any,
|
||||||
fast_ack_sender: Callable[[dict[str, Any], dict[str, Any]], bool] | None = None,
|
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:
|
) -> str:
|
||||||
"""Use the local edge if healthy, otherwise preserve the existing cloud route."""
|
"""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 legacy_router(event)
|
||||||
return "JWeBot Error: Local edge unavailable"
|
return "JWeBot Error: Local edge unavailable"
|
||||||
if response.get("status") == "reply":
|
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", ""))
|
reply = str(response.get("text", ""))
|
||||||
if reply and len(reply) <= 900:
|
if reply and len(reply) <= 900:
|
||||||
if response.get("fast_ack") is True:
|
if response.get("fast_ack") is True:
|
||||||
@@ -986,10 +1015,62 @@ def run() -> None:
|
|||||||
)
|
)
|
||||||
return True
|
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(
|
module.route_event = lambda event: edge_router_reply(
|
||||||
event,
|
event,
|
||||||
legacy_router=legacy_router,
|
legacy_router=legacy_router,
|
||||||
fast_ack_sender=send_fast_ack,
|
fast_ack_sender=send_fast_ack,
|
||||||
|
reply_sender=send_multi_reply,
|
||||||
)
|
)
|
||||||
wait_for_ready(module)
|
wait_for_ready(module)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user