This commit is contained in:
@@ -1,5 +1,38 @@
|
||||
# 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
|
||||
|
||||
- Replace instant personal lottery tickets with one persistent multiplayer
|
||||
|
||||
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
|
||||
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
||||
并通过带视觉确认的 GUI 发送链路回复。
|
||||
|
||||
当前版本:`alpha 0.6.3.1015`。
|
||||
当前版本:`alpha 0.6.4.1015`。
|
||||
|
||||
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
|
||||
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
|
||||
@@ -20,6 +20,12 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
|
||||
0.6.3 将彩票改为群内多人轮次,加入报名、状态、主持人开奖和取消;
|
||||
抽奖、彩票、21 点统一使用 `Jacky #N:...` 局号并支持 `/游戏记录`。
|
||||
完整玩法见[多人彩票与游戏记录](docs/group-lottery-0.6.3.md)。
|
||||
0.6.4 新增 2~8 人 CS Major 自走棋:固定五人首发与教练、每轮 6 人
|
||||
公开商店、属性/定位/战术克制、小组单循环、1v4 与 2v3 淘汰赛,以及
|
||||
BO3/BO5 的 MR15 加时赛。支持三名真人加入一支确定性 AI 测试战队;
|
||||
Taffy 负责赛制播报、顺序准备、交棒和全程剧情主持。所有选秀、战斗和
|
||||
加时剧情均在本地事务内落档。
|
||||
完整规则见[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点 要牌|停牌|状态|结束
|
||||
/游戏记录 [页码](/gamehistory)
|
||||
/游戏帮助(/games)
|
||||
/自走棋开始|报名|开赛|状态|结算|档案
|
||||
/自走棋加AI战队(三名真人测试模式)
|
||||
/选1~6;/战术 火力|稳定|残局;/阵容;/结束回合
|
||||
/丢骰子 [NdM]
|
||||
/猜数字 开始 [上限]
|
||||
/石头剪刀布 [开始|加入|出|状态|结算|结束]
|
||||
@@ -258,7 +267,7 @@ visually confirmed GUI bridge.
|
||||
Current version:
|
||||
|
||||
```text
|
||||
alpha 0.6.3.1015
|
||||
alpha 0.6.4.1015
|
||||
```
|
||||
|
||||
## Runtime Flow
|
||||
|
||||
@@ -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()
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-config"
|
||||
version = "0.6.3a0+1015"
|
||||
version = "0.6.4a0+1015"
|
||||
description = "Profile-based configuration compiler for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
|
||||
@@ -36,4 +36,4 @@ __all__ = [
|
||||
"write_default_profile",
|
||||
]
|
||||
|
||||
__version__ = "0.6.3a0+1015"
|
||||
__version__ = "0.6.4a0+1015"
|
||||
|
||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-extension-host"
|
||||
version = "0.6.3a0+1015"
|
||||
version = "0.6.4a0+1015"
|
||||
description = "Single-release, single-invocation process host for JWeBot extensions"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==0.6.3a0+1015",
|
||||
"jwebot-kernel==0.6.3a0+1015",
|
||||
"jwebot-sdk==0.6.4a0+1015",
|
||||
"jwebot-kernel==0.6.4a0+1015",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -16,4 +16,4 @@ __all__ = [
|
||||
"process_request",
|
||||
]
|
||||
|
||||
__version__ = "0.6.3a0+1015"
|
||||
__version__ = "0.6.4a0+1015"
|
||||
|
||||
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-kernel"
|
||||
version = "0.6.3a0+1015"
|
||||
version = "0.6.4a0+1015"
|
||||
description = "Stable routing and extension authority for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-config==0.6.3a0+1015",
|
||||
"jwebot-sdk==0.6.3a0+1015",
|
||||
"jwebot-official-extensions==0.6.3a0+1015",
|
||||
"jwebot-config==0.6.4a0+1015",
|
||||
"jwebot-sdk==0.6.4a0+1015",
|
||||
"jwebot-official-extensions==0.6.4a0+1015",
|
||||
"tomli>=2; python_version < '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -107,4 +107,4 @@ __all__ = [
|
||||
"verify_installed_release_signature",
|
||||
]
|
||||
|
||||
__version__ = "0.6.3a0+1015"
|
||||
__version__ = "0.6.4a0+1015"
|
||||
|
||||
@@ -1070,6 +1070,71 @@ class Ledger:
|
||||
ON group_lottery_entries(
|
||||
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
|
||||
ON events(group_fingerprint, created_at);
|
||||
CREATE INDEX IF NOT EXISTS outbox_due
|
||||
@@ -3634,6 +3699,250 @@ class Ledger:
|
||||
self.connection.execute("ROLLBACK")
|
||||
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]:
|
||||
with self.lock:
|
||||
events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
||||
|
||||
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-official-extensions"
|
||||
version = "0.6.3a0+1015"
|
||||
version = "0.6.4a0+1015"
|
||||
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"jwebot-sdk==0.6.3a0+1015",
|
||||
"jwebot-sdk==0.6.4a0+1015",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
"""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 (
|
||||
DIRECT_KERNEL_COMMANDS,
|
||||
DIRECT_KERNEL_COMMAND_KINDS,
|
||||
@@ -35,6 +47,9 @@ from .steam import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CARD_BY_ID",
|
||||
"COACH_BONUSES",
|
||||
"CsMajorAutochess",
|
||||
"DICE_CHALLENGE_TIMEOUT_SECONDS",
|
||||
"DEFAULT_STEAM_MIHOMO_PROXY_URL",
|
||||
"DIRECT_KERNEL_COMMANDS",
|
||||
@@ -43,8 +58,12 @@ __all__ = [
|
||||
"LOCAL_COMMAND_ALIASES",
|
||||
"LOCAL_EXTENSIONS",
|
||||
"LOCAL_GAME_TIMEOUT_SECONDS",
|
||||
"MAX_PLAYERS",
|
||||
"MAX_RPS_PLAYERS",
|
||||
"MIN_PLAYERS",
|
||||
"OFFICIAL_AUTHOR",
|
||||
"PLAYER_POOL",
|
||||
"build_round_plan",
|
||||
"KernelCommandDescriptor",
|
||||
"LocalExtensionDescriptor",
|
||||
"LocalExtensionError",
|
||||
@@ -58,8 +77,11 @@ __all__ = [
|
||||
"deterministic_int",
|
||||
"local_command_parts",
|
||||
"local_extension_command_token",
|
||||
"lineup_power",
|
||||
"matchup_modifier",
|
||||
"parse_autochess_action",
|
||||
"extension_for_kind",
|
||||
"render_feature_list",
|
||||
]
|
||||
|
||||
__version__ = "0.6.3a0+1015"
|
||||
__version__ = "0.6.4a0+1015"
|
||||
|
||||
+37
@@ -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))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,6 +61,20 @@ LOCAL_COMMAND_ALIASES = {
|
||||
"人物": "person_info",
|
||||
"个人属性": "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",
|
||||
),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="cs-major-autochess",
|
||||
author=JWEBOT_AUTHOR,
|
||||
description="CS自走棋",
|
||||
help_entries=("/自走棋开始|报名",),
|
||||
command_kinds=("cs_autochess",),
|
||||
),
|
||||
LocalExtensionDescriptor(
|
||||
extension_id="person-directory",
|
||||
author=OFFICIAL_AUTHOR,
|
||||
|
||||
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .cs_autochess import CsMajorAutochess
|
||||
from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list
|
||||
from .player_games import PlayerGames
|
||||
|
||||
@@ -107,6 +108,7 @@ class LocalCommandEngine:
|
||||
self.profile_fact_is_safe = profile_fact_is_safe
|
||||
self.profile_expiry = profile_expiry
|
||||
self.players = PlayerGames(ledger, person_display_label)
|
||||
self.autochess = CsMajorAutochess(ledger)
|
||||
|
||||
def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str:
|
||||
question = str(row["question"])
|
||||
@@ -128,6 +130,8 @@ class LocalCommandEngine:
|
||||
"game_rules",
|
||||
}:
|
||||
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":
|
||||
return self.reply_cleaner(self.dice(row, arguments))
|
||||
if kind == "dice_challenge":
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jwebot-sdk"
|
||||
version = "0.6.3a0+1015"
|
||||
version = "0.6.4a0+1015"
|
||||
description = "Typed, capability-limited extension API for JWeBot plugins"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = []
|
||||
|
||||
@@ -38,4 +38,4 @@ __all__ = [
|
||||
"SingleGroupContext",
|
||||
]
|
||||
|
||||
__version__ = "0.6.3a0+1015"
|
||||
__version__ = "0.6.4a0+1015"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[tool.jwebot]
|
||||
release = "alpha 0.6.3.1015"
|
||||
release = "alpha 0.6.4.1015"
|
||||
production_deployments_enabled_by_default = false
|
||||
|
||||
[tool.jwebot.workspace]
|
||||
|
||||
@@ -83,6 +83,7 @@ class LocalExtensionInventoryTests(unittest.TestCase):
|
||||
"context-summary": OFFICIAL_AUTHOR,
|
||||
"person-directory": OFFICIAL_AUTHOR,
|
||||
"single-player-games": JWEBOT_AUTHOR,
|
||||
"cs-major-autochess": JWEBOT_AUTHOR,
|
||||
"person-memory": OFFICIAL_AUTHOR,
|
||||
"server-status": OFFICIAL_AUTHOR,
|
||||
"jwebot-version": OFFICIAL_AUTHOR,
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user