Files

695 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import random
import uuid
import asyncio
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from app.database import get_db
from app.models.game import GameSession, SessionPlayer, ChatMessage, ClueState, Vote, GameStatus, MessageType
from app.scripts.sample import get_script_by_id, get_script_by_title
from app.agents.npc import NPCAgent
from app.agents.dm import DMAgent
router = APIRouter(prefix="/api/games", tags=["games"])
active_connections: dict[str, list[WebSocket]] = {}
game_locks: dict[str, asyncio.Lock] = {}
def get_lock(game_id: str) -> asyncio.Lock:
if game_id not in game_locks:
game_locks[game_id] = asyncio.Lock()
return game_locks[game_id]
async def broadcast(game_id: str, message: dict):
if game_id in active_connections:
data = json.dumps(message, ensure_ascii=False)
for ws in active_connections[game_id][:]:
try:
await ws.send_text(data)
except Exception:
active_connections[game_id].remove(ws)
async def broadcast_to_role(game_id: str, target_role_id: str, message: dict):
if game_id in active_connections:
data = json.dumps(message, ensure_ascii=False)
for ws in active_connections[game_id][:]:
try:
role_id = getattr(ws, "role_id", None)
if role_id is None or role_id == target_role_id:
await ws.send_text(data)
except Exception:
active_connections[game_id].remove(ws)
class CreateGameRequest(BaseModel):
script_id: str
human_role_id: str = ""
class JoinGameRequest(BaseModel):
role_id: str
class ChatRequest(BaseModel):
type: str = "public"
content: str
target_role_id: str | None = None
sender_role_id: str = ""
class VoteRequest(BaseModel):
target_role_id: str
reason: str = ""
voter_role_id: str = ""
@router.post("/create")
async def create_game(req: CreateGameRequest, db: AsyncSession = Depends(get_db)):
script = get_script_by_id(req.script_id)
if not script:
raise HTTPException(404, "剧本不存在")
game = GameSession(
id=str(uuid.uuid4()),
script_id=req.script_id,
status=GameStatus.waiting.value,
phase=script["phases"][0]["name"] if script["phases"] else "",
config={"script": script},
)
db.add(game)
await db.commit()
for role in script["roles"]:
is_human = role["id"] == req.human_role_id
sp = SessionPlayer(
id=str(uuid.uuid4()),
session_id=game.id,
role_id=role["id"],
role_name=role["name"],
is_human=is_human,
avatar_url="",
)
db.add(sp)
first_phase_clues = script["phases"][0].get("clues", []) if script["phases"] else []
for clue_id in first_phase_clues:
cs = ClueState(id=str(uuid.uuid4()), session_id=game.id, clue_id=clue_id, status="released")
db.add(cs)
await db.commit()
return {"game_id": game.id}
@router.post("/{game_id}/join")
async def join_game(game_id: str, req: JoinGameRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(GameSession).where(GameSession.id == game_id))
game = result.scalar_one_or_none()
if not game:
raise HTTPException(404, "游戏不存在")
player = SessionPlayer(
id=str(uuid.uuid4()),
session_id=game_id,
role_id=req.role_id,
is_human=True,
)
db.add(player)
await db.commit()
script = get_script_by_id(game.script_id) or {}
for role in script.get("roles", []):
if role["id"] == req.role_id:
return {
"player_id": player.id,
"role_id": req.role_id,
"role_name": role["name"],
"public_profile": role["publicProfile"],
"secret_profile": role["secretProfile"],
"secret": role.get("secret", ""),
"goal": role["goal"],
}
return {"player_id": player.id, "role_id": req.role_id}
@router.post("/{game_id}/start")
async def start_game(game_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(GameSession).where(GameSession.id == game_id))
game = result.scalar_one_or_none()
if not game:
raise HTTPException(404, "游戏不存在")
game.status = GameStatus.playing.value
game.started_at = datetime.utcnow()
await db.commit()
script = game.config.get("script", {}) if game.config else {}
background = script.get("background", "")
title = script.get("title", "")
dm_msg = ChatMessage(
id=str(uuid.uuid4()),
session_id=game_id,
sender_role_id="dm",
sender_name="DM",
message_type=MessageType.system.value,
content=f"欢迎来到【{title}】。\n{background}\n\n请各位玩家阅读角色信息,做好准备后,在公屏中发送「准备好了」开始游戏。",
phase_index=game.phase_index,
)
db.add(dm_msg)
await db.commit()
await broadcast(game_id, {
"type": "game_started",
"data": {
"phase": game.phase,
"phase_index": game.phase_index,
"message": {
"id": dm_msg.id,
"sender_name": "DM",
"sender_role_id": "dm",
"message_type": "system",
"content": dm_msg.content,
"created_at": dm_msg.created_at.isoformat(),
}
}
})
return {"status": "started", "phase": game.phase}
@router.post("/{game_id}/chat")
async def send_chat(game_id: str, req: ChatRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(GameSession).where(GameSession.id == game_id))
game = result.scalar_one_or_none()
if not game:
raise HTTPException(404, "游戏不存在")
sender_name = req.sender_role_id
players_result = await db.execute(
select(SessionPlayer).where(SessionPlayer.session_id == game_id, SessionPlayer.role_id == req.sender_role_id)
)
player = players_result.scalar_one_or_none()
script = game.config.get("script", {}) if game.config else {}
for role in script.get("roles", []):
if role["id"] == req.sender_role_id:
sender_name = role["name"]
break
msg = ChatMessage(
id=str(uuid.uuid4()),
session_id=game_id,
sender_role_id=req.sender_role_id,
sender_name=sender_name,
message_type=req.type,
content=req.content,
target_role_id=req.target_role_id,
phase_index=game.phase_index,
)
db.add(msg)
npc_responses = await _generate_npc_responses_inline(game_id, req, script, sender_name, game.phase, game.phase_index, db)
for npc_msg_data in npc_responses:
if npc_msg_data["_msg"] is not None:
db.add(npc_msg_data["_msg"])
await db.commit()
msg_data = {
"type": "chat_message",
"data": {
"id": msg.id,
"sender_role_id": req.sender_role_id,
"sender_name": sender_name,
"message_type": req.type,
"content": req.content,
"target_role_id": req.target_role_id,
"phase_index": game.phase_index,
"created_at": msg.created_at.isoformat(),
}
}
if req.type == "private" and req.target_role_id:
await broadcast_to_role(game_id, req.sender_role_id, msg_data)
await broadcast_to_role(game_id, req.target_role_id, msg_data)
else:
await broadcast(game_id, msg_data)
for npc in npc_responses:
npc_broadcast = {k: v for k, v in npc.items() if k != "_msg"}
if npc["data"]["message_type"] == "private" and npc["data"].get("target_role_id"):
await broadcast_to_role(game_id, req.sender_role_id, npc_broadcast)
await broadcast_to_role(game_id, npc["data"]["target_role_id"], npc_broadcast)
else:
await broadcast(game_id, npc_broadcast)
return {
"status": "ok",
"message_id": msg.id,
"npc_responses": [
{k: v for k, v in n.items() if k != "_msg"}
for n in npc_responses
],
}
@router.post("/{game_id}/phase/advance")
async def advance_phase(game_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(GameSession).where(GameSession.id == game_id))
game = result.scalar_one_or_none()
if not game:
raise HTTPException(404, "游戏不存在")
script = game.config.get("script", {}) if game.config else {}
phases = script.get("phases", [])
next_idx = game.phase_index + 1
if next_idx >= len(phases):
return {"status": "already_at_end"}
game.phase_index = next_idx
game.phase = phases[next_idx]["name"]
await db.commit()
phase = phases[next_idx]
msg = ChatMessage(
id=str(uuid.uuid4()),
session_id=game_id,
sender_role_id="dm",
sender_name="DM",
message_type=MessageType.system.value,
content=f"【{phase['name']}\n{phase['description']}\n\n{phase.get('publicInfo', '')}",
phase_index=next_idx,
)
db.add(msg)
await db.commit()
clues_to_release = phase.get("clues", [])
for clue_id in clues_to_release:
existing = await db.execute(
select(ClueState).where(ClueState.session_id == game_id, ClueState.clue_id == clue_id)
)
if not existing.scalar_one_or_none():
cs = ClueState(id=str(uuid.uuid4()), session_id=game_id, clue_id=clue_id, status="released")
db.add(cs)
await db.commit()
await broadcast(game_id, {
"type": "phase_change",
"data": {
"phase": game.phase,
"phase_index": next_idx,
"message": {
"id": msg.id,
"sender_name": "DM",
"sender_role_id": "dm",
"message_type": "system",
"content": msg.content,
"created_at": msg.created_at.isoformat(),
},
"new_clues": clues_to_release,
}
})
return {"status": "ok", "phase": game.phase, "phase_index": next_idx}
@router.post("/{game_id}/clue/{clue_id}/reveal")
async def reveal_clue(game_id: str, clue_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(ClueState).where(ClueState.session_id == game_id, ClueState.clue_id == clue_id)
)
clue_state = result.scalar_one_or_none()
if not clue_state:
raise HTTPException(404, "线索不存在或未发放")
clue_state.status = "revealed"
await db.commit()
game_result = await db.execute(select(GameSession).where(GameSession.id == game_id))
game = game_result.scalar_one()
script = game.config.get("script", {}) if game.config else {}
clue_info = None
for c in script.get("clues", []):
if c["id"] == clue_id:
clue_info = c
break
await broadcast(game_id, {
"type": "clue_revealed",
"data": {"clue_id": clue_id, "clue": clue_info}
})
return {"status": "revealed", "clue": clue_info}
@router.post("/{game_id}/vote")
async def submit_vote(game_id: str, req: VoteRequest, db: AsyncSession = Depends(get_db)):
voter = await db.execute(
select(SessionPlayer).where(SessionPlayer.session_id == game_id, SessionPlayer.role_id == req.voter_role_id)
)
voter_player = voter.scalar_one_or_none()
if not voter_player:
raise HTTPException(404, "投票者不存在")
target = await db.execute(
select(SessionPlayer).where(SessionPlayer.session_id == game_id, SessionPlayer.role_id == req.target_role_id)
)
target_player = target.scalar_one_or_none()
if not target_player:
raise HTTPException(404, "投票目标不存在")
vote = Vote(
id=str(uuid.uuid4()),
session_id=game_id,
voter_id=voter_player.id,
target_id=target_player.id,
reason=req.reason,
)
db.add(vote)
await db.commit()
await broadcast(game_id, {
"type": "vote_cast",
"data": {
"voter_role_id": req.voter_role_id,
"target_role_id": req.target_role_id,
"voter_name": voter_player.role_name,
}
})
return {"status": "ok"}
@router.post("/{game_id}/vote/end")
async def end_vote(game_id: str, db: AsyncSession = Depends(get_db)):
game_result = await db.execute(select(GameSession).where(GameSession.id == game_id))
game = game_result.scalar_one_or_none()
if not game:
raise HTTPException(404, "游戏不存在")
script = game.config.get("script", {}) if game.config else {}
existing_votes_result = await db.execute(select(Vote).where(Vote.session_id == game_id))
existing_votes = existing_votes_result.scalars().all()
existing_voter_ids = {v.voter_id for v in existing_votes}
players_result = await db.execute(select(SessionPlayer).where(SessionPlayer.session_id == game_id))
players_list = players_result.scalars().all()
players = {p.id: p for p in players_list}
clues_result = await db.execute(select(ClueState).where(ClueState.session_id == game_id, ClueState.status == "released"))
released_clue_states = clues_result.scalars().all()
released_clue_ids = {c.clue_id for c in released_clue_states}
all_clues = {c["id"]: c for c in script.get("clues", [])}
known_clues = [all_clues[cid]["description"] for cid in released_clue_ids if cid in all_clues]
npc_players = [p for p in players_list if not p.is_human and p.id not in existing_voter_ids]
all_roles = script.get("roles", [])
role_dict = {r["id"]: r for r in all_roles}
for npc in npc_players:
targets = [p for p in players_list if p.id != npc.id]
if not targets:
continue
npc_role = role_dict.get(npc.role_id)
if npc_role:
agent = NPCAgent(npc_role, script.get("background", ""), "")
vote_reason = await agent.respond("投票阶段", [], "请投票选出你最怀疑的人,并说明理由",
known_clues=known_clues, all_roles=all_roles)
vote_content = vote_reason.get("content", "")
target_id = random.choice(targets).id
for t in targets:
if t.role_name and t.role_name in vote_content:
target_id = t.id
break
else:
target_id = random.choice(targets).id
vote = Vote(
id=str(uuid.uuid4()),
session_id=game_id,
voter_id=npc.id,
target_id=target_id,
reason="",
)
db.add(vote)
target_player = players[target_id]
await broadcast(game_id, {
"type": "vote_cast",
"data": {
"voter_role_id": npc.role_id,
"voter_name": npc.role_name,
"target_role_id": target_player.role_id,
"target_name": target_player.role_name,
}
})
await db.commit()
votes_result = await db.execute(select(Vote).where(Vote.session_id == game_id))
votes = votes_result.scalars().all()
tally: dict[str, list[str]] = {}
for v in votes:
target_role = players[v.target_id].role_id if v.target_id in players else "unknown"
voter_role = players[v.voter_id].role_id if v.voter_id in players else "unknown"
if target_role not in tally:
tally[target_role] = []
tally[target_role].append(voter_role)
max_votes = 0
accused_role_id = ""
for role_id, voters in tally.items():
if len(voters) > max_votes:
max_votes = len(voters)
accused_role_id = role_id
accused_name = accused_role_id
for role in script.get("roles", []):
if role["id"] == accused_role_id:
accused_name = role["name"]
break
truth = script.get("truth", "")
game.status = GameStatus.completed.value
game.completed_at = datetime.utcnow()
await db.commit()
result_msg = ChatMessage(
id=str(uuid.uuid4()),
session_id=game_id,
sender_role_id="dm",
sender_name="DM",
message_type=MessageType.system.value,
content=f"投票结束!最多票指向:{accused_name}{max_votes}票)\n\n真相:{truth}",
phase_index=game.phase_index,
)
db.add(result_msg)
await db.commit()
await broadcast(game_id, {
"type": "vote_result",
"data": {
"tally": {role_id: voters for role_id, voters in tally.items()},
"accused_role_id": accused_role_id,
"accused_name": accused_name,
"truth": truth,
"game_id": game_id,
"message": {
"id": result_msg.id,
"sender_name": "DM",
"sender_role_id": "dm",
"message_type": "system",
"content": result_msg.content,
"created_at": result_msg.created_at.isoformat(),
}
}
})
if game_id in active_connections:
del active_connections[game_id]
return {"status": "completed", "accused": accused_name, "truth": truth}
@router.get("/{game_id}/state")
async def get_game_state(game_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(GameSession).where(GameSession.id == game_id))
game = result.scalar_one_or_none()
if not game:
raise HTTPException(404, "游戏不存在")
clues_result = await db.execute(select(ClueState).where(ClueState.session_id == game_id))
clues = clues_result.scalars().all()
players_result = await db.execute(select(SessionPlayer).where(SessionPlayer.session_id == game_id))
players = players_result.scalars().all()
msgs_result = await db.execute(
select(ChatMessage).where(ChatMessage.session_id == game_id).order_by(ChatMessage.created_at)
)
msgs = msgs_result.scalars().all()
script = game.config.get("script", {}) if game.config else {}
return {
"id": game.id,
"script_id": game.script_id,
"status": game.status,
"phase": game.phase,
"phase_index": game.phase_index,
"script": script,
"players": [
{"id": p.id, "role_id": p.role_id, "role_name": p.role_name, "is_human": p.is_human, "status": p.status}
for p in players
],
"clues": [{"clue_id": c.clue_id, "status": c.status} for c in clues],
"messages": [
{
"id": m.id, "sender_role_id": m.sender_role_id, "sender_name": m.sender_name,
"message_type": m.message_type, "content": m.content, "target_role_id": m.target_role_id,
"clue_id": m.clue_id, "phase_index": m.phase_index,
"created_at": m.created_at.isoformat(),
}
for m in msgs
],
}
@router.get("/{game_id}/replay")
async def get_replay(game_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(GameSession).where(GameSession.id == game_id))
game = result.scalar_one_or_none()
if not game:
raise HTTPException(404, "游戏不存在")
msgs_result = await db.execute(
select(ChatMessage).where(ChatMessage.session_id == game_id).order_by(ChatMessage.created_at)
)
msgs = msgs_result.scalars().all()
votes_result = await db.execute(
select(Vote).where(Vote.session_id == game_id)
)
votes = votes_result.scalars().all()
players_result = await db.execute(select(SessionPlayer).where(SessionPlayer.session_id == game_id))
players = {p.id: p for p in players_result.scalars().all()}
script = game.config.get("script", {}) if game.config else {}
vote_details = []
for v in votes:
voter = players.get(v.voter_id)
target = players.get(v.target_id)
vote_details.append({
"voter_name": voter.role_name if voter else "unknown",
"target_name": target.role_name if target else "unknown",
"reason": v.reason,
})
return {
"game_id": game_id,
"script_title": script.get("title", game.script_id),
"truth": script.get("truth", ""),
"roles": [
{"id": r["id"], "name": r["name"], "publicProfile": r["publicProfile"],
"secretProfile": r["secretProfile"], "goal": r["goal"]}
for r in script.get("roles", [])
],
"messages": [
{"id": m.id, "sender_name": m.sender_name, "content": m.content,
"type": m.message_type, "time": m.created_at.isoformat()}
for m in msgs
],
"votes": vote_details,
}
@router.websocket("/ws/{game_id}")
async def game_websocket(websocket: WebSocket, game_id: str, role_id: str = Query("")):
await websocket.accept()
websocket.role_id = role_id
if game_id not in active_connections:
active_connections[game_id] = []
active_connections[game_id].append(websocket)
try:
while True:
data = await websocket.receive_text()
msg = json.loads(data)
msg_type = msg.get("type", "")
msg_data = msg.get("data", {})
if msg_type == "ping":
await websocket.send_text(json.dumps({"type": "pong"}))
except WebSocketDisconnect:
if game_id in active_connections and websocket in active_connections[game_id]:
active_connections[game_id].remove(websocket)
except Exception:
if game_id in active_connections and websocket in active_connections[game_id]:
active_connections[game_id].remove(websocket)
async def _generate_npc_responses_inline(game_id: str, req, script: dict, sender_name: str, phase_name: str, phase_index: int, db: AsyncSession) -> list[dict]:
npc_responses = []
try:
npc_roles = [r for r in script.get("roles", []) if r["id"] != req.sender_role_id]
random.shuffle(npc_roles)
if not npc_roles:
return npc_responses
recent_msgs_result = await db.execute(
select(ChatMessage).where(ChatMessage.session_id == game_id).order_by(ChatMessage.created_at.desc()).limit(10)
)
recent_msgs = recent_msgs_result.scalars().all()
recent_list = [{"sender_name": m.sender_name, "content": m.content} for m in recent_msgs]
clues_result = await db.execute(select(ClueState).where(ClueState.session_id == game_id, ClueState.status == "released"))
released_clue_states = clues_result.scalars().all()
released_clue_ids = {c.clue_id for c in released_clue_states}
all_clues = {c["id"]: c for c in script.get("clues", [])}
known_clues = [all_clues[cid]["description"] for cid in released_clue_ids if cid in all_clues]
phases = script.get("phases", [])
current_phase = phases[phase_index] if phase_index < len(phases) else None
phase_goal = current_phase["description"] if current_phase else ""
all_roles = script.get("roles", [])
for npc_role in npc_roles[:2]:
agent = NPCAgent(npc_role, script.get("background", ""), "")
is_private = req.type == "private" and req.target_role_id == npc_role["id"]
response = await agent.respond(phase_name, recent_list, req.content, is_private,
known_clues=known_clues, phase_goal=phase_goal, all_roles=all_roles)
content = response.get("content", "")
action = response.get("action", "")
if not content or action == "remain_silent":
continue
now = datetime.utcnow()
npc_msg = ChatMessage(
id=str(uuid.uuid4()),
session_id=game_id,
sender_role_id=npc_role["id"],
sender_name=npc_role["name"],
message_type="private" if is_private else "public",
content=content,
target_role_id=req.sender_role_id if is_private else None,
phase_index=phase_index,
created_at=now,
)
npc_responses.append({
"_msg": npc_msg,
"type": "chat_message",
"data": {
"id": npc_msg.id,
"sender_role_id": npc_role["id"],
"sender_name": npc_role["name"],
"message_type": npc_msg.message_type,
"content": content,
"target_role_id": npc_msg.target_role_id,
"phase_index": phase_index,
"created_at": npc_msg.created_at.isoformat(),
}
})
except Exception:
pass
return npc_responses