Release alpha 0.6.7 autochess pacing
check / local-gate (push) Failing after 32s

This commit is contained in:
JWeBot Contributors
2026-10-15 18:08:55 +08:00
parent a74147e7f1
commit 3dea42b268
24 changed files with 865 additions and 218 deletions
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## alpha 0.6.7.1015
- Expand CS Major autochess content to 100+ player cards and an 18-coach
metadata pool while keeping the opening coach draft to 10 visible choices.
- Replace abstract training targets with real round-robin training rounds.
Each round resolves participant-vs-participant or participant-vs-AI BO1
scrims, then opens an operation window for D rolling, locking, lineup swaps,
tactics, and readiness.
- Replace fixed score bands with deterministic MR15 round simulation. Map
scores now come from individual round outcomes, including 15-15 overtime,
instead of direct mappings such as 16-6 or 16-9.
- Add a 120+ line local narrative library for openings, streaks, pauses,
clutches, economy beats, closes, training hooks, and media/sponsor
challenges so match reports vary across maps.
- Hide raw target/performance calculation text from training, match, and
challenge reports while preserving structured battle facts in the archive.
- Start drafted players as two-star素材 and rebalance roster completion toward
low/mid-cost cards, leaving upgrades and synergies to the shop loop.
## alpha 0.6.6.1015
- Rebuild CS Major autochess around multi-message hosting: tournament
+9 -6
View File
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。
当前版本:`alpha 0.6.6.1015`。
当前版本:`alpha 0.6.7.1015`。
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
@@ -26,10 +26,13 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
0.6.5 将 AI 测试模式扩展到双人:两名真人可一键补入 AI 战队 A、B,
两支 AI 独立运营。
0.6.6 重构自走棋流程:开赛后三轮选秀,第一轮从固定 10 人教练池中
选教练,第二、三轮选选手;随后进入三场训练赛、BO3 单循环小组赛、
一次媒体日或赞助挑战、BO5 半决赛和总决赛。主持消息会拆分为多条
回复;所有需要推进的阶段由相关真人发送 `/结束回合` 自动进入下一步,
已淘汰玩家不会再收到赛前准备提示。
选教练,第二、三轮选选手;随后进入训练赛、小组赛、挑战、淘汰赛和
总决赛。主持消息会拆分为多条回复;所有需要推进的阶段由相关真人发送
`/结束回合` 自动进入下一步,已淘汰玩家不会再收到赛前准备提示。
0.6.7 继续强化文字自走棋体验:卡池扩充到 100+ 人、教练库扩充到
18 人;训练赛改为按参赛人数生成的真实对阵轮次;单图比分由 MR15
逐回合模拟产生,不再使用固定比分档;赛事叙事素材扩充到 120+ 条,
D 牌、锁店、调阵容和战术窗口会在轮次之间自然提示。
### 运行流程
@@ -272,7 +275,7 @@ visually confirmed GUI bridge.
Current version:
```text
alpha 0.6.6.1015
alpha 0.6.7.1015
```
## Runtime Flow
+1 -1
View File
@@ -1 +1 @@
alpha 0.6.6.1015
alpha 0.6.7.1015
+34 -29
View File
@@ -8,7 +8,7 @@
1. 做成一个多人报名、多人运营、多人对战的群聊自走棋。
2. 玩法重心放在每轮选秀和阵容运营,而不是复杂微操。
3. 用 Major 赛事包装 PvE、小组单循环、淘汰赛和终局剧情。
3. 用 Major 赛事包装训练赛、小组单循环、淘汰赛和终局剧情。
4. 结算逻辑必须完全本地、可重放、可持久化、可回档验证。
5. 每一段剧情都要落档,且剧情只能在数值结算之后生成。
@@ -16,7 +16,7 @@
一句话定义:
`三轮赛前选秀 -> 训练赛拿资源 -> D 牌与布阵 -> BO3 小组单循环 -> BO5 淘汰赛 -> 保存每轮赛报与剧情`
`三轮赛前选秀 -> 轮次训练赛拿资源 -> D 牌与布阵 -> BO3 小组单循环 -> BO5 淘汰赛 -> 保存每轮赛报与剧情`
玩家感受到的乐趣应当主要来自:
@@ -79,7 +79,8 @@
- Eternal Fire
选手数据不是对真实比赛数据的一比一复刻,而是基于公众认知做游戏化映射。
首发版本固定维护 `60` 名棋子,运行期不在线抓取或改写数值。
0.6.7 起本地维护 `100+` 名棋子和 `18` 名教练元数据;运行期不在线抓取
或改写数值。首轮教练选秀仍固定展示 10 个候选,保证聊天窗口可读。
### 4.2 费用层级
@@ -280,41 +281,44 @@
1. `回合开始播报`
2. `6 人选秀 / 刷新 / 升级 / 调阵`
3. `PvE 或 PvP 结算`
3. `训练赛 / 赛事挑战 / PvP 结算`
4. `回合结算与经济发放`
5. `剧情归档`
## 10. PvE 设计
## 10. 训练赛与赛事挑战
PvE 不叫“打野怪”,统一包装成赛事内活动。
训练赛不再是阈值型 PvE。它是正式小组赛前的多轮真实对阵,用来给玩家
检验阵容、获取金币并自然插入 D 牌窗口。媒体日和赞助挑战仍作为赛事内
插曲出现,但只提供资源变动,不淘汰玩家。
### 10.1 PvE 类型
### 10.1 活动类型
- 训练赛
- 媒体日
- 热身赛
- 赞助挑战
- 地图专项训练
- 本地陪练队热身
### 10.2 PvE 作用
### 10.2 训练赛作用
PvE 的目标不是淘汰人,而是给资源:
训练赛按参赛队伍生成 round-robin 轮次。四队测试局会有三轮训练赛,
每队在训练阶段遇到另外三支队伍;奇数队伍时轮空队会与本地陪练队热身。
训练赛只发放金币,不记录小组胜负,也不扣生命。
- 金币
- 装备
- 战术卡
- 教练经验
- 临时 buff
每轮训练赛结束后进入运营窗口:
### 10.3 PvE 结算逻辑
- `/刷新` 即 D 牌,消耗金币换一组 6 人商店;
- `/选1~/选6` 买入商店选手,重复卡自动累积星级;
- `/锁定` 保留当前商店;
- `/上阵` 调整 5 人首发;
- `/战术 火力|稳定|残局` 改变下一场的回合倾向。
PvE 对手是模板化敌方阵容或阈值型事件,不需要复杂 AI。
### 10.3 训练赛结算逻辑
示例:
训练赛使用与正式比赛相同的单图 MR15 逐回合模拟,只是赛制为 BO1。
胜方获得更多金币,负方也有保底金币,保证测试局能继续运营。
`玩家总战力 >= 目标战力` 则获胜,否则失败。
这样实现简单,也利于稳定复盘。
媒体日和赞助挑战根据阵容完整度、教练、战术和本地确定性随机因子发放
金币,但播报不展示“目标/发挥”这类计算日志,只写剧情和奖励。
## 11. PvP 赛制
@@ -430,7 +434,7 @@ PvE 对手是模板化敌方阵容或阈值型事件,不需要复杂 AI。
- 开局报名完成
- 每轮 6 人选秀结果
- 每次刷新和关键升星
- 每次 PvE 事件结果
- 每轮训练赛和赛事挑战结果
- 每场 PvP 结果
- 小组赛排名、晋级和淘汰节点
- 淘汰赛对阵与结果
@@ -597,8 +601,9 @@ PvE 对手是模板化敌方阵容或阈值型事件,不需要复杂 AI。
Jacky 选择了 zonic!
现在轮到 @Alice 做出选择,使用 /选1~/选10。
训练赛阶段结束。可以 /刷新、/选1~/选6、/上阵、/战术;
所有真人发送 /结束回合 后进入小组赛。
训练赛第 1/3 轮对阵:Team Jacky vs Team Alayak;AI 战队 A vs AI 战队 B
本轮训练结束。现在是运营窗口:/刷新 是 D 牌,/选1~/选6 是买店内选手;
所有真人发送 /结束回合 后进入下一轮训练。
```
以下节点都必须由 Taffy 主持并落档:
@@ -607,7 +612,7 @@ Jacky 选择了 zonic!
- AI 测试战队加入
- 赛制与种子公布
- 三轮选秀候选池、选择结果和选秀完成
- 三场训练赛结果
- 每轮训练赛对阵、比分、金币和运营提示
- 媒体日或赞助挑战
- 每场 BO3/BO5 对阵与结果
- 每张加时地图
@@ -625,7 +630,7 @@ Jacky 选择了 zonic!
- 每轮 6 人选秀
- 买卡、卖卡、升星
- 教练等级
- 简化 PvE
- 简化训练赛
- 简化 PvP
- 基础剧情归档
@@ -638,7 +643,7 @@ Jacky 选择了 zonic!
### 第三阶段:内容扩展
- 在固定 60 人首发池基础上增加新赛季棋子
- 在 100+ 人本地卡池基础上增加新赛季棋子
- 更多俱乐部和国家
- 特殊赛事事件
- 更细的剧情风格与本届 Major 年鉴
@@ -660,7 +665,7 @@ Jacky 选择了 zonic!
最重要的三个锚点已经确定:
1. `每轮固定 6 人公开选秀`
2. `PvE 负责资源,PvP 负责小组单循环与淘汰赛`
2. `训练赛与挑战负责资源,PvP 负责小组单循环与淘汰赛`
3. `每一段剧情都必须落档`
如果按这个设计推进,实现出来的成品会更像一场可回放、可复盘、
@@ -2,11 +2,11 @@
Author: `jwebot`
CS自走棋
自走棋
Help:
- `/自走棋开始|报名`
- `/自走棋 开始|报名|选`
Registered commands:
@@ -1,7 +1,7 @@
id = "cs-major-autochess"
author = "jwebot"
description = "CS自走棋"
help = ["/自走棋开始|报名"]
description = "自走棋"
help = ["/自走棋 开始|报名|选"]
version = "1.0.0"
api_version = "1"
entrypoint = "cs_major_autochess:plugin"
@@ -5,8 +5,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'cs-major-autochess'
AUTHOR = 'jwebot'
DESCRIPTION = 'CS自走棋'
HELP_ENTRIES = ('/自走棋开始|报名',)
DESCRIPTION = '自走棋'
HELP_ENTRIES = ('/自走棋 开始|报名|选',)
COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合')
@@ -19,8 +19,8 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, 'CS自走棋')
self.assertEqual(HELP_ENTRIES, ('/自走棋开始|报名',))
self.assertEqual(DESCRIPTION, '自走棋')
self.assertEqual(HELP_ENTRIES, ('/自走棋 开始|报名|选',))
self.assertIn('/自走棋', registered)
self.assertEqual(len(registered), 14)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-config"
version = "0.6.6a0+1015"
version = "0.6.7a0+1015"
description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9"
dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile",
]
__version__ = "0.6.6a0+1015"
__version__ = "0.6.7a0+1015"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-extension-host"
version = "0.6.6a0+1015"
version = "0.6.7a0+1015"
description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==0.6.6a0+1015",
"jwebot-kernel==0.6.6a0+1015",
"jwebot-sdk==0.6.7a0+1015",
"jwebot-kernel==0.6.7a0+1015",
]
[project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request",
]
__version__ = "0.6.6a0+1015"
__version__ = "0.6.7a0+1015"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-kernel"
version = "0.6.6a0+1015"
version = "0.6.7a0+1015"
description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-config==0.6.6a0+1015",
"jwebot-sdk==0.6.6a0+1015",
"jwebot-official-extensions==0.6.6a0+1015",
"jwebot-config==0.6.7a0+1015",
"jwebot-sdk==0.6.7a0+1015",
"jwebot-official-extensions==0.6.7a0+1015",
"tomli>=2; python_version < '3.11'",
]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature",
]
__version__ = "0.6.6a0+1015"
__version__ = "0.6.7a0+1015"
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-official-extensions"
version = "0.6.6a0+1015"
version = "0.6.7a0+1015"
description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==0.6.6a0+1015",
"jwebot-sdk==0.6.7a0+1015",
]
[tool.setuptools.packages.find]
@@ -90,4 +90,4 @@ __all__ = [
"render_feature_list",
]
__version__ = "0.6.6a0+1015"
__version__ = "0.6.7a0+1015"
@@ -5,8 +5,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'cs-major-autochess'
AUTHOR = 'jwebot'
DESCRIPTION = 'CS自走棋'
HELP_ENTRIES = ('/自走棋开始|报名',)
DESCRIPTION = '自走棋'
HELP_ENTRIES = ('/自走棋 开始|报名|选',)
COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合')
@@ -145,6 +145,62 @@ PLAYER_POOL = (
_card("maj3r", "MAJ3R", 2, "指挥", "土耳其", "Eternal Fire", ("Space Soldiers",), 68, 74, 71, 93),
_card("wicadia", "Wicadia", 2, "步枪手", "土耳其", "Eternal Fire", (), 76, 70, 68),
_card("jottaaa", "jottAAA", 1, "步枪手", "土耳其", "Eternal Fire", ("Sangal",), 67, 64, 63),
_card("teses_heroic", "TeSeS", 2, "步枪手", "丹麦", "HEROIC", ("Falcons",), 76, 73, 72),
_card("sjuush", "sjuush", 2, "步枪手", "丹麦", "HEROIC", (), 74, 78, 77),
_card("degster", "degster", 3, "狙击手", "俄罗斯", "HEROIC", ("OG", "Spirit"), 82, 76, 82),
_card("kyxsan", "kyxsan", 2, "指挥", "北马其顿", "HEROIC", ("Apeks",), 64, 75, 70, 90),
_card("nertz_heroic", "NertZ", 3, "步枪手", "以色列", "HEROIC", ("ENCE", "Liquid"), 85, 79, 82),
_card("torzi_mouz", "torzsi", 2, "狙击手", "匈牙利", "MOUZ", (), 78, 78, 80),
_card("spinx", "Spinx", 4, "步枪手", "以色列", "Vitality", ("ENCE",), 88, 87, 86),
_card("electronic", "electroNic", 4, "步枪手", "俄罗斯", "Virtus.pro", ("NAVI", "Cloud9"), 87, 84, 84),
_card("fame", "fame", 3, "步枪手", "俄罗斯", "Virtus.pro", (), 82, 83, 82),
_card("fl1t", "FL1T", 3, "步枪手", "俄罗斯", "Virtus.pro", ("forZe",), 81, 82, 80),
_card("jame", "Jame", 3, "指挥", "俄罗斯", "Virtus.pro", ("AVANGAR",), 72, 87, 86, 96),
_card("yekindar_vp", "YEKINDAR", 4, "步枪手", "拉脱维亚", "Virtus.pro", ("Liquid", "FURIA"), 90, 78, 76),
_card("hallzerk", "hallzerk", 2, "狙击手", "挪威", "Complexity", ("Dignitas",), 75, 72, 75),
_card("grim", "Grim", 2, "步枪手", "美国", "Complexity", ("Liquid",), 74, 74, 73),
_card("jt", "JT", 2, "指挥", "南非", "Complexity", ("Cloud9",), 62, 73, 69, 89),
_card("floppy", "floppy", 1, "步枪手", "美国", "Complexity", ("Cloud9",), 68, 67, 65),
_card("junior", "junior", 1, "狙击手", "美国", "NRG", ("FURIA", "Complexity"), 68, 64, 66),
_card("art", "arT", 2, "指挥", "巴西", "Fluxo", ("FURIA",), 69, 68, 74, 92),
_card("biguzera", "biguzera", 3, "指挥", "巴西", "paiN", (), 75, 80, 80, 92),
_card("nqz", "nqz", 2, "狙击手", "巴西", "paiN", ("00Nation",), 76, 72, 74),
_card("snow", "snow", 1, "步枪手", "巴西", "paiN", (), 67, 65, 63),
_card("brnz4n", "brnz4n", 1, "步枪手", "巴西", "MIBR", (), 66, 66, 64),
_card("exit", "exit", 2, "指挥", "巴西", "MIBR", (), 67, 75, 71, 88),
_card("insani", "insani", 3, "步枪手", "巴西", "MIBR", (), 83, 75, 77),
_card("saffee", "saffee", 2, "狙击手", "巴西", "MIBR", ("FURIA",), 76, 72, 74),
_card("slaxz", "slaxz-", 2, "狙击手", "德国", "GamerLegion", ("ALTERNATE",), 75, 72, 74),
_card("volt", "volt", 2, "步枪手", "英国", "GamerLegion", ("Into the Breach",), 74, 73, 72),
_card("acoR", "acoR", 2, "狙击手", "丹麦", "GamerLegion", ("MOUZ",), 75, 73, 76),
_card("keoz", "Keoz", 1, "步枪手", "比利时", "GamerLegion", (), 65, 66, 64),
_card("isak", "isak", 1, "步枪手", "瑞典", "GamerLegion", (), 66, 67, 65),
_card("tabseN", "tabseN", 3, "指挥", "德国", "BIG", (), 77, 82, 79, 94),
_card("krimbo", "Krimbo", 3, "步枪手", "德国", "BIG", (), 82, 82, 81),
_card("syrson", "syrsoN", 2, "狙击手", "德国", "BIG", (), 76, 73, 76),
_card("mantuu", "mantuu", 2, "狙击手", "波兰", "BIG", ("OG",), 76, 74, 75),
_card("blamef", "blameF", 4, "步枪手", "丹麦", "fnatic", ("Astralis", "Complexity"), 88, 88, 84),
_card("bodyy", "bodyy", 2, "指挥", "法国", "fnatic", ("G2",), 66, 75, 72, 89),
_card("matys", "MATYS", 2, "步枪手", "斯洛伐克", "fnatic", (), 76, 72, 72),
_card("afro", "afro", 2, "狙击手", "法国", "fnatic", ("LDLC",), 75, 72, 74),
_card("kairon", "KaiR0N-", 2, "步枪手", "俄罗斯", "BetBoom", ("Virtus.pro",), 76, 70, 72),
_card("zorte", "zorte", 2, "狙击手", "俄罗斯", "BetBoom", ("forZe",), 77, 73, 75),
_card("nafany", "nafany", 2, "指挥", "俄罗斯", "BetBoom", ("Cloud9", "Gambit"), 65, 74, 70, 91),
_card("s1ren", "s1ren", 2, "步枪手", "俄罗斯", "BetBoom", ("Spirit",), 75, 73, 72),
_card("rigoN", "rigoN", 2, "步枪手", "瑞士", "Bad News Eagles", ("Guild Eagles",), 77, 70, 72),
_card("juanflatroo", "juanflatroo", 2, "步枪手", "科索沃", "Bad News Eagles", ("Guild Eagles",), 76, 71, 72),
_card("calyx", "Calyx", 2, "步枪手", "土耳其", "Eternal Fire", ("Space Soldiers",), 75, 73, 74),
_card("issaa", "ISSAA", 1, "步枪手", "约旦", "JiJieHao", ("OG",), 67, 64, 66),
_card("boros", "BOROS", 2, "步枪手", "约旦", "Into the Breach", ("Falcons", "Monte"), 78, 68, 70),
_card("woro2k", "Woro2k", 3, "狙击手", "乌克兰", "Monte", (), 82, 76, 80),
_card("demqq", "DemQQ", 2, "步枪手", "乌克兰", "Monte", (), 76, 74, 73),
_card("dycha", "dycha", 2, "步枪手", "波兰", "ENCE", (), 75, 74, 72),
_card("gla1ve", "gla1ve", 3, "指挥", "丹麦", "ENCE", ("Astralis",), 72, 84, 79, 98),
_card("hades", "hades", 2, "狙击手", "波兰", "ENCE", ("9INE",), 76, 73, 75),
_card("goofy", "Goofy", 1, "步枪手", "波兰", "ENCE", ("9INE",), 67, 66, 64),
_card("sausol", "sausol", 1, "步枪手", "西班牙", "Movistar Riders", (), 66, 65, 63),
_card("alex_movistar", "alex", 2, "指挥", "西班牙", "Movistar Riders", ("NiP",), 64, 74, 70, 88),
_card("dav1g", "dav1g", 1, "步枪手", "西班牙", "KOI", ("Movistar Riders",), 65, 65, 64),
)
CARD_BY_ID = {card["id"]: card for card in PLAYER_POOL}
@@ -182,16 +238,180 @@ COACH_POOL = (
_coach("trace", "trace", 2, "防守复盘", 70, 86, 74, 6),
_coach("ash", "ash", 1, "新人培养", 76, 74, 72, 5),
_coach("mithr", "mithR", 1, "基础纪律", 70, 78, 70, 5),
_coach("kuben", "kuben", 4, "慢控默认", 74, 88, 82, 8),
_coach("groove", "groove", 3, "学院体系", 72, 84, 78, 7),
_coach("guerri", "guerri", 4, "巴西爆弹", 86, 76, 80, 8),
_coach("xizt", "Xizt", 4, "默认纪律", 76, 86, 82, 8),
_coach("sycrone", "sycrone", 3, "青年提拔", 80, 80, 78, 7),
_coach("malek", "maLeK", 3, "明星平衡", 82, 78, 80, 7),
_coach("peacemaker", "peacemaker", 2, "临时救火", 78, 74, 76, 6),
_coach("kassad", "kassad", 2, "纪律重建", 72, 82, 74, 6),
)
COACH_BY_ID = {coach["id"]: coach for coach in COACH_POOL}
TRAINING_OPPONENTS = (
("Team Falcons", 292),
("Natus Vincere", 306),
("Team Vitality", 316),
("FaZe Clan", 300),
("G2 Esports", 286),
("The MongolZ", 278),
)
TRAINING_WIN_GOLD = 3
TRAINING_LOSS_GOLD = 1
TRAINING_BYE_GOLD = 2
NARRATIVE_FRAGMENTS = {
"openings": (
"{team} 开局没有急着提速,先把默认控图铺开。",
"{team} 第一波提速很坚决,外场道具落点干净。",
"{team} 在手枪局后选择慢摸,试图逼出对手的前压信息。",
"{team} 的狙击手早早架住第一交火点,节奏被压得很低。",
"{team} 连续用两颗闪光清掉近点,进攻面一下打开。",
"{team} 防守开局站位很深,把回防路线留得很完整。",
"{team} 先拿地图控制权,再把包点压力一点点推上去。",
"{team} 开局没有赌点,靠信息互换慢慢读阵型。",
"{team} 在第一枪位赢下后没有贪,马上回收人数优势。",
"{team} 用一波假打骗出轮转,第二时间才开始真正爆弹。",
"{team} 选择保守开局,宁愿少拿信息也不送首杀。",
"{team} 的指挥把默认拉得很散,让对手很难判断重心。",
"{team} 手枪局道具很少,但站位互补做得很扎实。",
"{team} 没有被前几回合影响,依旧按自己的节奏展开。",
"{team} 的第一套进攻明显针对了对手弱侧。",
"{team} 在经济局也保留了两颗关键闪,准备打后点反清。",
"{team} 上来就抢中路,想用主动权换后续转点空间。",
"{team} 先把狙击枪放在安全位,围绕它慢慢组织推进。",
),
"runs": (
"{team} 连拿 {count} 分之后,现场短暂安静下来。",
"{team} 把经济滚起来后,连续 {count} 个回合没有给对手完整开枪空间。",
"{team} 这一波 {count} 连分不是纯枪法碾压,而是每回合都换到正确信息。",
"{team} 的节奏忽快忽慢,{count} 个回合里每次进点方式都不一样。",
"{team} 靠补枪链吃下 {count} 分,队伍语音应该已经很热。",
"{team} 的中期处理开始成型,连续 {count} 分把对手钱打空。",
"{team} 没有急着结束比赛,而是用 {count} 连分把地图控制牢牢握住。",
"{team} 这段 {count} 连分很关键,尤其是第二个残局稳定了军心。",
"{team} 在暂停之后突然提速,直接打出 {count} 连分。",
"{team} 先抓前压,再抓回防,{count} 个回合把读秒处理做满。",
"{team} 的防守交叉火力越站越舒服,{count} 连分让比分拉开。",
"{team} 把对手的道具节奏读透,连续 {count} 分都避开了主防区。",
"{team} 经济起飞后没有乱买,{count} 连分中枪线一直保持完整。",
"{team} 在被首杀的回合仍然能翻,{count} 连分含金量很高。",
"{team} 的默认推进开始收利息,{count} 个回合每次都能换到便宜。",
"{team} 靠一波小连胜把压力交回去,{count} 分之后对手必须叫停。",
"{team} 的二楼夹击反复奏效,{count} 连分让对手开始犹豫。",
"{team} 连续 {count} 回合把残局拖到优势人数,后点纪律很稳。",
"{team} 打出 {count} 连分后节奏放慢,像是在等对手自己露破绽。",
"{team} 这波 {count} 连分让地图气质变了,所有枪位都开始往前压。",
),
"clutches": (
"{player} 留到最后一个残局,靠一颗烟把拆包时间偷了出来。",
"{player} 没有急着补枪,等脚步声靠近后才横拉收掉残局。",
"{player} 在 1v2 里先断退路,再回头处理包点。",
"{player} 的残局处理很冷,假拆骗出第一枪后直接反清。",
"{player} 被逼到死角,但一颗反弹闪把局面重新打开。",
"{player} 没有追击残血,而是先保住雷包,判断非常老练。",
"{player} 关键回合双杀,把本来要崩的经济线救回来。",
"{player} 在读秒阶段完成补枪,队友的拉扯给得刚刚好。",
"{player} 先拿信息再开枪,这个残局赢在耐心。",
"{player} 硬吃白光完成换人,比分没有被对手咬住。",
"{player} 从连接位摸出,正好打掉回防节奏。",
"{player} 这次不追求集锦,只把枪线封死等队友到位。",
"{player} 的狙击枪没有空枪,关键首杀让整回合变得简单。",
"{player} 残血仍然敢看第一枪位,队伍因此拿到赛点。",
"{player} 拿下残局后没有庆祝,马上提醒队友保枪经济。",
"{player} 靠脚步假信息骗出转点,最后一枪很稳。",
"{player} 在包点里连续换位,对手始终没能定位。",
"{player} 把闪光背身吃完后仍然补到第二枪,这是关键转折。",
"{player} 一个人拖住整条回防路线,为队伍争到拆包窗口。",
"{player} 最后选择不 peek,让时间替自己赢下这一分。",
),
"pauses": (
"比分被拉开后,双方都慢了半拍,像是在重新读这张图。",
"这里适合停一下,刚才几回合的经济差已经开始影响下一枪。",
"暂停回来之后,防守方明显把第一接触点后撤了半格。",
"连续交火后节奏突然变慢,双方都在等对方先犯错。",
"这一段没有夸张集锦,但每一颗道具都在改变下一回合。",
"场面安静了几秒,真正决定胜负的是下一次首杀。",
"队伍语音应该都在压节奏,没人愿意在这里送掉经济。",
"这不是简单的比分变化,战术重心已经从爆弹转到控图。",
"现场进入短暂拉扯,地图控制权一点点被重新分配。",
"两边都知道下一分很贵,所以这回合开局格外谨慎。",
"这一小段空白很重要,强队往往就是在这里调整默认。",
"比赛没有马上进入下一波交火,指挥正在重新排优先级。",
"刚才的连分让对手不得不换站位,防线开始出现试探。",
"经济压力让双方都少了一点容错,下一波道具会很关键。",
"这里没有急躁的前压,只有一点点互相试探。",
"暂停不是为了拖时间,而是在给下一套进攻换答案。",
"双方都把节奏压住,地图像被重新归零。",
"这一分之前的几秒钟很长,所有人都在等第一个脚步声。",
"场上没有无意义的 peek,每个人都在守自己的触发条件。",
"这段过渡让比赛更像真正的 BO 系列赛,而不是单纯掷骰子。",
),
"economy": (
"{team} 赢下这一分后可以完整起枪,下一回合选择会更多。",
"{team} 输掉长枪局后经济见底,可能要交出一回合调整空间。",
"{team} 没有强行追梦,选择保枪把下一回合留住。",
"{team} 的连败补偿到位,下一回合有机会靠道具质量反打。",
"{team} 这回合缴下两把枪,经济收益比比分更重要。",
"{team} 虽然丢分,但保下主狙,下一回合还有破局点。",
"{team} 的半甲手枪没有打成,但至少逼出了对手不少道具。",
"{team} 经济优势开始显现,可以更大胆地争夺第一枪位。",
"{team} 如果下一分再丢,就必须进入半起调整。",
"{team} 这一波选择全甲全弹,明显是要在中期一次打穿。",
"{team} 保下来的两把枪让下回合不至于完全被动。",
"{team} 连续换枪成功,把对手的存款一点点磨掉。",
"{team} 用低成本回合换到下包,经济线还没有断。",
"{team} 这波赢的不只是分数,还有下一回合的主动权。",
"{team} 被重置经济后节奏放慢,先求稳住半场尾声。",
"{team} 靠残局胜利拿到额外空间,可以考虑继续 D 牌补强。",
),
"closers": (
"{team} 没有给翻盘窗口,最后一波回防处理得很完整。",
"{team} 在赛点局选择最熟悉的默认,用纪律结束地图。",
"{team} 最后一回合没有贪枪,稳稳把人数优势转成胜势。",
"{team} 的收官很干净,地图尾声没有出现多余失误。",
"{team} 用一波双拉打开包点,最后比分定格。",
"{team} 把比赛拖进自己舒服的节奏,收尾阶段非常稳。",
"{team} 赛点局的道具层次很清楚,对手没有舒服的回防路线。",
"{team} 终于等到对手前压,抓住这个窗口终结地图。",
"{team} 收下最后一分时没有冒进,这就是强队式结尾。",
"{team} 最后一枪来得很快,但前面的信息铺垫已经足够。",
"{team} 在关键经济局之后顺势收掉地图。",
"{team} 先控退路再进包点,结尾没有留下残局悬念。",
"{team} 把上半场积累的优势守到最后。",
"{team} 用一波慢夹收官,比分看起来合理,过程更像真实比赛。",
"{team} 没让对手连追,赛点局直接把节奏抢回来。",
"{team} 的最后一分赢在补枪,枪线没有断。",
"{team} 收官回合把每个角落都清干净,耐心给了回报。",
"{team} 把悬念留到最后一刻,但关键枪没有手软。",
),
"training_hooks": (
"训练赛不计入小组胜负,但会发金币,方便后续刷新商店。",
"这轮训练更像实战热身,每队都会遇到同场参赛对手。",
"训练赛重点不是淘汰谁,而是检验当前阵容能不能打完整张图。",
"这轮结束后可以 D 牌、锁店或调首发,下一轮再检验成果。",
"AI 战队也会参与训练,对局结果会写入档案。",
"如果阵容缺少指挥,训练赛的中期回合会更容易失控。",
"如果你想追羁绊,现在是最适合刷新商店的窗口。",
"训练赛的金币很重要,别把每次刷新都当成免费试错。",
"这一阶段没有生命扣除,重点是运营和阵容成型。",
"训练赛的地图会轮换,不会一直用同一个固定比分模板。",
"每轮训练结束后,真人都需要发送 /结束回合 才会进入下一轮。",
"训练赛会按轮次播报,避免一次性刷出一大串结果。",
"如果出现轮空,会安排本地陪练队补一张 BO1。",
"训练阶段适合观察战术克制,火力、稳定、残局都会影响回合走向。",
"连胜会带来金币,但失败也有保底,保证测试局能继续运营。",
"这部分是纯文字自走棋的节奏层,不再是单条目标数值判定。",
),
"challenges": (
"媒体日问题很尖锐,队伍需要在镜头前保持状态。",
"赞助商临时加码,要求队伍用指定战术打出观赏性。",
"主办方安排了签名会,状态管理成了额外考验。",
"训练馆网络短暂波动,队伍必须快速适应备用机器。",
"赛前采访拖长,指挥需要把注意力重新拉回比赛。",
"赞助商想看激进打法,但稳住节奏的人会拿到更高回报。",
"媒体日不是战斗回合,却会影响队伍的运营资源。",
"队伍如果阵容完整,就更容易把这次插曲处理成奖金。",
"这次挑战只出现一次,处理完就回到小组赛主线。",
"主舞台彩排占用了热身时间,教练的管理能力开始生效。",
"赞助商要求展示新战术,选错方向也不会淘汰,只会少拿金币。",
"现场采访提到了前一场失利,队员心态需要稳住。",
"这类挑战不扣生命,目的只是给运营阶段增加变数。",
"处理好这次插曲的队伍,会在下一场前多一点经济余地。",
),
}
def _hash_int(seed: str) -> int:
@@ -201,6 +421,18 @@ def _hash_int(seed: str) -> int:
)
def _pick_fragment(category: str, seed: str, **values: Any) -> str:
fragments = tuple(NARRATIVE_FRAGMENTS.get(category, ()))
if not fragments:
return ""
template = fragments[_hash_int(f"{category}|{seed}") % len(fragments)]
return template.format(**values)
def _narrative_inventory_size() -> int:
return sum(len(items) for items in NARRATIVE_FRAGMENTS.values())
def _pack_response_parts(parts: list[str]) -> str:
cleaned = [part.strip() for part in parts if part.strip()]
if len(cleaned) <= 1:
@@ -918,21 +1150,152 @@ def _auto_manage_ai(
return stories
MAP_POOL = ("Mirage", "Inferno", "Nuke", "Ancient", "Dust2")
MAP_POOL = ("Mirage", "Inferno", "Nuke", "Ancient", "Dust2", "Anubis", "Overpass")
def _map_score(margin: float, seed: str) -> tuple[str, str, int]:
if margin >= 0.18:
return "16-6", "碾压", 0
if margin >= 0.11:
return "16-9", "明显优势", 0
if margin >= 0.055:
return "16-12", "小胜", 0
if margin >= 0.025:
return "16-14", "险胜", 0
overtime_count = 1 + _hash_int(seed + "|overtime") % 2
winner_score = 16 + overtime_count * 3
return f"{winner_score}-{winner_score - 2}", "加时险胜", overtime_count
def _round_probability(
adjusted_a: float,
adjusted_b: float,
seed: str,
round_index: int,
score_a: int,
score_b: int,
) -> float:
average = max(1.0, (adjusted_a + adjusted_b) / 2)
power_edge = max(-0.16, min(0.16, (adjusted_a - adjusted_b) / average * 0.30))
pressure = max(-0.04, min(0.04, (score_b - score_a) * 0.004))
side_swing = 0.018 if round_index <= 15 else -0.012
if round_index > 30:
side_swing = 0.0
jitter = ((_hash_int(f"{seed}|prob|{round_index}") % 701) - 350) / 10_000
return max(0.32, min(0.68, 0.5 + power_edge + pressure + side_swing + jitter))
def _simulate_map(
state: dict[str, Any],
key_a: str,
key_b: str,
seed: str,
map_index: int,
adjusted_a: float,
adjusted_b: float,
) -> dict[str, Any]:
score_a = 0
score_b = 0
rounds: list[str] = []
current_key = ""
current_run = 0
best_run_key = ""
best_run = 0
def record_round(round_index: int) -> None:
nonlocal score_a, score_b, current_key, current_run
nonlocal best_run_key, best_run
probability_a = _round_probability(
adjusted_a,
adjusted_b,
seed,
round_index,
score_a,
score_b,
)
roll = (_hash_int(f"{seed}|round|{round_index}") % 10_000) / 10_000
winner_key = key_a if roll < probability_a else key_b
if winner_key == key_a:
score_a += 1
else:
score_b += 1
rounds.append(winner_key)
if winner_key == current_key:
current_run += 1
else:
current_key = winner_key
current_run = 1
if current_run > best_run:
best_run = current_run
best_run_key = winner_key
for round_index in range(1, 31):
record_round(round_index)
if score_a >= 16 or score_b >= 16:
break
round_index = len(rounds)
if score_a == 15 and score_b == 15:
while max(score_a, score_b) < 19 or abs(score_a - score_b) < 2:
round_index += 1
record_round(round_index)
if round_index > 72:
break
winner_key = key_a if score_a > score_b else key_b
loser_key = key_b if winner_key == key_a else key_a
winner_score = score_a if winner_key == key_a else score_b
loser_score = score_b if winner_key == key_a else score_a
overtime_rounds = max(0, len(rounds) - 30)
overtime_count = (overtime_rounds + 5) // 6
if overtime_count:
band = "加时险胜"
elif loser_score <= 6:
band = "压制"
elif loser_score <= 10:
band = "优势"
elif loser_score <= 13:
band = "小胜"
else:
band = "险胜"
map_name = MAP_POOL[(map_index - 1) % len(MAP_POOL)]
winner_team = _team_name(state["players"][winner_key])
loser_team = _team_name(state["players"][loser_key])
best_run_team = (
_team_name(state["players"][best_run_key]) if best_run_key else winner_team
)
opening_team = _team_name(
state["players"][key_a if _hash_int(seed + "|open") % 2 == 0 else key_b]
)
actor = _key_player_name(state, winner_key, map_index)
pause = _pick_fragment("pauses", f"{seed}|pause")
run_text = (
_pick_fragment(
"runs",
f"{seed}|run",
team=best_run_team,
count=best_run,
)
if best_run >= 4
else pause
)
overtime_story = ""
if overtime_count:
overtime_story = (
f"{map_name} 常规时间战成 15-15。{winner_team} 在加时第 "
f"{overtime_count} 段抓住 {loser_team} 的转点空隙,以 "
f"{winner_score}-{loser_score} 收下地图。"
)
return {
"map_index": map_index,
"map_name": map_name,
"winner_key": winner_key,
"winner": state["players"][winner_key]["display_name"],
"loser_key": loser_key,
"loser": state["players"][loser_key]["display_name"],
"score": f"{winner_score}-{loser_score}",
"score_a": score_a,
"score_b": score_b,
"band": band,
"overtime_count": overtime_count,
"overtime_story": overtime_story,
"rounds_played": len(rounds),
"best_run_key": best_run_key,
"best_run": best_run,
"opening_text": _pick_fragment("openings", f"{seed}|opening", team=opening_team),
"run_text": run_text,
"pause_text": pause,
"clutch_text": _pick_fragment("clutches", f"{seed}|clutch", player=actor),
"economy_text": _pick_fragment("economy", f"{seed}|economy", team=loser_team),
"closer_text": _pick_fragment("closers", f"{seed}|closer", team=winner_team),
"adjusted_a": round(adjusted_a, 2),
"adjusted_b": round(adjusted_b, 2),
}
def _battle(
@@ -969,51 +1332,23 @@ def _battle(
* (10_000 + fluct_b)
/ 10_000
)
if adjusted_a == adjusted_b:
adjusted_a += (
0.01 if _hash_int(f"{seed}|map|{map_index}|tie") % 2 == 0 else -0.01
)
winner_key = key_a if adjusted_a > adjusted_b else key_b
item = _simulate_map(
state,
key_a,
key_b,
f"{seed}|map|{map_index}",
map_index,
adjusted_a,
adjusted_b,
)
winner_key = str(item["winner_key"])
if winner_key == key_a:
wins_a += 1
else:
wins_b += 1
high = max(adjusted_a, adjusted_b)
low = min(adjusted_a, adjusted_b)
margin = (high - low) / max(high, 1)
score, band, overtime_count = _map_score(
margin,
f"{seed}|map|{map_index}",
)
winner_name = state["players"][winner_key]["display_name"]
loser_key = key_b if winner_key == key_a else key_a
loser_name = state["players"][loser_key]["display_name"]
map_name = MAP_POOL[map_index - 1]
overtime_story = ""
if overtime_count:
overtime_story = (
f"{map_name} 常规时间战成 15-15。"
f"{winner_name} 在第 {overtime_count} 轮加时抓住"
f"{loser_name} 的转点空隙,以 {score} 收下地图。"
)
maps.append(
{
"map_index": map_index,
"map_name": map_name,
"winner_key": winner_key,
"winner": winner_name,
"loser_key": loser_key,
"loser": loser_name,
"score": score,
"band": band,
"overtime_count": overtime_count,
"overtime_story": overtime_story,
"adjusted_a": round(adjusted_a, 2),
"adjusted_b": round(adjusted_b, 2),
"fluctuation_a_bps": fluct_a,
"fluctuation_b_bps": fluct_b,
}
)
item["fluctuation_a_bps"] = fluct_a
item["fluctuation_b_bps"] = fluct_b
maps.append(item)
if wins_a == target_maps or wins_b == target_maps:
break
winner_key, loser_key = (
@@ -1113,11 +1448,15 @@ def _battle_story(result: dict[str, Any]) -> str:
for item in result["maps"]
)
matchup = result["matchup"]
tactic_note = (
"战术克制明显"
if abs(float(matchup["a_total_percent"])) >= 5.5
else "战术拉扯接近"
)
return _host_text(
f"{result['winner']} 在 {result['series_format']} 以 {result['score']} "
f"击败 {result['loser']}({map_scores});"
f"战术 {matchup['tactic_a']} 对 {matchup['tactic_b']},"
f"{result['player_a']} 克制修正 {matchup['a_total_percent']:+.2f}%。"
f"击败 {result['loser']}({map_scores})。"
f"战术面是 {matchup['tactic_a']} 对 {matchup['tactic_b']},{tactic_note}。"
)
@@ -1684,7 +2023,7 @@ def _draft_candidate_cards(state: dict[str, Any], round_number: int) -> list[str
def _start_draft_round(state: dict[str, Any], round_number: int) -> None:
draft_type = "coach" if round_number == 1 else "player"
candidates = (
[coach["id"] for coach in COACH_POOL]
[coach["id"] for coach in COACH_POOL[:10]]
if draft_type == "coach"
else _draft_candidate_cards(state, round_number)
)
@@ -1740,8 +2079,13 @@ def _draft_prompt_part(state: dict[str, Any]) -> str:
return f"现在轮到 @{name} 做出选择,使用 {command}。"
def _add_unit_to_roster(player: dict[str, Any], card_id: str) -> None:
unit = _new_unit(card_id)
def _add_unit_to_roster(
player: dict[str, Any],
card_id: str,
*,
copies: int = 1,
) -> None:
unit = _new_unit(card_id, copies=copies)
if len(player["board"]) < STARTER_COUNT:
player["board"].append(unit)
else:
@@ -1771,13 +2115,15 @@ def _apply_draft_choice(
)
else:
card = CARD_BY_ID[choice_id]
if int(state["pool"].get(choice_id, 0)) <= 0:
draft_copies = 3
if int(state["pool"].get(choice_id, 0)) < draft_copies:
raise ValueError("这名选手已经被选走")
state["pool"][choice_id] -= 1
_add_unit_to_roster(player, choice_id)
state["pool"][choice_id] -= draft_copies
_add_unit_to_roster(player, choice_id, copies=draft_copies)
text = (
f"{player['display_name']} 选择了 {card['name']}!"
f"{card['cost']}费 {card['role']},{card['team']}/{card['nation']}。"
f"{card['cost']}费 {card['role']},{card['team']}/{card['nation']},"
"以 2 星素材加入阵容。"
)
candidates.pop(slot - 1)
draft["candidates"] = candidates
@@ -1876,7 +2222,10 @@ def _ensure_full_rosters(state: dict[str, Any]) -> None:
]
candidates.sort(
key=lambda card_id: (
CARD_BY_ID[card_id]["cost"] if player.get("is_ai") else -CARD_BY_ID[card_id]["cost"],
CARD_BY_ID[card_id]["cost"]
if player.get("is_ai")
else abs(int(CARD_BY_ID[card_id]["cost"]) - 2),
int(CARD_BY_ID[card_id]["cost"]),
_hash_int(f"{state['run_id']}|fill|{key}|{card_id}"),
)
)
@@ -1954,62 +2303,172 @@ def _shop_status_text(state: dict[str, Any], player: dict[str, Any]) -> str:
) + "\n操作:/选1~/选6;/刷新;/锁定;/阵容;/战术 火力|稳定|残局;/结束回合"
def _training_parts(state: dict[str, Any], event_id: str) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]:
parts = [
f"欢迎来到{state['event_title']}!你们已经到达酒店宾馆,即将开始训练赛任务。每队会连续完成三场训练赛,结束后可以 D 牌、调阵容,再用 /结束回合 进入小组赛。"
def _training_schedule(state: dict[str, Any]) -> list[dict[str, Any]]:
rounds: list[dict[str, Any]] = []
keys = list(state["seed_order"])
for round_index, pairs in enumerate(_round_robin_rounds(keys), start=1):
paired = {key for pair in pairs for key in pair}
bye = next((key for key in keys if key not in paired), "")
rounds.append(
{
"round": round_index,
"pairs": [[str(pair[0]), str(pair[1])] for pair in pairs],
"bye": bye,
}
)
return rounds
def _ensure_training_schedule(state: dict[str, Any]) -> None:
if state.get("training_rounds"):
return
state["training_rounds"] = _training_schedule(state)
state["training_round_cursor"] = 0
def _training_pairing_line(state: dict[str, Any], round_item: dict[str, Any]) -> str:
rendered = [
f"{_team_name(state['players'][pair[0]])} vs {_team_name(state['players'][pair[1]])}"
for pair in round_item.get("pairs", [])
]
bye = str(round_item.get("bye") or "")
if bye:
rendered.append(f"{_team_name(state['players'][bye])} vs 本地陪练队")
return ";".join(rendered)
def _training_match_part(
state: dict[str, Any],
result: dict[str, Any],
round_number: int,
) -> str:
item = result["maps"][0]
winner_key = str(result["winner_key"])
loser_key = str(result["loser_key"])
winner = state["players"][winner_key]
loser = state["players"][loser_key]
return "\n".join(
[
(
f"训练赛第 {round_number} 轮:{_team_name(winner)} "
f"vs {_team_name(loser)}"
),
(
f"{item['map_name']}:{item['opening_text']}"
f"{item['run_text']}"
),
(
f"{item['clutch_text']}{item['closer_text']}"
f"{_team_name(winner)} 以 {item['score']} 拿下本图。"
),
(
f"金币:{_team_name(winner)} +{TRAINING_WIN_GOLD},"
f"{_team_name(loser)} +{TRAINING_LOSS_GOLD}。"
),
]
)
def _training_bye_part(state: dict[str, Any], key: str, round_number: int) -> str:
player = state["players"][key]
player["gold"] = int(player["gold"]) + TRAINING_BYE_GOLD
hook = _pick_fragment(
"training_hooks",
f"{state['run_id']}|training|bye|{round_number}|{key}",
)
return (
f"训练赛第 {round_number} 轮:{_team_name(player)} 与本地陪练队完成热身。\n"
f"{hook}\n金币:{_team_name(player)} +{TRAINING_BYE_GOLD}。"
)
def _settle_training_round(
state: dict[str, Any],
event_id: str,
) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]:
_ensure_training_schedule(state)
rounds = list(state.get("training_rounds", []))
cursor = int(state.get("training_round_cursor", 0))
if cursor >= len(rounds):
state["phase"] = "training_complete_manage"
_prepare_new_shops(state, "training-complete")
_reset_ready(state, _human_keys(state))
return [
"训练赛已经全部结束。可以最后 D 牌、调阵容;所有真人发送 /结束回合 后进入小组赛。"
], [], []
round_item = rounds[cursor]
round_number = int(round_item["round"])
state["round_number"] = round_number
parts: list[str] = []
stories: list[dict[str, Any]] = []
battles: list[dict[str, Any]] = []
for key in state["join_order"]:
player = state["players"][key]
power = lineup_power(player)["power"]
lines = [f"{_team_name(player)} 训练赛:"]
total_reward = 0
for index in range(3):
opponent, target = TRAINING_OPPONENTS[
(_hash_int(f"{state['run_id']}|train|{key}|{index}") + index)
% len(TRAINING_OPPONENTS)
]
fluctuation = _hash_int(
f"{state['run_id']}|{event_id}|train|{key}|{index}"
) % 401 - 200
adjusted = power * (10_000 + fluctuation) / 10_000
won = adjusted >= target
reward = 3 if won else 1
total_reward += reward
score = "16-12" if won else "13-16"
if abs(adjusted - target) < 12:
score = "19-17" if won else "17-19"
lines.append(
f"{index + 1}. vs {opponent}(目标 {target},发挥 {adjusted:.1f}):"
f"{'胜' if won else '负'} {score},获得 {reward} 金币"
)
result = {
"opponent": opponent,
"target_power": target,
"player_power": round(adjusted, 1),
"score": score,
"won": won,
"reward": reward,
}
battles.append(
_battle_record(
state,
battle_type="training",
player_a=key,
player_b="training",
result=result,
)
)
player["gold"] = int(player["gold"]) + total_reward
lines.append(f"训练赛合计收入 {total_reward} 金币;当前金币 {player['gold']}。")
text = "\n".join(lines)
if cursor == 0:
intro = (
f"欢迎来到{state['event_title']}!你们已经到达酒店宾馆,"
f"训练赛共 {len(rounds)} 轮。训练赛不扣生命,只产出运营金币。"
)
parts.append(intro)
stories.append(_story(state, "training_open", intro))
hook = _pick_fragment(
"training_hooks",
f"{state['run_id']}|training|hook|{round_number}",
)
parts.append(
f"训练赛第 {round_number}/{len(rounds)} 轮对阵:"
f"{_training_pairing_line(state, round_item)}\n{hook}"
)
for match_index, pair in enumerate(round_item.get("pairs", []), start=1):
key_a, key_b = str(pair[0]), str(pair[1])
result = _battle(
state,
key_a,
key_b,
f"{state['run_id']}|training|{round_number}|{match_index}|{event_id}",
best_of=1,
)
winner_key = str(result["winner_key"])
loser_key = str(result["loser_key"])
state["players"][winner_key]["gold"] = (
int(state["players"][winner_key]["gold"]) + TRAINING_WIN_GOLD
)
state["players"][loser_key]["gold"] = (
int(state["players"][loser_key]["gold"]) + TRAINING_LOSS_GOLD
)
result["training_rewards"] = {
winner_key: TRAINING_WIN_GOLD,
loser_key: TRAINING_LOSS_GOLD,
}
battle = _battle_record(
state,
battle_type="training_bo1",
player_a=key_a,
player_b=key_b,
result=result,
)
text = _training_match_part(state, result, round_number)
parts.append(text)
stories.append(_story(state, "training_result", text, {"person_key": key}))
state["phase"] = "training_manage"
_prepare_new_shops(state, "training")
stories.append(_story(state, "training_result", text, result))
battles.append(battle)
bye = str(round_item.get("bye") or "")
if bye:
text = _training_bye_part(state, bye, round_number)
parts.append(text)
stories.append(_story(state, "training_bye", text, {"person_key": bye}))
state["training_round_cursor"] = cursor + 1
_prepare_new_shops(state, f"training-{round_number}")
_reset_ready(state, _human_keys(state))
parts.append("训练赛阶段结束。可以 /刷新、/选1~/选6、/上阵、/战术;所有真人发送 /结束回合 后进入小组赛。")
if cursor + 1 < len(rounds):
state["phase"] = "training_manage"
parts.append(
"本轮训练结束。现在是运营窗口:/刷新 是 D 牌,/选1~/选6 是买店内选手,"
"/锁定 可保留商店,/上阵 可调整首发;所有真人发送 /结束回合 后进入下一轮训练。"
)
else:
state["phase"] = "training_complete_manage"
parts.append(
"训练赛全部结束。可以最后 D 牌、调阵容;所有真人发送 /结束回合 后进入小组赛。"
)
return parts, stories, battles
@@ -2120,38 +2579,42 @@ def _series_report_parts(
result: dict[str, Any],
) -> list[str]:
key_a, key_b = match["pair"]
team_a = _team_name(state["players"][key_a])
team_b = _team_name(state["players"][key_b])
matchup = result["matchup"]
parts = [
f"{_match_title(state, match)} 开始。{team_a} 战力 {result['power_a']:.1f},{team_b} 战力 {result['power_b']:.1f}。"
(
f"{_match_title(state, match)} 开始。"
f"战术面是 {matchup['tactic_a']} 对 {matchup['tactic_b']},"
"本场按逐回合模拟推进。"
)
]
wins: dict[str, int] = {key_a: 0, key_b: 0}
for index, item in enumerate(result["maps"], start=1):
winner_key = str(item["winner_key"])
loser_key = str(item["loser_key"])
wins[winner_key] += 1
actor = _key_player_name(state, winner_key, index)
closer = _key_player_name(state, loser_key, index + 2)
lead_text = (
f"{_team_name(state['players'][winner_key])} 在系列赛中 "
f"{wins[winner_key]}-{wins[loser_key]} {'领先' if wins[winner_key] > wins[loser_key] else '追平'}"
)
overtime = "常规时间 15-15 后进入加时," if int(item["overtime_count"]) else ""
parts.append(
f"图{index} {item['map_name']}:{overtime}{actor} 在关键回合打开局面,"
f"{closer} 的残局反扑没能改写结果。"
f"图{index} {item['map_name']}:{item['opening_text']}"
f"{item['run_text']}{item['clutch_text']}"
f"{item['economy_text']}{item['closer_text']}"
f"{_team_name(state['players'][winner_key])} 以 {item['score']} 拿下地图;{lead_text}。"
)
winner_key = str(result["winner_key"])
loser_key = str(result["loser_key"])
reward = 5 if match["stage"] == "group" else 6
state["players"][winner_key]["gold"] = int(state["players"][winner_key]["gold"]) + reward
parts.append(
final_text = (
f"系列赛结束,{_team_name(state['players'][winner_key])} 以 {result['score']} 击败 "
f"{_team_name(state['players'][loser_key])},获得 {reward} 金币。"
"可用于 /刷新 和 /选1~/选6。"
"这笔钱可以马上用于 /刷新、/选1~/选6 或锁店等下一轮运营。"
)
return parts[:5]
parts[-1] = parts[-1] + "\n" + final_text
if len(parts) <= 5:
return parts
return [parts[0], parts[1], parts[2], "\n".join(parts[3:-1]), parts[-1]]
def _settle_current_match(
@@ -2214,9 +2677,13 @@ def _special_event_parts(
won = power >= target
reward = 4 if won else 1
player["gold"] = int(player["gold"]) + reward
hook = _pick_fragment(
"challenges",
f"{state['run_id']}|{event_kind}|{event_id}|{key}",
)
text = (
f"{_team_name(player)} {'完成' if won else '勉强应付'}{event_kind}:"
f"表现 {power:.1f}/{target},获得 {reward} 金币。"
f"{hook}获得 {reward} 金币。"
)
parts.append(text)
stories.append(_story(state, "special_event", text, {"person_key": key}))
@@ -2289,7 +2756,8 @@ class CsMajorAutochess:
"选秀完成后每队 5 名首发、1 名教练、3 个备战席。\n"
"/选1~/选6;/阵容;/刷新;/锁定;/上阵 备战编号 [首发位];/出售 备战编号。"
"/战术 火力|稳定|残局,稳定克火力、火力克残局、残局克稳定。\n"
"三场训练赛后进入 BO3 单循环小组赛;半决赛/决赛 BO5。"
"训练赛按参赛人数生成多轮真实对阵,每轮结束后可 D 牌和调阵容;"
"随后进入 BO3 单循环小组赛,半决赛/决赛 BO5。"
"所有需要推进的阶段由相关真人发送 /结束回合,满足条件后自动进入下一步。"
)
@@ -2373,7 +2841,7 @@ class CsMajorAutochess:
)
season = next_season
state = {
"schema_version": 2,
"schema_version": 3,
"run_id": f"major_{season:04d}",
"season_number": season,
"event_title": f"第 {season} 届 IEM 盐田 Major",
@@ -2403,6 +2871,8 @@ class CsMajorAutochess:
"story_sequence": 0,
"battle_sequence": 0,
"special_event_done": False,
"training_rounds": [],
"training_round_cursor": 0,
}
text = (
f"{state['event_title']} 开放报名;主持人 {display_name} 已自动加入。"
@@ -2485,7 +2955,7 @@ class CsMajorAutochess:
f"参赛者:{names}。",
(
f"种子顺序:{_seed_lines(state)}。本届采用 {mode}:"
"先进行三轮选秀,随后训练赛、小组赛 BO3 单循环,"
"先进行三轮选秀,随后按轮次训练赛、小组赛 BO3 单循环,"
"淘汰赛 BO5;单图 MR15,常规时间 15-15 进入加时。"
),
_draft_board_part(state),
@@ -2667,10 +3137,10 @@ class CsMajorAutochess:
phase = str(state.get("phase"))
stories: list[dict[str, Any]] = []
battles: list[dict[str, Any]] = []
if phase == "lineup_prep":
parts, stories, battles = _training_parts(state, event_id)
if phase in {"lineup_prep", "training_manage"}:
parts, stories, battles = _settle_training_round(state, event_id)
return respond(state, parts, stories=stories, battles=battles)
if phase == "training_manage":
if phase == "training_complete_manage":
parts = _start_group_parts(state)
return respond(state, parts)
if phase == "group_ready":
@@ -158,8 +158,10 @@ LOCAL_EXTENSIONS = (
LocalExtensionDescriptor(
extension_id="cs-major-autochess",
author=JWEBOT_AUTHOR,
description="CS自走棋",
help_entries=("/自走棋开始|报名",),
description="自走棋",
help_entries=(
"/自走棋 开始|报名|选",
),
command_kinds=("cs_autochess",),
),
LocalExtensionDescriptor(
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-sdk"
version = "0.6.6a0+1015"
version = "0.6.7a0+1015"
description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9"
dependencies = []
@@ -38,4 +38,4 @@ __all__ = [
"SingleGroupContext",
]
__version__ = "0.6.6a0+1015"
__version__ = "0.6.7a0+1015"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot]
release = "alpha 0.6.6.1015"
release = "alpha 0.6.7.1015"
production_deployments_enabled_by_default = false
[tool.jwebot.workspace]
+21 -11
View File
@@ -77,12 +77,15 @@ class Release064AutochessTests(unittest.TestCase):
return replies
def test_static_pool_parser_and_coach_pool(self):
self.assertEqual(len(AUTOCHESS.PLAYER_POOL), 60)
self.assertEqual(len({card["id"] for card in AUTOCHESS.PLAYER_POOL}), 60)
self.assertEqual(len(AUTOCHESS.COACH_POOL), 10)
self.assertGreaterEqual(len(AUTOCHESS.PLAYER_POOL), 100)
self.assertEqual(
len({card["id"] for card in AUTOCHESS.PLAYER_POOL}),
len(AUTOCHESS.PLAYER_POOL),
)
self.assertGreaterEqual(len(AUTOCHESS.COACH_POOL), 18)
self.assertEqual(
len({coach["id"] for coach in AUTOCHESS.COACH_POOL}),
10,
len(AUTOCHESS.COACH_POOL),
)
self.assertEqual(
AUTOCHESS.parse_autochess_action("/自走棋开始"),
@@ -114,7 +117,7 @@ class Release064AutochessTests(unittest.TestCase):
self.assertEqual(player["board"], [])
self.assertEqual(player["coach_id"], "")
def test_three_round_draft_training_and_group_intro(self):
def test_three_round_draft_training_rounds_and_group_intro(self):
self.start_two_human_two_ai()
final_draft = self.finish_draft()
state = self.state()
@@ -126,14 +129,21 @@ class Release064AutochessTests(unittest.TestCase):
self.assertEqual(len(player["bench"]), 3)
self.assertTrue(player["coach_id"])
self.command("/结束回合")
training = self.command("/结束回合", sender="Bob")
self.assertIn("Team Falcons", "\n".join(training))
self.assertIn("Natus Vincere", "\n".join(training))
training = self.ready_all()
training_text = "\n".join(training)
self.assertIn("训练赛第 1/3 轮", training_text)
self.assertIn("Team", training_text)
self.assertNotIn("目标", training_text)
self.assertNotIn("发挥", training_text)
self.assertEqual(self.state()["phase"], "training_manage")
self.command("/结束回合")
group = self.command("/结束回合", sender="Bob")
round_two = "\n".join(self.ready_all())
self.assertIn("训练赛第 2/3 轮", round_two)
round_three = "\n".join(self.ready_all())
self.assertIn("训练赛第 3/3 轮", round_three)
self.assertEqual(self.state()["phase"], "training_complete_manage")
group = self.ready_all()
self.assertIn("正赛小组赛", "\n".join(group))
state = self.state()
self.assertEqual(state["phase"], "group_ready")
+138
View File
@@ -0,0 +1,138 @@
"""Behavioral coverage for the CS Major autochess 0.6.7 pacing rewrite."""
from __future__ import annotations
import json
import re
import tempfile
import unittest
from pathlib import Path
from test_jwebot_edge import MODULE, config
from jwebot_official_extensions import cs_autochess as AUTOCHESS
class Release067AutochessTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.engine = MODULE.EdgeApplication(config(Path(self.temp.name)))
self.counter = 0
def tearDown(self):
self.engine.close()
self.temp.cleanup()
def command(self, question, *, sender="Alice", event_id=None):
self.counter += 1
response = self.engine.accept(
{
"event_id": event_id or f"autochess-067-{self.counter}",
"group_fingerprint": "group-a",
"sender": sender,
"text": "@JWeBot " + question,
"mention_bot": True,
"confidence": 1.0,
"sender_confidence": 1.0,
"body_confidence": 1.0,
}
)
self.assertEqual(response["status"], "reply")
return list(response.get("texts") or [response["text"]])
def state(self):
row = self.engine.ledger.connection.execute(
"""
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 ready_all(self):
state = self.state()
replies = []
for key in list(state.get("ready_keys") or []):
replies.extend(
self.command(
"/结束回合",
sender=state["players"][key]["display_name"],
)
)
return replies
def start_two_human_two_ai(self):
self.command("/自走棋开始")
self.command("/自走棋报名", sender="Bob")
self.command("/自走棋加AI战队")
self.command("/自走棋开赛")
def finish_draft(self):
self.command("/选1")
self.command("/选1", sender="Bob")
self.command("/选1", sender="Bob")
self.command("/选1")
self.command("/选1")
self.command("/选1", sender="Bob")
def test_narrative_inventory_and_training_round_robin(self):
self.assertGreaterEqual(AUTOCHESS._narrative_inventory_size(), 120)
self.start_two_human_two_ai()
self.finish_draft()
training = "\n".join(self.ready_all())
state = self.state()
self.assertEqual(len(state["training_rounds"]), 3)
self.assertEqual(state["training_round_cursor"], 1)
self.assertIn("训练赛第 1/3 轮对阵", training)
self.assertIn("Team", training)
self.assertIn("AI 战队", training)
self.assertNotIn("目标", training)
self.assertNotIn("发挥", training)
def test_round_simulation_produces_natural_score_variety(self):
self.start_two_human_two_ai()
self.finish_draft()
state = self.state()
key_a, key_b = state["seed_order"][:2]
scores = set()
for index in range(12):
result = AUTOCHESS._battle(
state,
key_a,
key_b,
f"score-variety-{index}",
best_of=1,
)
score = result["maps"][0]["score"]
self.assertRegex(score, r"^\d+-\d+$")
self.assertGreaterEqual(result["maps"][0]["rounds_played"], 16)
scores.add(score)
old_fixed = {"16-6", "16-9", "16-12", "16-14", "19-17"}
self.assertGreaterEqual(len(scores), 3)
self.assertTrue(scores - old_fixed)
def test_group_report_is_paced_and_hides_raw_calculation_terms(self):
self.start_two_human_two_ai()
self.finish_draft()
self.ready_all()
self.ready_all()
self.ready_all()
self.ready_all()
report = "\n".join(self.ready_all())
self.assertIn("图1", report)
self.assertIn("系列赛结束", report)
self.assertNotIn("目标", report)
self.assertNotIn("发挥", report)
self.assertNotIn("克制修正", report)
self.assertIsNotNone(re.search(r"\b\d{2}-\d{1,2}\b", report))
if __name__ == "__main__":
unittest.main()