feat: add CS Major autochess tournament
check / local-gate (push) Failing after 2m1s

This commit is contained in:
JWeBot Contributors
2026-10-15 15:17:32 +08:00
parent f07ae653c7
commit e713ff369a
26 changed files with 3901 additions and 20 deletions
+33
View File
@@ -1,5 +1,38 @@
# Changelog # Changelog
## alpha 0.6.4.1015
- Add a reviewed 2-8 player CS Major autochess extension with a fixed
five-player starting lineup, one always-active coach, an eight-slot bench,
and a six-candidate shop that shows complete game data.
- Ship a fixed 60-player local pool with rifle, sniper, and in-game-leader
roles; team and nation synergies; shared copy counts; and deterministic
three-copy/two-star and nine-copy/three-star upgrades.
- Keep all five player slots occupied. Lineup changes are one-for-one swaps,
starters cannot be sold directly, and coach upgrades only add a 2%-10%
team bonus.
- Add player-selected firepower, stability, and clutch tactics. Resolve
stability over firepower, firepower over clutch, clutch over stability,
plus sniper/rifle/leader role counters before bounded +/-2% map variance.
- Generate the competition format once at tournament start: two players go
directly to the final, three play a round robin for two final places, four
use seeded 1v4 and 2v3 semifinals, and five to eight play a complete round
robin for four semifinal places.
- Add an explicit three-human-plus-one-AI test mode. The host can add one
deterministic local AI as seed four; it buys players, upgrades its coach,
swaps its strongest lineup, and selects a counter tactic without an online
model.
- Give Taffy a persistent host state machine: welcome and format briefing,
rotating human preparation order, turn enforcement, explicit handoffs,
round introductions, matchup calls, overtime narration, advancement, and
championship presentation.
- Run group matches as BO3 and knockout matches as BO5. Simulate every map
under MR15, including 15-15 overtime, 19-17/22-20 scores, and separately
archived overtime stories.
- Persist run state, idempotent action responses, structured battles, and
every generated story in serialized SQLite transactions. Invalid archives
roll back the full action.
## alpha 0.6.3.1015 ## alpha 0.6.3.1015
- Replace instant personal lottery tickets with one persistent multiplayer - Replace instant personal lottery tickets with one persistent multiplayer
+11 -2
View File
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 GUI 发送链路回复。
当前版本:`alpha 0.6.3.1015`。 当前版本:`alpha 0.6.4.1015`。
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加 0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见 两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
@@ -20,6 +20,12 @@ 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 人
公开商店、属性/定位/战术克制、小组单循环、1v4 与 2v3 淘汰赛,以及
BO3/BO5 的 MR15 加时赛。支持三名真人加入一支确定性 AI 测试战队;
Taffy 负责赛制播报、顺序准备、交棒和全程剧情主持。所有选秀、战斗和
加时剧情均在本地事务内落档。
完整规则见[CS Major 自走棋设计稿](docs/cs-major-autochess-design.md)。
### 运行流程 ### 运行流程
@@ -90,6 +96,9 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 /21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
/游戏记录 [页码](/gamehistory) /游戏记录 [页码](/gamehistory)
/游戏帮助(/games) /游戏帮助(/games)
/自走棋开始|报名|开赛|状态|结算|档案
/自走棋加AI战队(三名真人测试模式)
/选1~6;/战术 火力|稳定|残局;/阵容;/结束回合
/丢骰子 [NdM] /丢骰子 [NdM]
/猜数字 开始 [上限] /猜数字 开始 [上限]
/石头剪刀布 [开始|加入|出|状态|结算|结束] /石头剪刀布 [开始|加入|出|状态|结算|结束]
@@ -258,7 +267,7 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```text
alpha 0.6.3.1015 alpha 0.6.4.1015
``` ```
## Runtime Flow ## Runtime Flow
+1 -1
View File
@@ -1 +1 @@
alpha 0.6.3.1015 alpha 0.6.4.1015
+658
View File
@@ -0,0 +1,658 @@
# CS Major 自走棋设计稿
本文档定义一个完全本地、确定性结算、以 CS 职业选手为棋子的群聊
小游戏方案。赛事包装统一为“第 n 届 IEM 盐田 Major”。AI 默认负责主持、
裁判、播报和剧情生成;主持人也可显式加入一支本地确定性 AI 战队。
## 1. 设计目标
1. 做成一个多人报名、多人运营、多人对战的群聊自走棋。
2. 玩法重心放在每轮选秀和阵容运营,而不是复杂微操。
3. 用 Major 赛事包装 PvE、小组单循环、淘汰赛和终局剧情。
4. 结算逻辑必须完全本地、可重放、可持久化、可回档验证。
5. 每一段剧情都要落档,且剧情只能在数值结算之后生成。
## 2. 核心体验
一句话定义:
`公开 6 人选秀 -> 凑羁绊与升星 -> 打 PvE 拿资源 -> 打 PvP 跑 Major 赛程 -> 保存每轮赛报与剧情`
玩家感受到的乐趣应当主要来自:
- 抢 6 人选秀里的关键卡;
- 决定要不要升教练等级、保经济还是强行 D 牌;
- 赌阵容成型时点;
- 看小组单循环和淘汰赛赛报;
- 看最终冠军剧情与本届 Major 档案。
## 3. 对局对象与规模
### 3.1 报名
- 开局命令由任意玩家发起。
- 其他玩家在报名窗口内加入。
- 建议一局支持 `2~8` 名真人玩家。
- 主持人可在报名阶段发送 `/自走棋加AI战队`,最多加入一支 AI 战队。
- 三名真人加一支 AI 时,按四队赛制直接生成 `1v4`、`2v3` 半决赛。
- AI 占一个参赛席位,但不对应群成员,也不调用在线模型。
- AI 每回合以确定性规则买牌、升级教练、调整首发和选择克制战术。
### 3.2 玩家初始状态
每位玩家开局拥有:
- 生命值:`100`
- 金币:`10`
- 教练等级:`1`
- 完整首发:`5 名一星选手 + 1 名教练`
- 阵容位:固定 `5 个选手位 + 1 个教练位`,比赛时必须全部上场
- 备战席:`8 格`
- 连胜/连败计数:`0`
- 本局独立事件档案编号
开赛时系统从低费池为每位玩家确定性分配 5 名首发选手,保证第一场
比赛就是完整的五人阵容。后续选秀用于替换、强化和升星,而不是解锁
上场人口。
## 4. 选手池设计
### 4.1 选手来源
只使用“近期非常出名”的职业选手。第一期池子优先覆盖以下俱乐部与明星:
- Spirit
- Vitality
- Falcons
- G2
- FaZe
- NAVI
- MOUZ
- The MongolZ
- Astralis
- Liquid
- FURIA
- Eternal Fire
选手数据不是对真实比赛数据的一比一复刻,而是基于公众认知做游戏化映射。
首发版本固定维护 `60` 名棋子,运行期不在线抓取或改写数值。
### 4.2 费用层级
选手分为 `1~5 费`,越高费越稀有,基础数值和成长更强。
示例分层:
- `5 费`:donk、ZywOo
- `4 费`:m0NESY、NiKo、ropz、frozen
- `3 费`:apEX、b1t、xertioN、w0nderful、malbsMd
- `2 费`:辅助型或中坚选手
- `1 费`:前期过渡与羁绊补齐牌
正式实现时应维护一份固定的选手静态表,不允许运行期随意漂移。
### 4.3 定位
只保留三个核心定位:
- `步枪手`
- `狙击手`
- `指挥`
定位不是羁绊,而是战斗中的属性倾向与技能触发来源。
### 4.4 羁绊
只保留两类羁绊:
1. `战队羁绊`
2. `国家羁绊`
战队羁绊可来自:
- 当前主战队;
- 历史待过的关键战队;
- 每名选手最多挂 `3` 条战队标签,避免过度泛化。
国家羁绊只有 `1` 条。
## 5. 选手数值
### 5.1 基础数值轴
为避免面板过宽,第一期只展示三个主数值:
- `火力`
- `稳定`
- `残局`
其中指挥位额外拥有隐藏或显式的 `指挥` 修正值,用于影响全队。
### 5.2 数值范围
- 1 费:`52~68`
- 2 费:`60~76`
- 3 费:`68~84`
- 4 费:`78~92`
- 5 费:`88~98`
要求同费位内部也要有明显差异,不能所有 4 费都只有一两点波动。
### 5.3 派生战斗权重
可先采用以下简单模型:
`单卡战力 = 火力 * 0.45 + 稳定 * 0.30 + 残局 * 0.25 + 星级修正 + 羁绊修正 + 教练修正`
若定位是狙击手,可给火力更高上限;
若定位是指挥,可给团队修正更高权重。
### 5.4 属性与定位克制
战斗不能只比较总战力后掷骰子。三项主属性形成闭环:
- `稳定克火力`:稳定阵容能吸收强攻的第一波冲击;
- `火力克残局`:前期人数优势会压缩残局发挥空间;
- `残局克稳定`:高残局能力能打破按部就班的默认局面。
三个定位也形成轻量闭环:
- `狙击手克步枪手`
- `步枪手克指挥`
- `指挥克狙击手`
玩家每回合可选择 `/战术 火力|稳定|残局`。战术选择会强化对应属性,
并在克制对方战术时获得额外修正。属性、定位和战术的总克制修正上限
应明显高于随机波动,让调阵和判断对手比运气更重要。
## 6. 升星规则
- `3 张同名 = 2 星`
- `9 张同名 = 3 星`
升星收益建议:
- 2 星:基础战力乘 `1.35`
- 3 星:基础战力乘 `1.80`
升星后保留原有羁绊与定位,不改变标签。
## 7. 教练系统
教练位不是普通战斗单位,但始终与 5 名选手一起上场。
### 7.1 教练等级作用
教练只提供额外的全队战术加成,不控制上场人数,也不占用选手位。
建议加成:
- 1 级:全队战力 `+2%`
- 2 级:全队战力 `+4%`
- 3 级:全队战力 `+6%`
- 4 级:全队战力 `+8%`
- 5 级:全队战力 `+10%`
### 7.2 教练升级
玩家可花金币升级教练经验。
建议升级费用为 `6 / 10 / 16 / 24` 金币。升级只提高上述战术加成。
高费选手的商店概率改为随赛事回合推进,不再绑定教练等级。
## 8. 每轮 6 人选秀
这是本模式的核心。
### 8.1 固定规则
- 每轮只展示 `6` 名候选选手。
- 这 `6` 人对所有玩家公开可见。
- 每位玩家始终保有 5 名首发;购买的新选手进入备战席或与同名选手合成。
- 调整阵容时必须用备战席选手替换首发,不能让首发少于 5 人。
- 第一版采用“公开同池、各自购买”的简化方案:
- 所有人看到同一组 6 人;
- 某个玩家选择后,不会把该候选从其他玩家界面里抢空;
- 但底层共享总卡池仍然会影响后续刷新概率。
- 每名候选都必须展示完整数据:
- 编号 `1~6`
- 选手名
- 费用
- 定位
- 国家
- 主战队
- 历史战队
- 主数值
- 玩家通过编号选择,不输入复杂自然语言。
### 8.2 选秀指令
建议指令面如下:
```text
/自走棋开始
/自走棋报名
/自走棋加AI战队
/自走棋状态
/选1
/选2
/选3
/选4
/选5
/选6
/刷新
/锁定
/升级教练
/战术 火力|稳定|残局
/上阵 备战编号 [首发位]
/下阵 首发位 备战编号
/出售 备战编号
/阵容
/赛程
/结束回合
```
中文是主命令,英文别名可以后补。
### 8.3 刷新与锁定
- 每轮默认展示一组 6 人。
- 玩家可支付金币执行刷新。
- 玩家可锁定本轮 6 人,使下一轮仍保留同一组候选。
### 8.4 顺位
- 开局首轮选秀顺位随机。
- 后续普通回合不再做蛇形公共抢位,而是每位玩家各自面对同一组 6 人池。
- 如果后续想加竞技性,可在第二阶段引入“共享卡池抢空”机制。
第一期建议先做“公开同池、各自结算”,避免群聊交互过重。
## 9. 回合流程
建议一整回合拆成五段:
1. `回合开始播报`
2. `6 人选秀 / 刷新 / 升级 / 调阵`
3. `PvE 或 PvP 结算`
4. `回合结算与经济发放`
5. `剧情归档`
## 10. PvE 设计
PvE 不叫“打野怪”,统一包装成赛事内活动。
### 10.1 PvE 类型
- 训练赛
- 媒体日
- 热身赛
- 赞助挑战
- 地图专项训练
### 10.2 PvE 作用
PvE 的目标不是淘汰人,而是给资源:
- 金币
- 装备
- 战术卡
- 教练经验
- 临时 buff
### 10.3 PvE 结算逻辑
PvE 对手是模板化敌方阵容或阈值型事件,不需要复杂 AI。
示例:
`玩家总战力 >= 目标战力` 则获胜,否则失败。
这样实现简单,也利于稳定复盘。
## 11. PvP 赛制
### 11.1 赛事包装
整局赛事名称固定为:
`第 n 届 IEM 盐田 Major`
### 11.2 按参赛人数生成赛制
- `2 人`:直接进入决赛。
- `3 支队伍`:进行完整单循环,前 2 名进入决赛。
- `4 人`:不打小组赛,按报名种子直接进行 `1v4`、`2v3` 半决赛。
- `5~8 人`:进行完整小组单循环,每两支队伍只交手一次,前 4 名晋级。
奇数队伍的小组比赛日会有一队轮空,但不会获得胜场。
小组赛每场采用 `BO3`。
### 11.3 小组排名
小组赛首先按胜场排序;同胜场依次比较:
1. 相互交手结果;
2. 当前完整五人阵容战力;
3. 剩余生命值;
4. 固定种子编号。
所有配对在开赛时一次性生成并落档,不允许运行中根据结果重排赛程。
### 11.4 淘汰赛
- 四强固定为 `小组第 1 vs 第 4`、`第 2 vs 第 3`。
- 3 人赛制由小组第 1、2 名直接进入决赛。
- 4 人赛制使用报名种子 `1v4`、`2v3`。
- 2 人赛制直接进入决赛。
- 半决赛胜者进入总决赛,决出本届冠军。
- 半决赛和总决赛均采用 `BO5`。
## 12. 战斗结算
### 12.1 目标
战斗要做到:
- 本地确定性;
- 单轮可解释;
- 支持战报;
- 不依赖在线模型。
### 12.2 简化模型
建议第一期采用“阵容总分 + 对位修正”的轻量模型。
#### 步骤 1:计算阵容基础战力
`阵容基础战力 = 5 名首发选手战力总和`
#### 步骤 2:加上团队修正
团队修正来源:
- 战队羁绊
- 国家羁绊
- 教练等级提供的全队百分比加成
- 装备/战术卡
- 连胜或特殊事件 buff
#### 步骤 3:计算克制修正
依次计算:
1. `稳定 > 火力 > 残局 > 稳定` 的属性对位;
2. `狙击手 > 步枪手 > 指挥 > 狙击手` 的定位对位;
3. 双方本回合主动选择的战术对位。
克制结果必须进入结构化战报,明确展示双方修正值。
#### 步骤 4:加入小幅波动
只允许 `-2% ~ +2%` 的确定性哈希波动。单次运气不能覆盖成型阵容的
属性、定位和战术克制优势。
#### 步骤 5:计算比分
单图采用 `MR15`:
- 常规时间先到 `16` 分获胜;
- 常规时间 `15-15` 进入加时;
- 加时可产生 `19-17`,多轮加时可产生 `22-20` 等比分;
- 每次加时除结构化单图结果外,必须额外生成一段加时剧情并落档。
小组赛按 BO3 先赢 2 图,淘汰赛按 BO5 先赢 3 图。系列赛战报必须展示
大比分、每张图比分、加时次数和克制修正。
## 13. 剧情系统与落档
这是本模式的强制要求。
### 13.1 归档原则
必须先结算,后写剧情。
即:
1. 固化数值结果;
2. 存档回合事实;
3. 基于事实生成文字赛报;
4. 再把文字赛报作为附加档案保存。
### 13.2 必须落档的节点
- 开局报名完成
- 每轮 6 人选秀结果
- 每次刷新和关键升星
- 每次 PvE 事件结果
- 每场 PvP 结果
- 小组赛排名、晋级和淘汰节点
- 淘汰赛对阵与结果
- 冠军总结
### 13.3 档案结构
建议至少保存两类记录:
1. `结构化事件档`
2. `文本剧情档`
结构化事件档用于回放和调试;
文本剧情档用于玩家阅读。
示例:
```json
{
"run_id": "major_0001",
"round": 4,
"phase": "group_2",
"event_type": "pvp_result",
"player_a": "Jacky",
"player_b": "Alice",
"winner": "Jacky",
"score_band": "close_win",
"roster_a_power": 418,
"roster_b_power": 403
}
```
## 14. 数据持久化设计
建议新增以下 SQLite 表。
### 14.1 对局主表
`autochess_runs`
- run_id
- group_fingerprint
- season_number
- state
- created_at
- updated_at
### 14.2 玩家状态表
`autochess_players`
- run_id
- person_key
- display_name
- hp
- gold
- coach_level
- coach_xp
- wins
- losses
- streak
- board_json
- bench_json
- shop_json
- locked_shop
### 14.3 选秀记录表
`autochess_shop_events`
- run_id
- round_number
- person_key
- visible_pool_json
- action_type
- action_payload_json
- created_at
### 14.4 战斗结果表
`autochess_battles`
- run_id
- round_number
- phase
- battle_type
- player_a
- player_b
- result_json
- created_at
### 14.5 剧情归档表
`autochess_story_logs`
- run_id
- round_number
- phase
- story_type
- story_text
- facts_json
- source_event_id
- created_at
### 14.6 幂等与重放表
`autochess_action_replay`
- run_id
- person_key
- round_number
- event_id
- action_kind
- response_text
- created_at
## 15. 回放与幂等
因为群聊环境会出现重复事件、OCR 重读和命令重放,必须保证:
- 同一事件只执行一次;
- 同一回合不能重复发奖或重复扣款;
- 同一段剧情不能重复写多份版本;
- 回合失败时整段事务回滚。
建议沿用现有本地游戏的幂等思路,为自走棋单独增加 action replay 表。
## 16. 指令交互风格
建议所有回复都遵循现有小游戏风格:
`用户名 #局号:内容`
例如:
```text
Jacky #3:第 4 回合开始,你当前 32 金币,教练 3 级。
本轮选秀如下:
1. donk 5费 步枪手 Spirit 俄罗斯 火力96 稳定88 残局86
2. apEX 3费 指挥 Vitality 法国 火力68 稳定81 指挥95
...
发送“选1”到“选6”,或发送“刷新”“锁定”“升级教练”。
```
### 16.1 Taffy 主持状态机
Taffy 必须承担完整赛事主持,而不是只在结算时出现。
开赛时必须包含:
- `欢迎大家来到第 n 届 IEM 盐田 Major`
- 本届参赛者和种子
- 当前人数对应的完整赛制
- 小组赛 BO3、淘汰赛 BO5、单图 MR15 和加时规则
- 当前回合内容
- 第一位需要准备的玩家
每回合按真人玩家生成确定性的准备顺序。AI 自动准备,不占真人操作轮次。
只有当前玩家可以购买、刷新、选择战术和调整阵容。提交后必须明确交棒:
```text
Taffy:Jacky,请做好赛前准备;完成后发送 /结束回合。
Taffy:Jacky 准备完成!到 Alice 做准备了。
Taffy:Alice 准备完成!全员准备完毕,主持人可以结算。
```
下一回合轮换首位准备者,避免固定由同一个人总是先操作。
以下节点都必须由 Taffy 主持并落档:
- 报名开启和新选手加入
- AI 测试战队加入
- 赛制与种子公布
- 每位玩家开始/完成准备
- 每轮开始和结算
- 每场 BO3/BO5 对阵与结果
- 每张加时地图
- 小组排名和前四晋级
- 1v4、2v3 半决赛
- 总决赛和冠军颁奖
## 17. 实现顺序建议
建议按三个阶段落地。
### 第一阶段:最小可玩版
- 建局 / 报名 / 状态
- 每轮 6 人选秀
- 买卡、卖卡、升星
- 教练等级
- 简化 PvE
- 简化 PvP
- 基础剧情归档
### 第二阶段:赛事化增强
- 小组单循环完整配对
- 1v4、2v3 淘汰赛树
- 羁绊与装备系统细化
- 战报模板优化
### 第三阶段:内容扩展
- 在固定 60 人首发池基础上增加新赛季棋子
- 更多俱乐部和国家
- 特殊赛事事件
- 更细的剧情风格与本届 Major 年鉴
## 18. 第一版明确不做的内容
为保证能上线,第一版先不做:
- 实时地图微操
- 复杂技能动画
- 过多职业分类
- 在线爬取实时战队数据
- 大量开放文本输入解析
## 19. 最终结论
这套模式最适合按“强运营、轻操作、重赛报”路线实现。
最重要的三个锚点已经确定:
1. `每轮固定 6 人公开选秀`
2. `PvE 负责资源,PvP 负责小组单循环与淘汰赛`
3. `每一段剧情都必须落档`
如果按这个设计推进,实现出来的成品会更像一场可回放、可复盘、
可连载的本地 Major 模拟器,而不是一个只有数值结算的普通抽卡游戏。
@@ -0,0 +1,30 @@
# cs-major-autochess
Author: `jwebot`
CS自走棋
Help:
- `/自走棋开始|报名`
Registered commands:
- `/自走棋`
- `/cs自走棋`
- `/autochess`
- `/选`
- `/刷新`
- `/锁定`
- `/战术`
- `/升级教练`
- `/上阵`
- `/下阵`
- `/出售`
- `/阵容`
- `/赛程`
- `/结束回合`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `cs-major-autochess` Kernel service profile
and returns a structured `ReplyPlan`.
@@ -0,0 +1,14 @@
id = "cs-major-autochess"
author = "jwebot"
description = "CS自走棋"
help = ["/自走棋开始|报名"]
version = "1.0.0"
api_version = "1"
entrypoint = "cs_major_autochess:plugin"
type = "command"
[capabilities]
commands = ["/自走棋", "/cs自走棋", "/autochess", "/选", "/刷新", "/锁定", "/战术", "/升级教练", "/上阵", "/下阵", "/出售", "/阵容", "/赛程", "/结束回合"]
storage = false
network_profiles = []
service_profiles = ["cs-major-autochess"]
@@ -0,0 +1,37 @@
"""Generated reviewed adapter for cs-major-autochess."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'cs-major-autochess'
AUTHOR = 'jwebot'
DESCRIPTION = 'CS自走棋'
HELP_ENTRIES = ('/自走棋开始|报名',)
COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from cs_major_autochess import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, 'CS自走棋')
self.assertEqual(HELP_ENTRIES, ('/自走棋开始|报名',))
self.assertIn('/自走棋', registered)
self.assertEqual(len(registered), 14)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-config" name = "jwebot-config"
version = "0.6.3a0+1015" version = "0.6.4a0+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.3a0+1015" __version__ = "0.6.4a0+1015"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-extension-host" name = "jwebot-extension-host"
version = "0.6.3a0+1015" version = "0.6.4a0+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.3a0+1015", "jwebot-sdk==0.6.4a0+1015",
"jwebot-kernel==0.6.3a0+1015", "jwebot-kernel==0.6.4a0+1015",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "0.6.3a0+1015" __version__ = "0.6.4a0+1015"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-kernel" name = "jwebot-kernel"
version = "0.6.3a0+1015" version = "0.6.4a0+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.3a0+1015", "jwebot-config==0.6.4a0+1015",
"jwebot-sdk==0.6.3a0+1015", "jwebot-sdk==0.6.4a0+1015",
"jwebot-official-extensions==0.6.3a0+1015", "jwebot-official-extensions==0.6.4a0+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.3a0+1015" __version__ = "0.6.4a0+1015"
@@ -1070,6 +1070,71 @@ class Ledger:
ON group_lottery_entries( ON group_lottery_entries(
group_fingerprint, round_number, created_at, person_key group_fingerprint, round_number, created_at, person_key
); );
CREATE TABLE IF NOT EXISTS autochess_runs (
group_fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
season_number INTEGER NOT NULL,
state TEXT NOT NULL,
host_person_key TEXT NOT NULL,
state_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, run_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS autochess_one_active_run
ON autochess_runs(group_fingerprint)
WHERE state IN ('registration', 'active');
CREATE TABLE IF NOT EXISTS autochess_action_replay (
group_fingerprint TEXT NOT NULL,
event_id TEXT NOT NULL,
run_id TEXT NOT NULL,
person_key TEXT NOT NULL,
command TEXT NOT NULL,
response_text TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, event_id)
);
CREATE TABLE IF NOT EXISTS autochess_story_logs (
group_fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
story_id TEXT NOT NULL,
round_number INTEGER NOT NULL,
phase TEXT NOT NULL,
story_type TEXT NOT NULL,
story_text TEXT NOT NULL,
facts_json TEXT NOT NULL,
source_event_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, run_id, story_id),
FOREIGN KEY (group_fingerprint, run_id)
REFERENCES autochess_runs(group_fingerprint, run_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS autochess_battles (
group_fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
battle_id TEXT NOT NULL,
round_number INTEGER NOT NULL,
phase TEXT NOT NULL,
battle_type TEXT NOT NULL,
player_a TEXT NOT NULL,
player_b TEXT NOT NULL,
result_json TEXT NOT NULL,
source_event_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, run_id, battle_id),
FOREIGN KEY (group_fingerprint, run_id)
REFERENCES autochess_runs(group_fingerprint, run_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS autochess_story_run_round
ON autochess_story_logs(
group_fingerprint, run_id, round_number, story_id
);
CREATE INDEX IF NOT EXISTS autochess_battle_run_round
ON autochess_battles(
group_fingerprint, run_id, round_number, battle_id
);
CREATE INDEX IF NOT EXISTS events_group_created CREATE INDEX IF NOT EXISTS events_group_created
ON events(group_fingerprint, created_at); ON events(group_fingerprint, created_at);
CREATE INDEX IF NOT EXISTS outbox_due CREATE INDEX IF NOT EXISTS outbox_due
@@ -3634,6 +3699,250 @@ class Ledger:
self.connection.execute("ROLLBACK") self.connection.execute("ROLLBACK")
raise raise
def _autochess_action_replay(
self,
group: str,
event_id: str,
person_key: str,
command: str,
) -> str | None:
previous = self.connection.execute(
"""
SELECT person_key, command, response_text
FROM autochess_action_replay
WHERE group_fingerprint = ? AND event_id = ?
""",
(group, event_id),
).fetchone()
if previous is None:
return None
if previous["person_key"] != person_key or previous["command"] != command:
raise LocalCommandError("同一事件不能变更自走棋玩家或指令")
return str(previous["response_text"])
def _load_autochess_run(
self,
group: str,
) -> tuple[sqlite3.Row | None, dict[str, Any] | None]:
row = self.connection.execute(
"""
SELECT * FROM autochess_runs
WHERE group_fingerprint = ?
ORDER BY
CASE WHEN state IN ('registration', 'active') THEN 0 ELSE 1 END,
season_number DESC
LIMIT 1
""",
(group,),
).fetchone()
if row is None:
return None, None
try:
state = json.loads(str(row["state_json"]))
except json.JSONDecodeError as error:
raise LocalCommandError("自走棋状态无效") from error
if not isinstance(state, dict):
raise LocalCommandError("自走棋状态无效")
return row, state
@staticmethod
def _validate_autochess_state(state: dict[str, Any]) -> None:
status = state.get("status")
if status not in {"registration", "active", "completed", "cancelled"}:
raise LocalCommandError("自走棋赛事状态无效")
if (
not isinstance(state.get("run_id"), str)
or not state["run_id"]
or type(state.get("season_number")) is not int
or int(state["season_number"]) < 1
or not isinstance(state.get("host_person_key"), str)
or not state["host_person_key"]
or not isinstance(state.get("players"), dict)
):
raise LocalCommandError("自走棋赛事结构无效")
if status in {"active", "completed"}:
players = state["players"].values()
if any(
not isinstance(player, dict)
or not isinstance(player.get("board"), list)
or len(player["board"]) != 5
for player in players
):
raise LocalCommandError("自走棋比赛必须保持五人首发")
def apply_autochess_action(
self,
group: str,
sender: str,
event_id: str,
command: str,
action: Callable[
[dict[str, Any] | None, dict[str, str], int],
dict[str, Any],
],
) -> str:
"""Commit autochess state, replay, stories and battles atomically."""
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
replay = self._autochess_action_replay(
group,
event_id,
person_key,
command,
)
if replay is not None:
self.connection.execute("COMMIT")
return replay
stored, state = self._load_autochess_run(group)
next_season = int(
self.connection.execute(
"""
SELECT COALESCE(MAX(season_number), 0) + 1
FROM autochess_runs
WHERE group_fingerprint = ?
""",
(group,),
).fetchone()[0]
)
result = action(state, person, next_season)
if not isinstance(result, dict):
raise LocalCommandError("自走棋动作结果无效")
response = result.get("response")
new_state = result.get("state")
stories = result.get("stories", [])
battles = result.get("battles", [])
if not isinstance(response, str) or not response.strip():
raise LocalCommandError("自走棋响应无效")
if not isinstance(stories, list) or not isinstance(battles, list):
raise LocalCommandError("自走棋归档无效")
timestamp = utc_now()
run_id = str(stored["run_id"]) if stored is not None else ""
if new_state is not None:
if not isinstance(new_state, dict):
raise LocalCommandError("自走棋状态无效")
self._validate_autochess_state(new_state)
run_id = str(new_state["run_id"])
season_number = int(new_state["season_number"])
existing = self.connection.execute(
"""
SELECT created_at FROM autochess_runs
WHERE group_fingerprint = ? AND run_id = ?
""",
(group, run_id),
).fetchone()
created_at = (
str(existing["created_at"])
if existing is not None
else timestamp
)
self.connection.execute(
"""
INSERT INTO autochess_runs (
group_fingerprint, run_id, season_number, state,
host_person_key, state_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(group_fingerprint, run_id) DO UPDATE SET
season_number = excluded.season_number,
state = excluded.state,
host_person_key = excluded.host_person_key,
state_json = excluded.state_json,
updated_at = excluded.updated_at
""",
(
group,
run_id,
season_number,
str(new_state["status"]),
str(new_state["host_person_key"]),
canonical_json(new_state),
created_at,
timestamp,
),
)
for item in stories:
if (
not isinstance(item, dict)
or not isinstance(item.get("story_id"), str)
or not isinstance(item.get("text"), str)
or not isinstance(item.get("facts", {}), dict)
):
raise LocalCommandError("自走棋剧情归档无效")
self.connection.execute(
"""
INSERT INTO autochess_story_logs (
group_fingerprint, run_id, story_id,
round_number, phase, story_type, story_text,
facts_json, source_event_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
group,
run_id,
str(item["story_id"]),
int(item.get("round_number", 0)),
str(item.get("phase", "")),
str(item.get("story_type", "")),
str(item["text"]),
canonical_json(item.get("facts", {})),
event_id,
timestamp,
),
)
for item in battles:
if (
not isinstance(item, dict)
or not isinstance(item.get("battle_id"), str)
or not isinstance(item.get("result"), dict)
):
raise LocalCommandError("自走棋战斗归档无效")
self.connection.execute(
"""
INSERT INTO autochess_battles (
group_fingerprint, run_id, battle_id,
round_number, phase, battle_type, player_a,
player_b, result_json, source_event_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
group,
run_id,
str(item["battle_id"]),
int(item.get("round_number", 0)),
str(item.get("phase", "")),
str(item.get("battle_type", "")),
str(item.get("player_a", "")),
str(item.get("player_b", "")),
canonical_json(item["result"]),
event_id,
timestamp,
),
)
self.connection.execute(
"""
INSERT INTO autochess_action_replay (
group_fingerprint, event_id, run_id, person_key,
command, response_text, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
group,
event_id,
run_id,
person_key,
command,
response,
timestamp,
),
)
self.connection.execute("COMMIT")
return response
except BaseException:
self.connection.execute("ROLLBACK")
raise
def status(self) -> dict[str, Any]: def status(self) -> dict[str, Any]:
with self.lock: with self.lock:
events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0] events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0]
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-official-extensions" name = "jwebot-official-extensions"
version = "0.6.3a0+1015" version = "0.6.4a0+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.3a0+1015", "jwebot-sdk==0.6.4a0+1015",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -1,5 +1,17 @@
"""Reviewed JWeBot local command adapters and broker implementation.""" """Reviewed JWeBot local command adapters and broker implementation."""
from .cs_autochess import (
CARD_BY_ID,
COACH_BONUSES,
MAX_PLAYERS,
MIN_PLAYERS,
PLAYER_POOL,
CsMajorAutochess,
build_round_plan,
lineup_power,
matchup_modifier,
parse_autochess_action,
)
from .descriptors import ( from .descriptors import (
DIRECT_KERNEL_COMMANDS, DIRECT_KERNEL_COMMANDS,
DIRECT_KERNEL_COMMAND_KINDS, DIRECT_KERNEL_COMMAND_KINDS,
@@ -35,6 +47,9 @@ from .steam import (
) )
__all__ = [ __all__ = [
"CARD_BY_ID",
"COACH_BONUSES",
"CsMajorAutochess",
"DICE_CHALLENGE_TIMEOUT_SECONDS", "DICE_CHALLENGE_TIMEOUT_SECONDS",
"DEFAULT_STEAM_MIHOMO_PROXY_URL", "DEFAULT_STEAM_MIHOMO_PROXY_URL",
"DIRECT_KERNEL_COMMANDS", "DIRECT_KERNEL_COMMANDS",
@@ -43,8 +58,12 @@ __all__ = [
"LOCAL_COMMAND_ALIASES", "LOCAL_COMMAND_ALIASES",
"LOCAL_EXTENSIONS", "LOCAL_EXTENSIONS",
"LOCAL_GAME_TIMEOUT_SECONDS", "LOCAL_GAME_TIMEOUT_SECONDS",
"MAX_PLAYERS",
"MAX_RPS_PLAYERS", "MAX_RPS_PLAYERS",
"MIN_PLAYERS",
"OFFICIAL_AUTHOR", "OFFICIAL_AUTHOR",
"PLAYER_POOL",
"build_round_plan",
"KernelCommandDescriptor", "KernelCommandDescriptor",
"LocalExtensionDescriptor", "LocalExtensionDescriptor",
"LocalExtensionError", "LocalExtensionError",
@@ -58,8 +77,11 @@ __all__ = [
"deterministic_int", "deterministic_int",
"local_command_parts", "local_command_parts",
"local_extension_command_token", "local_extension_command_token",
"lineup_power",
"matchup_modifier",
"parse_autochess_action",
"extension_for_kind", "extension_for_kind",
"render_feature_list", "render_feature_list",
] ]
__version__ = "0.6.3a0+1015" __version__ = "0.6.4a0+1015"
@@ -0,0 +1,37 @@
"""Generated reviewed adapter for cs-major-autochess."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'cs-major-autochess'
AUTHOR = 'jwebot'
DESCRIPTION = 'CS自走棋'
HELP_ENTRIES = ('/自走棋开始|报名',)
COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -61,6 +61,20 @@ LOCAL_COMMAND_ALIASES = {
"人物": "person_info", "人物": "person_info",
"个人属性": "person_info", "个人属性": "person_info",
"person": "person_info", "person": "person_info",
"自走棋": "cs_autochess",
"cs自走棋": "cs_autochess",
"autochess": "cs_autochess",
"选": "cs_autochess",
"刷新": "cs_autochess",
"锁定": "cs_autochess",
"战术": "cs_autochess",
"升级教练": "cs_autochess",
"上阵": "cs_autochess",
"下阵": "cs_autochess",
"出售": "cs_autochess",
"阵容": "cs_autochess",
"赛程": "cs_autochess",
"结束回合": "cs_autochess",
} }
@@ -141,6 +155,13 @@ LOCAL_EXTENSIONS = (
"game_rules", "game_rules",
), ),
), ),
LocalExtensionDescriptor(
extension_id="cs-major-autochess",
author=JWEBOT_AUTHOR,
description="CS自走棋",
help_entries=("/自走棋开始|报名",),
command_kinds=("cs_autochess",),
),
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="person-directory", extension_id="person-directory",
author=OFFICIAL_AUTHOR, author=OFFICIAL_AUTHOR,
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
from typing import Any, Callable from typing import Any, Callable
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from .cs_autochess import CsMajorAutochess
from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list
from .player_games import PlayerGames from .player_games import PlayerGames
@@ -107,6 +108,7 @@ class LocalCommandEngine:
self.profile_fact_is_safe = profile_fact_is_safe self.profile_fact_is_safe = profile_fact_is_safe
self.profile_expiry = profile_expiry self.profile_expiry = profile_expiry
self.players = PlayerGames(ledger, person_display_label) self.players = PlayerGames(ledger, person_display_label)
self.autochess = CsMajorAutochess(ledger)
def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str: def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str:
question = str(row["question"]) question = str(row["question"])
@@ -128,6 +130,8 @@ class LocalCommandEngine:
"game_rules", "game_rules",
}: }:
return self.reply_cleaner(self.players.handle(row, kind, arguments)) return self.reply_cleaner(self.players.handle(row, kind, arguments))
if kind == "cs_autochess":
return self.reply_cleaner(self.autochess.handle(row))
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":
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-sdk" name = "jwebot-sdk"
version = "0.6.3a0+1015" version = "0.6.4a0+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.3a0+1015" __version__ = "0.6.4a0+1015"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot] [tool.jwebot]
release = "alpha 0.6.3.1015" release = "alpha 0.6.4.1015"
production_deployments_enabled_by_default = false production_deployments_enabled_by_default = false
[tool.jwebot.workspace] [tool.jwebot.workspace]
@@ -83,6 +83,7 @@ class LocalExtensionInventoryTests(unittest.TestCase):
"context-summary": OFFICIAL_AUTHOR, "context-summary": OFFICIAL_AUTHOR,
"person-directory": OFFICIAL_AUTHOR, "person-directory": OFFICIAL_AUTHOR,
"single-player-games": JWEBOT_AUTHOR, "single-player-games": JWEBOT_AUTHOR,
"cs-major-autochess": JWEBOT_AUTHOR,
"person-memory": OFFICIAL_AUTHOR, "person-memory": OFFICIAL_AUTHOR,
"server-status": OFFICIAL_AUTHOR, "server-status": OFFICIAL_AUTHOR,
"jwebot-version": OFFICIAL_AUTHOR, "jwebot-version": OFFICIAL_AUTHOR,
+508
View File
@@ -0,0 +1,508 @@
"""Behavioral coverage for the CS Major autochess extension."""
from __future__ import annotations
import copy
import json
import re
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
from test_jwebot_edge import MODULE, config
from jwebot_official_extensions import cs_autochess as AUTOCHESS
class Release064AutochessTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.engine = MODULE.EdgeApplication(config(self.root))
self.counter = 0
def tearDown(self):
self.engine.close()
self.temp.cleanup()
def command(
self,
question: str,
*,
sender: str = "Alice",
event_id: str | None = None,
) -> str:
self.counter += 1
row = {
"question": question,
"sender": sender,
"group_fingerprint": "group-a",
"event_id": event_id or f"autochess-{self.counter}",
}
return self.engine.local.handle(row, {})
def state(self) -> dict:
row = self.engine.ledger.connection.execute(
"""
SELECT state_json FROM autochess_runs
WHERE group_fingerprint = 'group-a'
ORDER BY season_number DESC
LIMIT 1
"""
).fetchone()
self.assertIsNotNone(row)
return json.loads(row[0])
def save_state(self, state: dict) -> None:
self.engine.ledger.connection.execute(
"""
UPDATE autochess_runs SET state_json = ?, state = ?
WHERE group_fingerprint = 'group-a' AND run_id = ?
""",
(
json.dumps(state, ensure_ascii=False, separators=(",", ":")),
state["status"],
state["run_id"],
),
)
def launch(self, names: list[str]) -> dict:
self.command("/自走棋开始", sender=names[0])
for name in names[1:]:
self.command("/自走棋报名", sender=name)
self.command("/自走棋开赛", sender=names[0])
return self.state()
def settle_to_completion(self) -> dict:
state = self.state()
while state["status"] == "active":
self.command("/自走棋强制结算")
state = self.state()
return state
def test_static_pool_parser_and_attribute_ranges(self):
self.assertEqual(len(AUTOCHESS.PLAYER_POOL), 60)
self.assertEqual(
len({card["id"] for card in AUTOCHESS.PLAYER_POOL}),
60,
)
ranges = {
1: (52, 68),
2: (60, 76),
3: (68, 84),
4: (78, 92),
5: (88, 98),
}
for card in AUTOCHESS.PLAYER_POOL:
with self.subTest(card=card["id"]):
lower, upper = ranges[card["cost"]]
for field in ("firepower", "stability", "clutch"):
self.assertGreaterEqual(card[field], lower)
self.assertLessEqual(card[field], upper)
self.assertIn(card["role"], {"步枪手", "狙击手", "指挥"})
self.assertLessEqual(len(card["teams"]), 3)
if card["role"] == "指挥":
self.assertGreater(card["leadership"], 0)
self.assertEqual(
AUTOCHESS.parse_autochess_action("/自走棋开始"),
("create", ""),
)
self.assertEqual(
AUTOCHESS.parse_autochess_action("/选6"),
("buy", "6"),
)
self.assertEqual(
AUTOCHESS.parse_autochess_action("/战术残局"),
("tactic", "残局"),
)
def test_round_plan_matches_player_count_without_duplicate_pairs(self):
for size in range(2, 9):
keys = [f"p{index}" for index in range(1, size + 1)]
plan = AUTOCHESS.build_round_plan(keys)
group_pairs = [
tuple(sorted(pair))
for stage in plan
if stage["kind"] == "group"
for pair in stage["pairs"]
]
with self.subTest(size=size):
if size == 2:
self.assertFalse(group_pairs)
self.assertFalse(
any(stage["kind"] == "semifinal" for stage in plan)
)
elif size == 4:
semifinal = next(
stage
for stage in plan
if stage["kind"] == "semifinal"
)
self.assertEqual(
semifinal["pairs"],
[["p1", "p4"], ["p2", "p3"]],
)
self.assertFalse(group_pairs)
else:
self.assertEqual(
len(group_pairs),
size * (size - 1) // 2,
)
self.assertEqual(len(group_pairs), len(set(group_pairs)))
self.assertEqual(plan[-1]["kind"], "final")
def test_launch_has_fixed_five_coach_shared_shop_and_complete_data(self):
response = self.command("/自走棋开始")
self.assertTrue(response.startswith("alice #1:"))
self.command("/自走棋报名", sender="Bob")
self.command("/自走棋报名", sender="Carol")
self.command("/自走棋报名", sender="Dave")
response = self.command("/自走棋开赛")
state = self.state()
self.assertEqual(len(state["round_plan"]), 5)
self.assertEqual(
[stage["kind"] for stage in state["round_plan"]],
["pve", "pve", "pve", "semifinal", "final"],
)
shops = []
for player in state["players"].values():
self.assertEqual(len(player["board"]), 5)
self.assertEqual(player["coach_level"], 1)
self.assertEqual(player["tactic"], "稳定")
self.assertEqual(len(player["shop"]), 6)
shops.append(player["shop"])
self.assertTrue(all(shop == shops[0] for shop in shops))
for slot in range(1, 7):
line = next(
value
for value in response.splitlines()
if value.startswith(f"{slot}. ")
)
self.assertRegex(
line,
r"\d费 (?:步枪手|狙击手|指挥).*历史 .*火\d+ 稳\d+ 残\d+",
)
self.assertLessEqual(len(response), MODULE.MAX_REPLY_CHARS)
def test_tactics_coach_and_lineup_changes_preserve_five_starters(self):
state = self.launch(["Alice", "Bob"])
alice_key = state["seed_order"][0]
before_board = copy.deepcopy(state["players"][alice_key]["board"])
before_power = AUTOCHESS.lineup_power(
state["players"][alice_key]
)["power"]
tactic = self.command("/战术残局")
self.assertIn("战术设为 残局", tactic)
coach = self.command("/升级教练")
self.assertIn("教练升到 2 级", coach)
state = self.state()
alice = state["players"][alice_key]
self.assertEqual(alice["board"], before_board)
self.assertEqual(len(alice["board"]), 5)
self.assertGreater(AUTOCHESS.lineup_power(alice)["power"], before_power)
starter_ids = {unit["card_id"] for unit in alice["board"]}
slot = next(
index
for index, card_id in enumerate(alice["shop"], start=1)
if card_id not in starter_ids
)
self.command(f"/选{slot}")
self.assertIn("首发必须保持 5 人", self.command("/出售1"))
swapped = self.command("/上阵 6 2")
self.assertIn("首发仍为完整 5 人", swapped)
state = self.state()
self.assertEqual(len(state["players"][alice_key]["board"]), 5)
def test_replay_debits_one_purchase_and_invalid_archive_rolls_back(self):
state = self.launch(["Alice", "Bob"])
alice_key = state["seed_order"][0]
card_id = state["players"][alice_key]["shop"][0]
cost = AUTOCHESS.CARD_BY_ID[card_id]["cost"]
old_gold = state["players"][alice_key]["gold"]
old_pool = state["pool"][card_id]
replies: list[str] = []
errors: list[BaseException] = []
def buy() -> None:
try:
replies.append(
self.command("/选1", event_id="same-autochess-buy")
)
except BaseException as error:
errors.append(error)
threads = [threading.Thread(target=buy) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertFalse(errors)
self.assertEqual(len(set(replies)), 1)
state = self.state()
self.assertEqual(state["players"][alice_key]["gold"], old_gold - cost)
self.assertEqual(state["pool"][card_id], old_pool - 1)
before_json = self.engine.ledger.connection.execute(
"""
SELECT state_json FROM autochess_runs
WHERE group_fingerprint = 'group-a' AND run_id = ?
""",
(state["run_id"],),
).fetchone()[0]
before_stories = self.engine.ledger.connection.execute(
"SELECT COUNT(*) FROM autochess_story_logs"
).fetchone()[0]
def invalid_archive(current, person, next_season):
del person, next_season
current["players"][alice_key]["gold"] += 999
return {
"state": current,
"response": "invalid",
"stories": [{"bad": True}],
"battles": [],
}
with self.assertRaisesRegex(
MODULE.LocalCommandError,
"剧情归档无效",
):
self.engine.ledger.apply_autochess_action(
"group-a",
"Alice",
"invalid-autochess-archive",
"invalid",
invalid_archive,
)
after_json = self.engine.ledger.connection.execute(
"""
SELECT state_json FROM autochess_runs
WHERE group_fingerprint = 'group-a' AND run_id = ?
""",
(state["run_id"],),
).fetchone()[0]
after_stories = self.engine.ledger.connection.execute(
"SELECT COUNT(*) FROM autochess_story_logs"
).fetchone()[0]
self.assertEqual(after_json, before_json)
self.assertEqual(after_stories, before_stories)
def test_four_team_seed_bracket_is_bo5_with_mr15_scores(self):
state = self.launch(["Alice", "Bob", "Carol", "Dave"])
seed_order = list(state["seed_order"])
completed = self.settle_to_completion()
self.assertEqual(completed["status"], "completed")
self.assertTrue(completed["champion_key"])
rows = self.engine.ledger.connection.execute(
"""
SELECT battle_type, player_a, player_b, result_json
FROM autochess_battles
WHERE battle_type != 'pve'
ORDER BY battle_id
"""
).fetchall()
semifinals = [row for row in rows if row["battle_type"] == "semifinal_bo5"]
finals = [row for row in rows if row["battle_type"] == "final_bo5"]
self.assertEqual(
{
frozenset((row["player_a"], row["player_b"]))
for row in semifinals
},
{
frozenset((seed_order[0], seed_order[3])),
frozenset((seed_order[1], seed_order[2])),
},
)
self.assertEqual(len(finals), 1)
for row in [*semifinals, *finals]:
result = json.loads(row["result_json"])
self.assertEqual(result["series_format"], "BO5")
self.assertRegex(result["score"], r"^3-[0-2]$")
self.assertTrue(3 <= len(result["maps"]) <= 5)
for item in result["maps"]:
self.assertRegex(item["score"], r"^(?:16-(?:6|9|12|14)|19-17|22-20)$")
self.assertLessEqual(abs(item["fluctuation_a_bps"]), 200)
self.assertLessEqual(abs(item["fluctuation_b_bps"]), 200)
def test_five_team_round_robin_bo3_then_top_four(self):
completed = self.launch(["Alice", "Bob", "Carol", "Dave", "Eve"])
self.assertEqual(
sum(
len(stage.get("pairs", []))
for stage in completed["round_plan"]
if stage["kind"] == "group"
),
10,
)
completed = self.settle_to_completion()
self.assertEqual(len(completed["qualifiers"]), 4)
rows = self.engine.ledger.connection.execute(
"""
SELECT battle_type, player_a, player_b, result_json
FROM autochess_battles
WHERE battle_type != 'pve'
ORDER BY battle_id
"""
).fetchall()
groups = [row for row in rows if row["battle_type"] == "group_bo3"]
self.assertEqual(len(groups), 10)
self.assertEqual(
len(
{
frozenset((row["player_a"], row["player_b"]))
for row in groups
}
),
10,
)
for row in groups:
result = json.loads(row["result_json"])
self.assertEqual(result["series_format"], "BO3")
self.assertRegex(result["score"], r"^2-[01]$")
self.assertEqual(
sum(row["battle_type"] == "semifinal_bo5" for row in rows),
2,
)
self.assertEqual(
sum(row["battle_type"] == "final_bo5" for row in rows),
1,
)
def test_three_humans_plus_ai_has_taffy_host_and_turn_handoffs(self):
self.command("/自走棋开始")
self.command("/自走棋报名", sender="Bob")
self.assertIn(
"先有且仅有 3 名真人",
self.command("/自走棋加AI战队"),
)
self.command("/自走棋报名", sender="Carol")
self.assertIn(
"只有主持人",
self.command("/自走棋加AI战队", sender="Bob"),
)
self.assertIn("第 4 种子", self.command("/自走棋加AI战队"))
self.assertIn(
"已经锁定参赛名单",
self.command("/自走棋报名", sender="Dave"),
)
launch = self.command("/自走棋开赛")
state = self.state()
self.assertIn("Taffy:欢迎大家来到第 1 届 IEM 盐田 Major", launch)
self.assertIn("3 人+1 AI 测试赛", launch)
self.assertIn("1v4、2v3", launch)
self.assertIn("小组赛 BO3,淘汰赛 BO5", launch)
self.assertIn("常规时间 15-15 进入加时", launch)
self.assertIn("alice,请做好赛前准备", launch)
self.assertTrue(state["ai_test_mode"])
self.assertEqual(state["seed_order"][-1], AUTOCHESS.AI_PERSON_KEY)
self.assertTrue(
state["players"][AUTOCHESS.AI_PERSON_KEY]["is_ai"]
)
semifinal = state["round_plan"][-2]
self.assertEqual(
semifinal["pairs"],
[
[state["seed_order"][0], AUTOCHESS.AI_PERSON_KEY],
[state["seed_order"][1], state["seed_order"][2]],
],
)
self.assertIn(
"当前轮到 alice",
self.command("/选1", sender="Bob"),
)
self.assertIn(
"alice 准备完成!到 bob 做准备了",
self.command("/结束回合"),
)
self.assertIn(
"当前轮到 bob",
self.command("/结束回合", sender="Carol"),
)
self.assertIn(
"bob 准备完成!到 carol 做准备了",
self.command("/结束回合", sender="Bob"),
)
self.assertIn(
"全员准备完毕",
self.command("/结束回合", sender="Carol"),
)
settled = self.command("/自走棋结算")
self.assertIn("Taffy:AI 战队 自动运营", settled)
self.assertIn("Taffy:bob,请做好赛前准备", settled)
state = self.state()
self.assertEqual(
state["preparation_order"][0],
state["seed_order"][1],
)
self.assertEqual(
self.engine.ledger.connection.execute(
"""
SELECT COUNT(*) FROM autochess_story_logs
WHERE story_type = 'ai_operation'
"""
).fetchone()[0],
1,
)
self.assertEqual(
self.engine.ledger.connection.execute(
"""
SELECT COUNT(*) FROM autochess_story_logs
WHERE story_text NOT LIKE 'Taffy:%'
"""
).fetchone()[0],
0,
)
def test_identical_final_creates_persisted_overtime_stories(self):
state = self.launch(["Alice", "Bob"])
for _ in range(3):
self.command("/自走棋强制结算")
state = self.state()
self.assertEqual(AUTOCHESS._current_stage(state)["kind"], "final")
first, second = state["seed_order"]
state["players"][second]["board"] = copy.deepcopy(
state["players"][first]["board"]
)
state["players"][first]["tactic"] = "稳定"
state["players"][second]["tactic"] = "稳定"
self.save_state(state)
with mock.patch.object(AUTOCHESS, "_hash_int", return_value=0):
response = self.command(
"/自走棋强制结算",
event_id="forced-overtime-final",
)
self.assertIn("19-17", response)
final = self.engine.ledger.connection.execute(
"""
SELECT result_json FROM autochess_battles
WHERE battle_type = 'final_bo5'
"""
).fetchone()
result = json.loads(final["result_json"])
self.assertEqual(result["score"], "3-0")
self.assertEqual(
[item["score"] for item in result["maps"]],
["19-17", "19-17", "19-17"],
)
overtime = self.engine.ledger.connection.execute(
"""
SELECT story_text, facts_json FROM autochess_story_logs
WHERE story_type = 'overtime'
ORDER BY story_id
"""
).fetchall()
self.assertEqual(len(overtime), 3)
for row in overtime:
self.assertIn("常规时间战成 15-15", row["story_text"])
self.assertEqual(json.loads(row["facts_json"])["overtime_count"], 1)
if __name__ == "__main__":
unittest.main()