Skip to content

Commit e2ebcc3

Browse files
committed
v9.0 Phase2: AI扮演+录制回放+跨端一致性 - 738个测试全绿
Phase2新增: - AIPlayerEngine: 4种策略(random/normal/boundary/explorer)自动扮演 - ActionRecorder/Replayer: 录制操作序列+倍速回放+玩家过滤+蓝本导出 - ConsistencyChecker: 跨端MD5哈希对比+文本相似度+一致性得分(0-100) - 12个新API端点(AI扮演3+录制4+回放3+一致性2) - 修复AI玩家error分支无限循环bug - 统一命名: 多人协同多端协同测试 - 52个新增测试(AIPlayer13+Recorder8+Replayer7+Consistency12+其他12)
1 parent bb28a6f commit e2ebcc3

9 files changed

Lines changed: 1329 additions & 10 deletions

File tree

desktop/src/components/AppLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const navItems = [
2222
{ to: '/running', icon: Activity, label: '测试面板' },
2323
{ to: '/desktop-test', icon: Monitor, label: '桌面测试' },
2424
{ to: '/miniprogram-test', icon: Smartphone, label: '小程序测试' },
25-
{ to: '/multiplayer-test', icon: Users, label: '多人测试' },
25+
{ to: '/multiplayer-test', icon: Users, label: '多端协同' },
2626
{ to: '/history', icon: History, label: '历史记录' },
2727
{ to: '/analytics', icon: BarChart3, label: '报告分析' },
2828
{ to: '/dashboard', icon: User, label: '个人中心' },

desktop/src/pages/MultiPlayerTestPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export default function MultiPlayerTestPage() {
155155
{/* 标题 */}
156156
<div className="flex items-center justify-between">
157157
<h1 className="text-xl font-bold text-white flex items-center gap-2">
158-
<Gamepad2 className="w-5 h-5 text-amber-400" /> 多人协同测试
158+
<Gamepad2 className="w-5 h-5 text-amber-400" /> 多端协同测试
159159
</h1>
160160
<div className="flex items-center gap-2">
161161
{status?.running && (

src/api/routes.py

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1375,10 +1375,152 @@ async def run_multiplayer_blueprint(req: dict) -> dict:
13751375
await bp.execute(_orchestrator)
13761376
return bp.get_report()
13771377

1378-
@router.delete("/multiplayer/room", tags=["多人测试"])
1378+
@router.delete("/multiplayer/room", tags=["多端协同"])
13791379
async def destroy_room() -> dict:
13801380
"""销毁房间,断开所有玩家。"""
13811381
await _orchestrator.reset()
13821382
return {"message": "房间已销毁"}
13831383

1384+
# ── 多端协同 Phase2(v9.0)────────────────────
1385+
1386+
from src.testing.ai_player import AIPlayerEngine, AIPlayerConfig, AIStrategy
1387+
from src.testing.action_recorder import ActionRecorder, ActionReplayer
1388+
from src.testing.consistency_checker import ConsistencyChecker
1389+
1390+
_ai_engines: dict[str, AIPlayerEngine] = {}
1391+
_recorder = ActionRecorder()
1392+
_replayer: Optional[ActionReplayer] = None
1393+
_checker = ConsistencyChecker()
1394+
1395+
@router.post("/multiplayer/ai/start/{player_id}", tags=["多端协同-AI"])
1396+
async def start_ai_player(player_id: str, req: dict = None) -> dict:
1397+
"""启动 AI 自动扮演指定玩家。
1398+
1399+
Body: strategy(random/normal/boundary/explorer), max_actions, action_delay
1400+
"""
1401+
req = req or {}
1402+
strategy = AIStrategy(req.get("strategy", "normal"))
1403+
config = AIPlayerConfig(
1404+
strategy=strategy,
1405+
max_actions=req.get("max_actions", 50),
1406+
action_delay=req.get("action_delay", 1.0),
1407+
)
1408+
engine = AIPlayerEngine(config)
1409+
_ai_engines[player_id] = engine
1410+
1411+
import asyncio
1412+
asyncio.create_task(engine.run_player(_orchestrator, player_id))
1413+
return {
1414+
"player_id": player_id,
1415+
"strategy": strategy.value,
1416+
"max_actions": config.max_actions,
1417+
}
1418+
1419+
@router.post("/multiplayer/ai/stop/{player_id}", tags=["多端协同-AI"])
1420+
async def stop_ai_player(player_id: str) -> dict:
1421+
"""停止指定玩家的 AI 扮演。"""
1422+
engine = _ai_engines.get(player_id)
1423+
if engine:
1424+
engine.stop()
1425+
return {"player_id": player_id, "actions_done": engine.action_count}
1426+
raise HTTPException(status_code=404, detail=f"AI玩家 {player_id} 不存在")
1427+
1428+
@router.get("/multiplayer/ai/report/{player_id}", tags=["多端协同-AI"])
1429+
async def ai_player_report(player_id: str) -> dict:
1430+
"""获取 AI 玩家操作报告。"""
1431+
engine = _ai_engines.get(player_id)
1432+
if engine:
1433+
return engine.get_report()
1434+
raise HTTPException(status_code=404, detail=f"AI玩家 {player_id} 不存在")
1435+
1436+
@router.post("/multiplayer/record/start", tags=["多端协同-录制"])
1437+
async def start_recording() -> dict:
1438+
"""开始录制操作。"""
1439+
_recorder.start()
1440+
return {"recording": True}
1441+
1442+
@router.post("/multiplayer/record/stop", tags=["多端协同-录制"])
1443+
async def stop_recording() -> dict:
1444+
"""停止录制。"""
1445+
_recorder.stop()
1446+
return {
1447+
"recording": False,
1448+
"actions": _recorder.action_count,
1449+
"duration": round(_recorder.duration, 2),
1450+
}
1451+
1452+
@router.post("/multiplayer/record/action", tags=["多端协同-录制"])
1453+
async def record_action(req: dict) -> dict:
1454+
"""录制一条操作。
1455+
1456+
Body: player_id, action, params(可选)
1457+
"""
1458+
if not _recorder.is_recording:
1459+
raise HTTPException(status_code=400, detail="未在录制状态")
1460+
recorded = _recorder.record(
1461+
player_id=req.get("player_id", ""),
1462+
action=req.get("action", ""),
1463+
params=req.get("params", {}),
1464+
)
1465+
return {"recorded": True, "offset": round(recorded.offset, 3), "total": _recorder.action_count}
1466+
1467+
@router.get("/multiplayer/record/export", tags=["多端协同-录制"])
1468+
async def export_recording() -> dict:
1469+
"""导出录制为蓝本格式。"""
1470+
return _recorder.export_blueprint()
1471+
1472+
@router.post("/multiplayer/replay/start", tags=["多端协同-回放"])
1473+
async def start_replay(req: dict = None) -> dict:
1474+
"""开始回放录制的操作。
1475+
1476+
Body: speed(倍率), player_filter(可选玩家列表)
1477+
"""
1478+
nonlocal _replayer
1479+
req = req or {}
1480+
actions = _recorder.actions
1481+
if not actions:
1482+
raise HTTPException(status_code=400, detail="无录制数据")
1483+
_replayer = ActionReplayer(actions)
1484+
1485+
import asyncio
1486+
speed = req.get("speed", 1.0)
1487+
player_filter = req.get("player_filter")
1488+
asyncio.create_task(_replayer.replay(_orchestrator, speed, player_filter))
1489+
return {"replaying": True, "total_actions": len(actions), "speed": speed}
1490+
1491+
@router.post("/multiplayer/replay/stop", tags=["多端协同-回放"])
1492+
async def stop_replay() -> dict:
1493+
"""停止回放。"""
1494+
if _replayer:
1495+
_replayer.stop()
1496+
return _replayer.get_status()
1497+
return {"replaying": False}
1498+
1499+
@router.get("/multiplayer/replay/status", tags=["多端协同-回放"])
1500+
async def replay_status() -> dict:
1501+
"""获取回放进度。"""
1502+
if _replayer:
1503+
return _replayer.get_status()
1504+
return {"replaying": False, "total": 0, "current": 0, "progress": 0}
1505+
1506+
@router.post("/multiplayer/consistency/check", tags=["多端协同-一致性"])
1507+
async def consistency_check(req: dict = None) -> dict:
1508+
"""执行跨端一致性检查。
1509+
1510+
Body: player_ids(可选, 默认全部), check_source, check_screenshot
1511+
"""
1512+
req = req or {}
1513+
report = await _checker.check(
1514+
_orchestrator,
1515+
player_ids=req.get("player_ids"),
1516+
check_source=req.get("check_source", True),
1517+
check_screenshot=req.get("check_screenshot", True),
1518+
)
1519+
return report.to_dict()
1520+
1521+
@router.get("/multiplayer/consistency/summary", tags=["多端协同-一致性"])
1522+
async def consistency_summary() -> dict:
1523+
"""获取一致性检查历史摘要。"""
1524+
return _checker.get_summary()
1525+
13841526
return router

src/testing/action_recorder.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
"""
2+
操作录制与回放引擎(v9.0 Phase2)
3+
4+
录制多端操作序列,保存为可回放的蓝本。
5+
支持:实时录制、时间戳精确回放、部分回放、蓝本导出。
6+
"""
7+
8+
import json
9+
import time
10+
from dataclasses import dataclass, field
11+
from pathlib import Path
12+
from typing import Any, Optional
13+
14+
from loguru import logger
15+
16+
17+
@dataclass
18+
class RecordedAction:
19+
"""一条录制的操作。"""
20+
player_id: str
21+
action: str
22+
params: dict = field(default_factory=dict)
23+
timestamp: float = 0.0 # 录制时的绝对时间
24+
offset: float = 0.0 # 相对于录制开始的偏移(秒)
25+
26+
27+
class ActionRecorder:
28+
"""操作录制器。"""
29+
30+
def __init__(self) -> None:
31+
self._recording = False
32+
self._start_time: float = 0
33+
self.actions: list[RecordedAction] = []
34+
35+
@property
36+
def is_recording(self) -> bool:
37+
return self._recording
38+
39+
@property
40+
def action_count(self) -> int:
41+
return len(self.actions)
42+
43+
@property
44+
def duration(self) -> float:
45+
if not self.actions:
46+
return 0
47+
return self.actions[-1].offset
48+
49+
def start(self) -> None:
50+
"""开始录制。"""
51+
self._recording = True
52+
self._start_time = time.time()
53+
self.actions.clear()
54+
logger.info("录制开始")
55+
56+
def stop(self) -> None:
57+
"""停止录制。"""
58+
self._recording = False
59+
logger.info("录制停止 | 操作: {} | 时长: {:.1f}s", len(self.actions), self.duration)
60+
61+
def record(self, player_id: str, action: str, params: dict = None) -> RecordedAction:
62+
"""录制一条操作。"""
63+
now = time.time()
64+
recorded = RecordedAction(
65+
player_id=player_id,
66+
action=action,
67+
params=params or {},
68+
timestamp=now,
69+
offset=now - self._start_time if self._start_time else 0,
70+
)
71+
self.actions.append(recorded)
72+
return recorded
73+
74+
def export_blueprint(self) -> dict:
75+
"""导出为多端蓝本格式。"""
76+
player_ids = list(dict.fromkeys(a.player_id for a in self.actions))
77+
players = [{"id": pid, "platform": "web"} for pid in player_ids]
78+
79+
steps = []
80+
for a in self.actions:
81+
step = {"player": a.player_id, "action": a.action}
82+
step.update(a.params)
83+
steps.append(step)
84+
85+
return {
86+
"mode": "multiplayer",
87+
"recorded": True,
88+
"duration": round(self.duration, 2),
89+
"players": players,
90+
"steps": steps,
91+
}
92+
93+
def save(self, path: str) -> Path:
94+
"""保存蓝本到文件。"""
95+
p = Path(path)
96+
p.parent.mkdir(parents=True, exist_ok=True)
97+
p.write_text(json.dumps(self.export_blueprint(), ensure_ascii=False, indent=2),
98+
encoding="utf-8")
99+
logger.info("蓝本已保存: {}", path)
100+
return p
101+
102+
@staticmethod
103+
def load_recording(path: str) -> list[RecordedAction]:
104+
"""从文件加载录制数据。"""
105+
p = Path(path)
106+
if not p.exists():
107+
raise FileNotFoundError(f"文件不存在: {path}")
108+
data = json.loads(p.read_text(encoding="utf-8"))
109+
actions = []
110+
offset = 0.0
111+
for step in data.get("steps", []):
112+
pid = step.get("player", "")
113+
action = step.get("action", "")
114+
params = {k: v for k, v in step.items() if k not in ("player", "action")}
115+
actions.append(RecordedAction(
116+
player_id=pid, action=action, params=params, offset=offset,
117+
))
118+
offset += 0.5 # 默认间隔
119+
return actions
120+
121+
122+
class ActionReplayer:
123+
"""操作回放器。"""
124+
125+
def __init__(self, actions: list[RecordedAction] = None) -> None:
126+
self.actions = actions or []
127+
self._replaying = False
128+
self._current_index = 0
129+
130+
@property
131+
def is_replaying(self) -> bool:
132+
return self._replaying
133+
134+
@property
135+
def progress(self) -> float:
136+
if not self.actions:
137+
return 0
138+
return self._current_index / len(self.actions)
139+
140+
@property
141+
def current_index(self) -> int:
142+
return self._current_index
143+
144+
async def replay(self, orchestrator: Any, speed: float = 1.0,
145+
player_filter: list[str] = None) -> list[dict]:
146+
"""回放录制的操作序列。
147+
148+
Args:
149+
orchestrator: MultiPlayerOrchestrator 实例
150+
speed: 回放速度倍率(2.0 = 2倍速)
151+
player_filter: 只回放这些玩家的操作(None=全部)
152+
"""
153+
self._replaying = True
154+
self._current_index = 0
155+
results = []
156+
prev_offset = 0.0
157+
158+
logger.info("回放开始 | 操作: {} | 速度: {}x | 过滤: {}",
159+
len(self.actions), speed, player_filter or "全部")
160+
161+
for i, action in enumerate(self.actions):
162+
if not self._replaying:
163+
break
164+
165+
if player_filter and action.player_id not in player_filter:
166+
continue
167+
168+
delay = (action.offset - prev_offset) / speed
169+
if delay > 0:
170+
await self._interruptible_sleep(delay)
171+
if not self._replaying:
172+
break
173+
prev_offset = action.offset
174+
175+
self._current_index = i + 1
176+
success = True
177+
error = ""
178+
179+
try:
180+
if action.action == "wait":
181+
await self._interruptible_sleep(action.params.get("duration", 1) / speed)
182+
else:
183+
await orchestrator.execute_action(
184+
action.player_id, action.action, **action.params
185+
)
186+
except Exception as e:
187+
success = False
188+
error = str(e)
189+
logger.warning("回放操作失败 | {} | {} | {}", action.player_id, action.action, e)
190+
191+
results.append({
192+
"index": i,
193+
"player": action.player_id,
194+
"action": action.action,
195+
"params": action.params,
196+
"success": success,
197+
"error": error,
198+
})
199+
200+
self._replaying = False
201+
passed = sum(1 for r in results if r["success"])
202+
logger.info("回放完成 | 执行: {} | 通过: {} | 失败: {}",
203+
len(results), passed, len(results) - passed)
204+
return results
205+
206+
async def _interruptible_sleep(self, duration: float) -> None:
207+
"""可中断的 sleep。"""
208+
import asyncio
209+
end = time.time() + duration
210+
while time.time() < end and self._replaying:
211+
await asyncio.sleep(min(0.1, end - time.time()))
212+
213+
def stop(self) -> None:
214+
self._replaying = False
215+
216+
def get_status(self) -> dict:
217+
return {
218+
"replaying": self._replaying,
219+
"total": len(self.actions),
220+
"current": self._current_index,
221+
"progress": round(self.progress * 100, 1),
222+
}

0 commit comments

Comments
 (0)