feat: add two-human AI autochess mode
check / local-gate (push) Failing after 50s

This commit is contained in:
JWeBot Contributors
2026-10-15 16:08:59 +08:00
parent e713ff369a
commit 9fff57134f
18 changed files with 228 additions and 41 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## alpha 0.6.5.1015
- Extend the AI test path for CS Major autochess to support two-human games:
`/自走棋加AI战队` now adds AI 战队 A and AI 战队 B when exactly two
real players have registered.
- Keep the same command for two- and three-human test modes. The tournament
engine selects 2 人+2 AI or 3 人+1 AI from the current registration count.
- Lock the roster after AI teams join, seed the four-team bracket as 1v4 and
2v3, and keep AI teams out of the human preparation order while preserving
independent AI operations and story archives.
## alpha 0.6.4.1015 ## alpha 0.6.4.1015
- Add a reviewed 2-8 player CS Major autochess extension with a fixed - Add a reviewed 2-8 player CS Major autochess extension with a fixed
+5 -3
View File
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 GUI 发送链路回复。
当前版本:`alpha 0.6.4.1015`。 当前版本:`alpha 0.6.5.1015`。
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加 0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见 两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
@@ -26,6 +26,8 @@ BO3/BO5 的 MR15 加时赛。支持三名真人加入一支确定性 AI 测试
Taffy 负责赛制播报、顺序准备、交棒和全程剧情主持。所有选秀、战斗和 Taffy 负责赛制播报、顺序准备、交棒和全程剧情主持。所有选秀、战斗和
加时剧情均在本地事务内落档。 加时剧情均在本地事务内落档。
完整规则见[CS Major 自走棋设计稿](docs/cs-major-autochess-design.md)。 完整规则见[CS Major 自走棋设计稿](docs/cs-major-autochess-design.md)。
0.6.5 将 AI 测试模式扩展到双人:两名真人可一键补入 AI 战队 A、B,
按四队种子运行 1v4、2v3 半决赛;两支 AI 独立运营且不占真人准备顺序。
### 运行流程 ### 运行流程
@@ -97,7 +99,7 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/游戏记录 [页码](/gamehistory) /游戏记录 [页码](/gamehistory)
/游戏帮助(/games) /游戏帮助(/games)
/自走棋开始|报名|开赛|状态|结算|档案 /自走棋开始|报名|开赛|状态|结算|档案
/自走棋加AI战队(三名真人测试模式) /自走棋加AI战队(两名或三名真人测试模式)
/选1~6;/战术 火力|稳定|残局;/阵容;/结束回合 /选1~6;/战术 火力|稳定|残局;/阵容;/结束回合
/丢骰子 [NdM] /丢骰子 [NdM]
/猜数字 开始 [上限] /猜数字 开始 [上限]
@@ -267,7 +269,7 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```text
alpha 0.6.4.1015 alpha 0.6.5.1015
``` ```
## Runtime Flow ## Runtime Flow
+1 -1
View File
@@ -1 +1 @@
alpha 0.6.4.1015 alpha 0.6.5.1015
+4 -2
View File
@@ -33,8 +33,10 @@
- 开局命令由任意玩家发起。 - 开局命令由任意玩家发起。
- 其他玩家在报名窗口内加入。 - 其他玩家在报名窗口内加入。
- 建议一局支持 `2~8` 名真人玩家。 - 建议一局支持 `2~8` 名真人玩家。
- 主持人可在报名阶段发送 `/自走棋加AI战队`,最多加入一支 AI 战队。 - 主持人可在报名阶段发送 `/自走棋加AI战队`:
- 三名真人加一支 AI 时,按四队赛制直接生成 `1v4`、`2v3` 半决赛。 - 两名真人时加入 AI 战队 A、B;
- 三名真人时加入一支 AI 战队。
- 两种测试模式都补满四队,直接生成 `1v4`、`2v3` 半决赛。
- AI 占一个参赛席位,但不对应群成员,也不调用在线模型。 - AI 占一个参赛席位,但不对应群成员,也不调用在线模型。
- AI 每回合以确定性规则买牌、升级教练、调整首发和选择克制战术。 - AI 每回合以确定性规则买牌、升级教练、调整首发和选择克制战术。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-config" name = "jwebot-config"
version = "0.6.4a0+1015" version = "0.6.5a0+1015"
description = "Profile-based configuration compiler for JWeBot" description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile", "write_default_profile",
] ]
__version__ = "0.6.4a0+1015" __version__ = "0.6.5a0+1015"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-extension-host" name = "jwebot-extension-host"
version = "0.6.4a0+1015" version = "0.6.5a0+1015"
description = "Single-release, single-invocation process host for JWeBot extensions" description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-sdk==0.6.4a0+1015", "jwebot-sdk==0.6.5a0+1015",
"jwebot-kernel==0.6.4a0+1015", "jwebot-kernel==0.6.5a0+1015",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "0.6.4a0+1015" __version__ = "0.6.5a0+1015"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-kernel" name = "jwebot-kernel"
version = "0.6.4a0+1015" version = "0.6.5a0+1015"
description = "Stable routing and extension authority for JWeBot" description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-config==0.6.4a0+1015", "jwebot-config==0.6.5a0+1015",
"jwebot-sdk==0.6.4a0+1015", "jwebot-sdk==0.6.5a0+1015",
"jwebot-official-extensions==0.6.4a0+1015", "jwebot-official-extensions==0.6.5a0+1015",
"tomli>=2; python_version < '3.11'", "tomli>=2; python_version < '3.11'",
] ]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature", "verify_installed_release_signature",
] ]
__version__ = "0.6.4a0+1015" __version__ = "0.6.5a0+1015"
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-official-extensions" name = "jwebot-official-extensions"
version = "0.6.4a0+1015" version = "0.6.5a0+1015"
description = "Reviewed local command adapters and broker implementation for JWeBot" description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-sdk==0.6.4a0+1015", "jwebot-sdk==0.6.5a0+1015",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -84,4 +84,4 @@ __all__ = [
"render_feature_list", "render_feature_list",
] ]
__version__ = "0.6.4a0+1015" __version__ = "0.6.5a0+1015"
@@ -11,6 +11,8 @@ MIN_PLAYERS = 2
MAX_PLAYERS = 8 MAX_PLAYERS = 8
AI_PERSON_KEY = "__local_ai_team__" AI_PERSON_KEY = "__local_ai_team__"
AI_DISPLAY_NAME = "AI 战队" AI_DISPLAY_NAME = "AI 战队"
AI_SECOND_PERSON_KEY = "__local_ai_team_2__"
AI_SECOND_DISPLAY_NAME = "AI 战队 B"
HOST_NAME = "Taffy" HOST_NAME = "Taffy"
STARTING_GOLD = 10 STARTING_GOLD = 10
STARTING_HP = 100 STARTING_HP = 100
@@ -325,6 +327,11 @@ def _preparation_prompt(state: dict[str, Any]) -> str:
def _format_description(state: dict[str, Any]) -> str: def _format_description(state: dict[str, Any]) -> str:
team_count = len(state["join_order"]) team_count = len(state["join_order"])
if state.get("ai_test_variant") == "two_plus_two":
return (
"本届采用 2 人+2 AI 测试赛:报名种子 1v4、2v3 进行 BO5 "
"半决赛,胜者进入 BO5 总决赛"
)
if state.get("ai_test_mode"): if state.get("ai_test_mode"):
return ( return (
"本届采用 3 人+1 AI 测试赛:报名种子 1v4、2v3 进行 BO5 " "本届采用 3 人+1 AI 测试赛:报名种子 1v4、2v3 进行 BO5 "
@@ -844,7 +851,7 @@ def _auto_manage_ai(
+ CARD_BY_ID[str(incoming["card_id"])]["name"] + CARD_BY_ID[str(incoming["card_id"])]["name"]
) )
text = ( text = (
f"{AI_DISPLAY_NAME} 自动运营:{'、'.join(actions)};" f"{player['display_name']} 自动运营:{'、'.join(actions)};"
f"金币 {player['gold']},五人首发战力 " f"金币 {player['gold']},五人首发战力 "
f"{lineup_power(player)['power']:.1f}。" f"{lineup_power(player)['power']:.1f}。"
) )
@@ -1573,7 +1580,8 @@ class CsMajorAutochess:
def rules() -> str: def rules() -> str:
return ( return (
"CS自走棋:/自走棋开始 创建报名;/自走棋报名;主持人 /自走棋开赛。\n" "CS自走棋:/自走棋开始 创建报名;/自走棋报名;主持人 /自走棋开赛。\n"
"三名真人报名后,主持人可用 /自走棋加AI战队 开启 3 人+1 AI 测试模式。\n" "两名或三名真人报名后,主持人可用 /自走棋加AI战队,"
"分别开启 2 人+2 AI 或 3 人+1 AI 测试模式。\n"
"每人固定 5 名首发 + 1 名教练,开赛即发完整首发;每轮公开 6 人商店。" "每人固定 5 名首发 + 1 名教练,开赛即发完整首发;每轮公开 6 人商店。"
"/选1~/选6 购买;/阵容;/上阵 备战编号 [首发位];/出售 备战编号。\n" "/选1~/选6 购买;/阵容;/上阵 备战编号 [首发位];/出售 备战编号。\n"
"教练只提供全队 +2%~+10% 加成;/升级教练;/刷新;/锁定。" "教练只提供全队 +2%~+10% 加成;/升级教练;/刷新;/锁定。"
@@ -1664,6 +1672,7 @@ class CsMajorAutochess:
"round_plan": [], "round_plan": [],
"group_results": [], "group_results": [],
"ai_test_mode": False, "ai_test_mode": False,
"ai_test_variant": "",
"preparation_order": [], "preparation_order": [],
"preparation_cursor": 0, "preparation_cursor": 0,
"qualifiers": [], "qualifiers": [],
@@ -1694,7 +1703,7 @@ class CsMajorAutochess:
return _result( return _result(
state, state,
_host_text( _host_text(
"3 人+1 AI 测试模式已经锁定参赛名单,不能再加入真人。" "AI 测试模式已经锁定参赛名单,不能再加入真人。"
), ),
) )
if person_key in state["players"]: if person_key in state["players"]:
@@ -1724,27 +1733,47 @@ class CsMajorAutochess:
state, state,
f"只有主持人 {state['host_display_name']} 可以加入 AI 战队", f"只有主持人 {state['host_display_name']} 可以加入 AI 战队",
) )
if AI_PERSON_KEY in state["players"]: if any(
return _result(state, "3 人+1 AI 测试战队已经加入") player.get("is_ai")
for player in state["players"].values()
):
return _result(state, "AI 测试战队已经加入")
human_keys = [ human_keys = [
key key
for key in state["join_order"] for key in state["join_order"]
if not state["players"][key].get("is_ai") if not state["players"][key].get("is_ai")
] ]
if len(human_keys) != 3: if len(human_keys) not in {2, 3}:
return _result( return _result(
state, state,
"3 人+1 AI 测试模式要求先有且仅有 3 名真人报名", "AI 测试模式要求先有且仅有 2 名或 3 名真人报名",
) )
state["players"][AI_PERSON_KEY] = _participant( if len(human_keys) == 2:
AI_PERSON_KEY, ai_teams = (
AI_DISPLAY_NAME, (AI_PERSON_KEY, "AI 战队 A"),
is_ai=True, (AI_SECOND_PERSON_KEY, AI_SECOND_DISPLAY_NAME),
) )
state["join_order"].append(AI_PERSON_KEY) variant = "two_plus_two"
mode_label = "2 人+2 AI"
join_text = (
"AI 战队 A、AI 战队 B 已作为第 3、4 种子加入测试赛;"
)
else:
ai_teams = ((AI_PERSON_KEY, AI_DISPLAY_NAME),)
variant = "three_plus_one"
mode_label = "3 人+1 AI"
join_text = "AI 战队已作为第 4 种子加入测试赛;"
for ai_key, ai_name in ai_teams:
state["players"][ai_key] = _participant(
ai_key,
ai_name,
is_ai=True,
)
state["join_order"].append(ai_key)
state["ai_test_mode"] = True state["ai_test_mode"] = True
state["ai_test_variant"] = variant
text = _host_text( text = _host_text(
"AI 战队已作为第 4 种子加入测试赛;" f"{join_text}{mode_label} 编制已锁定;"
"开赛后固定进行 1v4、2v3 半决赛,AI 将自动运营。" "开赛后固定进行 1v4、2v3 半决赛,AI 将自动运营。"
) )
story = _story( story = _story(
@@ -1752,8 +1781,16 @@ class CsMajorAutochess:
"ai_team_joined", "ai_team_joined",
text, text,
{ {
"person_key": AI_PERSON_KEY, "person_keys": [
"seed": 4, ai_key for ai_key, _ in ai_teams
],
"seeds": list(
range(
len(human_keys) + 1,
len(human_keys) + len(ai_teams) + 1,
)
),
"variant": variant,
"test_mode": True, "test_mode": True,
}, },
) )
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-sdk" name = "jwebot-sdk"
version = "0.6.4a0+1015" version = "0.6.5a0+1015"
description = "Typed, capability-limited extension API for JWeBot plugins" description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [] dependencies = []
@@ -38,4 +38,4 @@ __all__ = [
"SingleGroupContext", "SingleGroupContext",
] ]
__version__ = "0.6.4a0+1015" __version__ = "0.6.5a0+1015"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot] [tool.jwebot]
release = "alpha 0.6.4.1015" release = "alpha 0.6.5.1015"
production_deployments_enabled_by_default = false production_deployments_enabled_by_default = false
[tool.jwebot.workspace] [tool.jwebot.workspace]
+2 -2
View File
@@ -374,11 +374,11 @@ class Release064AutochessTests(unittest.TestCase):
def test_three_humans_plus_ai_has_taffy_host_and_turn_handoffs(self): def test_three_humans_plus_ai_has_taffy_host_and_turn_handoffs(self):
self.command("/自走棋开始") self.command("/自走棋开始")
self.command("/自走棋报名", sender="Bob")
self.assertIn( self.assertIn(
"先有且仅有 3 名真人", "2 名或 3 名真人",
self.command("/自走棋加AI战队"), self.command("/自走棋加AI战队"),
) )
self.command("/自走棋报名", sender="Bob")
self.command("/自走棋报名", sender="Carol") self.command("/自走棋报名", sender="Carol")
self.assertIn( self.assertIn(
"只有主持人", "只有主持人",
+135
View File
@@ -0,0 +1,135 @@
"""Behavioral coverage for the two-human plus two-AI test mode."""
from __future__ import annotations
import json
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 Release065TwoHumanAiTests(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
return self.engine.local.handle(
{
"question": question,
"sender": sender,
"group_fingerprint": "group-a",
"event_id": event_id or f"two-ai-{self.counter}",
},
{},
)
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 test_two_humans_add_two_ai_and_use_four_seed_bracket(self):
self.command("/自走棋开始")
self.command("/自走棋报名", sender="Bob")
added = self.command("/自走棋加AI战队")
self.assertIn("AI 战队 A、AI 战队 B", added)
self.assertIn("第 3、4 种子", added)
self.assertIn("2 人+2 AI", added)
self.assertIn(
"已经锁定参赛名单",
self.command("/自走棋报名", sender="Carol"),
)
launch = self.command("/自走棋开赛")
state = self.state()
self.assertIn("2 人+2 AI 测试赛", launch)
self.assertEqual(state["ai_test_variant"], "two_plus_two")
self.assertEqual(
state["seed_order"][2:],
[
AUTOCHESS.AI_PERSON_KEY,
AUTOCHESS.AI_SECOND_PERSON_KEY,
],
)
self.assertEqual(
state["preparation_order"],
state["seed_order"][:2],
)
self.assertTrue(
state["players"][AUTOCHESS.AI_PERSON_KEY]["is_ai"]
)
self.assertTrue(
state["players"][AUTOCHESS.AI_SECOND_PERSON_KEY]["is_ai"]
)
semifinal = state["round_plan"][-2]
self.assertEqual(
semifinal["pairs"],
[
[state["seed_order"][0], state["seed_order"][3]],
[state["seed_order"][1], state["seed_order"][2]],
],
)
self.assertIn("到 bob 做准备了", self.command("/结束回合"))
self.assertIn(
"全员准备完毕",
self.command("/结束回合", sender="Bob"),
)
settled = self.command("/自走棋结算")
self.assertIn("AI 战队 A 自动运营", settled)
self.assertIn("AI 战队 B 自动运营", settled)
self.assertEqual(
self.engine.ledger.connection.execute(
"""
SELECT COUNT(*) FROM autochess_story_logs
WHERE story_type = 'ai_operation'
"""
).fetchone()[0],
2,
)
def test_add_two_ai_is_idempotent_and_cannot_be_repeated(self):
self.command("/自走棋开始")
self.command("/自走棋报名", sender="Bob")
first = self.command(
"/自走棋加AI战队",
event_id="same-add-two-ai",
)
replay = self.command(
"/自走棋加AI战队",
event_id="same-add-two-ai",
)
self.assertEqual(first, replay)
self.assertIn(
"已经加入",
self.command("/自走棋加AI战队"),
)
state = self.state()
self.assertEqual(
sum(
bool(player.get("is_ai"))
for player in state["players"].values()
),
2,
)
if __name__ == "__main__":
unittest.main()