Initial commit: 网页联机狼人杀(FastAPI+WebSocket 后端 / React+TS+Sass 前端)
This commit is contained in:
commit
7f5ea6bee8
55 files changed
+6999
No files matched your search
@@ -0,0 +1,170 @@
|
||||
"""无头联机测试:2 个程序化真人客户端 + 10 个 AI 打一整局。
|
||||
|
||||
验证:REST 建房、WS 加入、座位分配、房主控制(补AI/开局)、
|
||||
个性化快照、人类行动提交(发言/投票/夜间技能)、断线重连、胜负结算。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import websockets
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
BASE = "http://127.0.0.1:8000"
|
||||
random.seed(7)
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.snap: dict | None = None
|
||||
self.token: str | None = None
|
||||
self.ws = None
|
||||
self.task = None
|
||||
|
||||
async def connect(self, code: str, token: str | None = None) -> None:
|
||||
url = f"ws://127.0.0.1:8000/ws/{code}?name={self.name}"
|
||||
if token:
|
||||
url += f"&token={token}"
|
||||
self.ws = await websockets.connect(url)
|
||||
self.task = asyncio.create_task(self._recv())
|
||||
|
||||
async def _recv(self) -> None:
|
||||
try:
|
||||
async for raw in self.ws:
|
||||
data = json.loads(raw)
|
||||
if data["type"] == "snapshot":
|
||||
self.snap = data["data"]
|
||||
if self.snap.get("token"):
|
||||
self.token = self.snap["token"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def send(self, obj: dict) -> None:
|
||||
await self.ws.send(json.dumps(obj, ensure_ascii=False))
|
||||
|
||||
def seat(self):
|
||||
me = (self.snap or {}).get("me")
|
||||
return me["seat"] if me else None
|
||||
|
||||
def pending(self):
|
||||
return (self.snap or {}).get("pending")
|
||||
|
||||
def phase(self):
|
||||
return (self.snap or {}).get("phase")
|
||||
|
||||
|
||||
async def act(c: Client) -> bool:
|
||||
"""若该客户端有待办则提交一个动作,返回是否提交过。"""
|
||||
p = c.pending()
|
||||
if not p:
|
||||
return False
|
||||
key = p["key"]
|
||||
if p.get("input_text"):
|
||||
text = "大家听我分析,我目前是好人视角,先跟票看看。" if p.get("purpose") != "election" \
|
||||
else "我想拿警徽带队,请大家支持。"
|
||||
await c.send({"type": "speech", "text": text})
|
||||
return True
|
||||
if key == "elect":
|
||||
await c.send({"type": "action", "action": "elect", "target": random.choice([1, None])})
|
||||
return True
|
||||
options = p.get("options") or []
|
||||
if p.get("allow_skip") and random.random() < 0.15:
|
||||
await c.send({"type": "action", "action": key, "target": None})
|
||||
return True
|
||||
if not options:
|
||||
await c.send({"type": "action", "action": key, "target": None})
|
||||
return True
|
||||
me = c.seat()
|
||||
pool = [o for o in options if o != me] or options
|
||||
await c.send({"type": "action", "action": key, "target": random.choice(pool)})
|
||||
return True
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/api/rooms",
|
||||
data=json.dumps({"board": "classic12", "host_name": "甲"}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
code = json.loads(urllib.request.urlopen(req).read())["code"]
|
||||
print("房间:", code)
|
||||
|
||||
a, b = Client("甲"), Client("乙")
|
||||
await a.connect(code)
|
||||
await b.connect(code)
|
||||
await asyncio.sleep(0.6)
|
||||
assert a.seat() == 1, f"甲座位错误: {a.seat()}"
|
||||
assert b.seat() == 2, f"乙座位错误: {b.seat()}"
|
||||
print("座位分配 OK: 甲=1号(房主), 乙=2号")
|
||||
|
||||
await a.send({"type": "control", "op": "fill_ai"})
|
||||
await asyncio.sleep(0.5)
|
||||
n = len(a.snap["players"])
|
||||
assert n == 12, f"补AI后玩家数 {n}"
|
||||
print("一键补满 AI OK: 12 名玩家")
|
||||
|
||||
# 乙断线重连(大厅阶段)
|
||||
old_token = b.token
|
||||
await b.ws.close()
|
||||
await asyncio.sleep(0.4)
|
||||
await b.connect(code, token=old_token)
|
||||
await asyncio.sleep(0.6)
|
||||
assert b.seat() == 2, f"重连后座位错误: {b.seat()}"
|
||||
print("断线重连 OK: 乙重新回到 2 号")
|
||||
|
||||
await a.send({"type": "control", "op": "start"})
|
||||
await asyncio.sleep(1.0)
|
||||
assert a.phase() != "lobby", "开局失败"
|
||||
print("开局 OK, 阶段:", a.snap["phase_text"])
|
||||
print("甲身份:", a.snap["me"].get("role_name"), "| 乙身份:", b.snap["me"].get("role_name"))
|
||||
|
||||
# 自动打完整局
|
||||
deadline = asyncio.get_event_loop().time() + 300
|
||||
acted_log = []
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
for c in (a, b):
|
||||
if await act(c):
|
||||
acted_log.append((c.name, c.phase()))
|
||||
if a.phase() == "game_over" and b.phase() == "game_over":
|
||||
break
|
||||
await asyncio.sleep(0.4)
|
||||
|
||||
print("\n=== 对局结束 ===")
|
||||
sa, sb = a.snap, b.snap
|
||||
assert sa["phase"] == "game_over", f"超时未结束: {sa['phase']} {sa['phase_text']}"
|
||||
print("胜者:", sa["winner"], "|", sa["winner_text"])
|
||||
print("甲(1号)身份:", next(p["role_name"] for p in sa["players"] if p["seat"] == 1),
|
||||
"存活:", next(p["alive"] for p in sa["players"] if p["seat"] == 1))
|
||||
print("乙(2号)身份:", next(p["role_name"] for p in sb["players"] if p["seat"] == 2),
|
||||
"存活:", next(p["alive"] for p in sb["players"] if p["seat"] == 2))
|
||||
print("结算页显示全部身份:", all(p["role_name"] for p in sa["players"]))
|
||||
|
||||
# 对局中乙断线重连测试
|
||||
token_b = b.token
|
||||
await b.ws.close()
|
||||
await asyncio.sleep(0.3)
|
||||
await b.connect(code, token=token_b)
|
||||
await asyncio.sleep(0.8)
|
||||
assert b.seat() == 2 and b.phase() == "game_over"
|
||||
print("对局结束后重连 OK")
|
||||
|
||||
# 房主 reset 回大厅再来一局
|
||||
await a.send({"type": "control", "op": "reset"})
|
||||
await asyncio.sleep(0.6)
|
||||
assert a.phase() == "lobby", f"reset 失败: {a.phase()}"
|
||||
print("再来一局(reset) OK")
|
||||
await a.send({"type": "control", "op": "start"})
|
||||
await asyncio.sleep(1.0)
|
||||
assert a.phase() != "lobby", "第二局开局失败"
|
||||
print("第二局开局 OK:", a.snap["phase_text"])
|
||||
|
||||
print("\n✅ 全部联机测试通过")
|
||||
await a.ws.close()
|
||||
await b.ws.close()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
Reference in new issue
Block a user