diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a388b99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# Database / binary data files +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log +logs/ + +# Environment / secrets +.env +.env.* + +# Binary files +*.pdf +*.exe +*.dll +*.so +*.dylib +*.jar +*.war +*.zip +*.tar +*.gz +*.rar +*.7z +*.png +*.jpg +*.jpeg +*.gif +*.ico +*.bmp +*.mp3 +*.mp4 +*.avi +*.wav +*.bin +*.dat + +# IDE / OS +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/agents/__init__.py b/backend/app/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/agents/ai.py b/backend/app/agents/ai.py new file mode 100644 index 0000000..a0bcbad --- /dev/null +++ b/backend/app/agents/ai.py @@ -0,0 +1,220 @@ +import json +import random +import httpx +from app.config import settings + + +class AIBackend: + def __init__(self): + self.backend = settings.AI_BACKEND + + async def chat(self, messages: list[dict], temperature: float = 0.7, max_tokens: int = 512) -> str: + if self.backend == "ollama": + return await self._ollama_chat(messages, temperature, max_tokens) + elif self.backend == "deepseek": + return await self._deepseek_chat(messages, temperature, max_tokens) + else: + return "" + + async def _ollama_chat(self, messages: list[dict], temperature: float, max_tokens: int) -> str: + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post( + f"{settings.OLLAMA_BASE_URL}/api/chat", + json={ + "model": settings.OLLAMA_MODEL, + "messages": messages, + "stream": False, + "options": {"temperature": temperature, "num_predict": max_tokens}, + }, + ) + data = resp.json() + return data.get("message", {}).get("content", "") + except Exception: + return "" + + async def _deepseek_chat(self, messages: list[dict], temperature: float, max_tokens: int) -> str: + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post( + f"{settings.DEEPSEEK_BASE_URL}/v1/chat/completions", + headers={"Authorization": f"Bearer {settings.DEEPSEEK_API_KEY}"}, + json={"model": settings.DEEPSEEK_MODEL, "messages": messages, "temperature": temperature, "max_tokens": max_tokens}, + ) + result = resp.json() + return result["choices"][0]["message"]["content"] + except Exception: + return "" + + +ai_backend = AIBackend() + + +DM_INTROS = { + "hardcore": "欢迎来到这场推理盛宴。真相就藏在你面前的线索之中,仔细观察,大胆推理。", + "emotional": "这是一个关于人心的故事。有时候,真相并不重要,重要的是你在这个过程中感受到了什么。", + "欢乐": "欢迎来到这场欢乐的聚会!记住,每个人都在演戏,但真相只有一个!", + "恐怖": "黑暗中有什么在注视着你……准备好了吗?", +} + + +async def dm_generate(script: dict, phase_index: int, released_clues: list[str], context: str = "") -> dict: + phases = script.get("phases", []) + phase = phases[phase_index] if phase_index < len(phases) else phases[-1] + title = script["title"] + script_type = script.get("type", "hardcore") + + type_intro = DM_INTROS.get(script_type, "") + clues_str = ", ".join(released_clues) if released_clues else "暂无" + + prompt = f"""你是一个剧本杀主持人(DM),请以主持人的身份发言。 +当前剧本: {title} +当前阶段: {phase['name']} +阶段描述: {phase['description']} +已发放线索: {clues_str} +上下文: {context} + +请简短发言(50字以内),推动剧情发展。""" + + result = await ai_backend.chat([{"role": "user", "content": prompt}]) + if result: + # Try to parse as JSON + cleaned = result.strip().removeprefix("```json").removesuffix("```").strip() + try: + return json.loads(cleaned) + except json.JSONDecodeError: + pass + return {"action": "narrate", "content": result} + + voice = phase["description"][:80] + if phase_index == 0 and context == "opening": + voice = f"{type_intro}\n\n欢迎来到【{title}】。{script.get('background', '')[:100]}" + return {"action": "narrate", "content": f"【{phase['name']}】{voice}"} + + +async def npc_generate(role: dict, background: str, phase_name: str, recent_messages: list[str], + player_message: str = "", is_private: bool = False, + known_clues: list[str] = None, phase_goal: str = "", + all_roles: list[dict] = None) -> dict: + personality = role.get("personality", role.get("name", "")) + clues_str = " | ".join(known_clues[-5:]) if known_clues else "暂无线索" + roles_str = "" + if all_roles: + rlist = [r["name"] for r in all_roles if r["id"] != role.get("id")] + roles_str = "其他玩家: " + ", ".join(rlist) + + prompt = f"""你正在扮演一个剧本杀角色。请严格按照角色设定发言。 + +角色信息: +- 姓名: {role['name']} +- 公开身份: {role.get('publicProfile', '')} +- 性格: {personality} +- 你的目标: {role.get('goal', '')} +- 你的秘密: {role.get('secretProfile', '')} + +当前背景: {background[:80]}... +当前阶段: {phase_name} +{("阶段目标: " + phase_goal) if phase_goal else ""} +{roles_str} + +你已知道的线索: +{clues_str} + +最近发言: +{' | '.join(recent_messages[-4:])} + +{'【私聊】' + player_message if is_private and player_message else ''} +{'【有人对你说】' + player_message if not is_private and player_message else ''} + +【发言规则】 +1. 以角色身份说人话,简短自然(30字以内) +2. 你的秘密绝对不能主动说出来 +3. 被问到时可以撒谎、回避、转移话题 +4. 结合已公开的线索来推理和回应 +5. 如果你知道某些线索的真相(如你就是凶手),可以故意误导他人 + +直接输出你的角色发言内容,不要JSON、不要解释。""" + + result = await ai_backend.chat([{"role": "user", "content": prompt}]) + if result: + cleaned = result.strip().removeprefix("```json").removesuffix("```").strip() + try: + return json.loads(cleaned) + except json.JSONDecodeError: + pass + if result.startswith("{"): + return {"action": "speak", "content": result} + return {"action": "speak", "content": result} + + return {"action": "speak", "content": _rule_npc_reply(role, player_message, is_private, known_clues)} + + +def _rule_npc_reply(role: dict, player_message: str, is_private: bool, known_clues: list[str] = None) -> str: + name = role["name"] + personality = role.get("personality", "") + goal = role.get("goal", "") + clues = known_clues or [] + + if not player_message: + if clues: + replies = [ + f"{name}沉思道:\"这些线索……我觉得需要重新梳理一下。\"", + f"{name}说:\"我注意到了一些细节,但现在还不方便说。\"", + f"{name}看了看法医报告:\"时间线和线索对不上,肯定有人撒谎。\"", + ] + return random.choice(replies) + replies = [ + f"{name}环顾四周,若有所思。", + f"{name}清了清嗓子:\"各位,我觉得我们应该整理一下思路。\"", + f"{name}沉默地看着大家。", + f"{name}低声说:\"这件事没有那么简单……\"", + ] + return random.choice(replies) + + if "凶手" in player_message or "杀人" in player_message or "你杀" in player_message: + denials = [ + f"{name}脸色一变:\"你凭什么这么说?证据呢?\"", + f"{name}冷笑一声:\"如果我是凶手,我还会坐在这里?\"", + f"{name}摇头:\"我没有理由杀他。\"", + ] + return random.choice(denials) + + if "时间" in player_message or "在哪" in player_message or "案发" in player_message: + times = [ + f"{name}回忆道:\"我当时在……让我想想。\"", + f"{name}说:\"那段时间我一个人在房间里。\"", + f"{name}皱眉:\"我不太确定具体时间,但我确实听到了什么声音。\"", + ] + return random.choice(times) + + if "线索" in player_message or "证据" in player_message or "发现" in player_message: + clues_replies = [ + f"{name}点头:\"这个线索确实值得注意。\"", + f"{name}沉思:\"但这个线索也可能是在误导我们。\"", + f"{name}说:\"我也有一个发现,但还不确定是否相关。\"", + ] + return random.choice(clues_replies) + + if "知道" in player_message or "秘密" in player_message: + secrets = [ + f"{name}回避了你的目光:\"我什么也不知道。\"", + f"{name}沉默了一会儿:\"每个人都有不想说的秘密,不是吗?\"", + f"{name}说:\"我只能告诉你,事情不是你看到的那样。\"", + ] + return random.choice(secrets) + + if "动机" in player_message or "为什么" in player_message or "目的" in player_message: + motives = [ + f"{name}说:\"每个人都有自己的理由,但有些理由……\"", + f"{name}耸耸肩:\"动机?也许我们需要先搞清楚发生了什么。\"", + f"{name}看着你:\"你确定你想知道真正的动机?\"", + ] + return random.choice(motives) + + generic = [ + f"{name}思考了一下:\"这个嘛,我说不好。\"", + f"{name}回答:\"我不太确定,但我觉得我们应该继续调查。\"", + f"{name}说:\"你说得有一定道理,但可能还有其他可能性。\"", + f"{name}点头:\"有意思,继续说。\"", + ] + return random.choice(generic) diff --git a/backend/app/agents/dm.py b/backend/app/agents/dm.py new file mode 100644 index 0000000..e3b9be0 --- /dev/null +++ b/backend/app/agents/dm.py @@ -0,0 +1,24 @@ +from app.agents.ai import dm_generate + + +class DMAgent: + def __init__(self, script: dict): + self.script = script + self.current_phase_index = 0 + self.released_clues = [] + + def get_opening(self) -> str: + title = self.script["title"] + bg = self.script.get("background", "") + return f"欢迎来到【{title}】。{bg}" + + async def act(self, phase_index: int, player_progress: str = "", context: str = "") -> dict: + result = await dm_generate(self.script, phase_index, self.released_clues, context) + if result: + return result + phases = self.script.get("phases", []) + phase = phases[phase_index] if phase_index < len(phases) else phases[-1] + return {"action": "narrate", "content": f"【{phase['name']}】{phase['description']}"} + + def get_replay_summary(self) -> str: + return f"剧本:{self.script['title']}\n真相:{self.script.get('truth', '')}" diff --git a/backend/app/agents/npc.py b/backend/app/agents/npc.py new file mode 100644 index 0000000..fd979da --- /dev/null +++ b/backend/app/agents/npc.py @@ -0,0 +1,26 @@ +from app.agents.ai import npc_generate + + +class NPCAgent: + def __init__(self, role: dict, background: str, public_info: str): + self.role = role + self.background = background + self.public_info = public_info + self.memory = [] + + async def respond(self, phase_name: str, recent_messages: list = None, player_message: str = "", is_private: bool = False, + known_clues: list[str] = None, phase_goal: str = "", all_roles: list[dict] = None) -> dict: + if recent_messages is None: + recent_messages = [] + recent = [ + f"{m.get('sender_name', m.get('sender', ''))}: {m.get('content', '')}" + for m in recent_messages[-5:] + ] + result = await npc_generate(self.role, self.background, phase_name, recent, player_message, is_private, + known_clues, phase_goal, all_roles) + if result: + content = result.get("content", "") + if content: + self.memory.append({"role": "assistant", "content": content}) + return result + return {"content": f"{self.role['name']}陷入了沉思……", "action": "speak"} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..f7c71cd --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,21 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "sqlite+aiosqlite:///./mmgame.db" + SECRET_KEY: str = "mmgame-dev-secret-key-change-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 + + AI_BACKEND: str = "rule" + DEEPSEEK_API_KEY: str = "" + DEEPSEEK_BASE_URL: str = "https://api.deepseek.com" + DEEPSEEK_MODEL: str = "deepseek-chat" + OLLAMA_BASE_URL: str = "http://localhost:11434" + OLLAMA_MODEL: str = "qwen2.5:7b" + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..bd482f7 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,24 @@ +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from app.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with async_session() as session: + try: + yield session + finally: + await session.close() + + +async def init_db(): + async with engine.begin() as conn: + from app.models import user, script, game # noqa + await conn.run_sync(Base.metadata.create_all) \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..7003ea9 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,31 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +from app.database import init_db +from app.routers import auth, scripts, games + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + + +app = FastAPI(title="AI剧本杀", version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(scripts.router) +app.include_router(games.router) + + +@app.get("/api/health") +async def health(): + return {"status": "ok"} \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/game.py b/backend/app/models/game.py new file mode 100644 index 0000000..35acdf9 --- /dev/null +++ b/backend/app/models/game.py @@ -0,0 +1,94 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Integer, Text, Boolean, DateTime, JSON +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base +import enum + + +class GameStatus(str, enum.Enum): + waiting = "waiting" + playing = "playing" + paused = "paused" + completed = "completed" + + +class PlayerStatus(str, enum.Enum): + active = "active" + left = "left" + eliminated = "eliminated" + + +class MessageType(str, enum.Enum): + public = "public" + private = "private" + system = "system" + clue = "clue" + + +class GameSession(Base): + __tablename__ = "game_sessions" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + script_id: Mapped[str] = mapped_column(String(36)) + status: Mapped[str] = mapped_column(String(20), default=GameStatus.waiting.value) + phase: Mapped[str] = mapped_column(String(100), default="") + phase_index: Mapped[int] = mapped_column(Integer, default=0) + started_at: Mapped[datetime] = mapped_column(DateTime, nullable=True) + completed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True) + config: Mapped[str] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class SessionPlayer(Base): + __tablename__ = "session_players" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + session_id: Mapped[str] = mapped_column(String(36)) + user_id: Mapped[str] = mapped_column(String(36), nullable=True) + role_id: Mapped[str] = mapped_column(String(100)) + role_name: Mapped[str] = mapped_column(String(100), default="") + is_human: Mapped[bool] = mapped_column(Boolean, default=True) + is_ready: Mapped[bool] = mapped_column(Boolean, default=False) + avatar_url: Mapped[str] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), default=PlayerStatus.active.value) + joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class ChatMessage(Base): + __tablename__ = "chat_messages" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + session_id: Mapped[str] = mapped_column(String(36)) + sender_role_id: Mapped[str] = mapped_column(String(100)) + sender_name: Mapped[str] = mapped_column(String(100), default="") + message_type: Mapped[str] = mapped_column(String(20), default=MessageType.public.value) + content: Mapped[str] = mapped_column(Text) + target_role_id: Mapped[str] = mapped_column(String(100), nullable=True) + clue_id: Mapped[str] = mapped_column(String(100), nullable=True) + phase_index: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class ClueState(Base): + __tablename__ = "clues_state" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + session_id: Mapped[str] = mapped_column(String(36)) + clue_id: Mapped[str] = mapped_column(String(100)) + status: Mapped[str] = mapped_column(String(20), default="unreleased") + revealed_by: Mapped[str] = mapped_column(String(100), nullable=True) + released_at: Mapped[datetime] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class Vote(Base): + __tablename__ = "votes" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + session_id: Mapped[str] = mapped_column(String(36)) + round: Mapped[int] = mapped_column(Integer, default=1) + voter_id: Mapped[str] = mapped_column(String(36)) + target_id: Mapped[str] = mapped_column(String(36)) + reason: Mapped[str] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/backend/app/models/script.py b/backend/app/models/script.py new file mode 100644 index 0000000..e4d147b --- /dev/null +++ b/backend/app/models/script.py @@ -0,0 +1,39 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Integer, Text, DateTime, Float, JSON, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base +import enum + + +class ScriptSource(str, enum.Enum): + builtin = "builtin" + ai_generated = "ai_generated" + uploaded = "uploaded" + creator = "creator" + + +class ScriptStatus(str, enum.Enum): + draft = "draft" + published = "published" + disabled = "disabled" + + +class Script(Base): + __tablename__ = "scripts" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + title: Mapped[str] = mapped_column(String(200)) + type: Mapped[str] = mapped_column(String(50)) + difficulty: Mapped[int] = mapped_column(Integer) + min_players: Mapped[int] = mapped_column(Integer) + max_players: Mapped[int] = mapped_column(Integer) + duration_min: Mapped[int] = mapped_column(Integer) + background: Mapped[str] = mapped_column(Text) + config: Mapped[str] = mapped_column(JSON) + source: Mapped[str] = mapped_column(String(20), default=ScriptSource.builtin.value) + creator_id: Mapped[str] = mapped_column(String(36), nullable=True) + status: Mapped[str] = mapped_column(String(20), default=ScriptStatus.published.value) + rating_avg: Mapped[float] = mapped_column(Float, default=0.0) + play_count: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..26456cb --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,27 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Integer, Text, Boolean, DateTime, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base +import enum + + +class UserRole(str, enum.Enum): + player = "player" + creator = "creator" + admin = "admin" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=True) + nickname: Mapped[str] = mapped_column(String(50), nullable=True) + avatar_url: Mapped[str] = mapped_column(Text, nullable=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=True) + role: Mapped[str] = mapped_column(String(20), default=UserRole.player.value) + is_guest: Mapped[bool] = mapped_column(Boolean, default=False) + game_count: Mapped[int] = mapped_column(Integer, default=0) + token_version: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..390e019 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,144 @@ +import uuid +from datetime import datetime, timedelta +from jose import jwt, JWTError +from passlib.context import CryptContext +from fastapi import APIRouter, Depends, HTTPException, Header +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from app.database import get_db +from app.models.user import User +from app.models.game import GameSession +from app.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class RegisterRequest(BaseModel): + email: str + password: str + nickname: str + + +class LoginRequest(BaseModel): + email: str + password: str + + +class GuestRequest(BaseModel): + nickname: str = "游客" + + +class AuthResponse(BaseModel): + token: str + user_id: str + nickname: str + is_guest: bool + + +def create_token(user_id: str) -> str: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return jwt.encode({"sub": user_id, "exp": expire, "v": 0}, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +async def get_current_user(authorization: str = Header(""), db: AsyncSession = Depends(get_db)) -> User: + if not authorization.startswith("Bearer "): + raise HTTPException(401, "未登录") + token = authorization[7:] + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + user_id = payload.get("sub") + except JWTError: + raise HTTPException(401, "无效的token") + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(401, "用户不存在") + return user + + +@router.post("/register") +async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == req.email)) + if result.scalar_one_or_none(): + raise HTTPException(400, "邮箱已注册") + user = User( + id=str(uuid.uuid4()), + email=req.email, + nickname=req.nickname, + password_hash=pwd_context.hash(req.password), + ) + db.add(user) + await db.commit() + return AuthResponse(token=create_token(user.id), user_id=user.id, nickname=user.nickname, is_guest=False) + + +@router.post("/login") +async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == req.email)) + user = result.scalar_one_or_none() + if not user or not user.password_hash or not pwd_context.verify(req.password, user.password_hash): + raise HTTPException(401, "邮箱或密码错误") + return AuthResponse(token=create_token(user.id), user_id=user.id, nickname=user.nickname, is_guest=False) + + +@router.post("/guest") +async def guest_login(req: GuestRequest, db: AsyncSession = Depends(get_db)): + user = User( + id=str(uuid.uuid4()), + nickname=req.nickname, + is_guest=True, + ) + db.add(user) + await db.commit() + return AuthResponse(token=create_token(user.id), user_id=user.id, nickname=user.nickname, is_guest=True) + + +@router.post("/logout") +async def logout(): + return {"status": "ok"} + + +@router.get("/profile") +async def get_profile(user: User = Depends(get_current_user)): + return { + "user_id": user.id, + "email": user.email, + "nickname": user.nickname, + "avatar_url": user.avatar_url, + "is_guest": user.is_guest, + "role": user.role, + "game_count": user.game_count, + "created_at": user.created_at.isoformat() if user.created_at else "", + } + + +@router.put("/profile") +async def update_profile(data: dict, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + if "nickname" in data: + user.nickname = data["nickname"] + if "avatar_url" in data: + user.avatar_url = data["avatar_url"] + await db.commit() + return {"status": "ok"} + + +@router.get("/history") +async def get_history(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(GameSession).order_by(GameSession.created_at.desc()).limit(50) + ) + games = result.scalars().all() + return [ + { + "id": g.id, + "script_id": g.script_id, + "status": g.status, + "phase": g.phase, + "started_at": g.started_at.isoformat() if g.started_at else "", + "completed_at": g.completed_at.isoformat() if g.completed_at else "", + "created_at": g.created_at.isoformat(), + } + for g in games + ] diff --git a/backend/app/routers/games.py b/backend/app/routers/games.py new file mode 100644 index 0000000..13b2940 --- /dev/null +++ b/backend/app/routers/games.py @@ -0,0 +1,694 @@ +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 diff --git a/backend/app/routers/scripts.py b/backend/app/routers/scripts.py new file mode 100644 index 0000000..54a0735 --- /dev/null +++ b/backend/app/routers/scripts.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +from app.database import get_db +from app.models.script import Script +from app.scripts.sample import SCRIPTS + +router = APIRouter(prefix="/api/scripts", tags=["scripts"]) + + +class ScriptListItem(BaseModel): + id: str + title: str + type: str + difficulty: int + min_players: int + max_players: int + duration: str + background: str + + +@router.get("") +async def list_scripts(): + items = [] + for s in SCRIPTS: + items.append(ScriptListItem( + id=s["id"], + title=s["title"], + type=s["type"], + difficulty=s["difficulty"], + min_players=s["playerCount"]["min"], + max_players=s["playerCount"]["max"], + duration=s["duration"], + background=s["background"], + )) + return items + + +@router.get("/{script_id}") +async def get_script(script_id: str): + for s in SCRIPTS: + if s["id"] == script_id: + return s + raise HTTPException(404, "剧本不存在") diff --git a/backend/app/scripts/__init__.py b/backend/app/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/scripts/sample.py b/backend/app/scripts/sample.py new file mode 100644 index 0000000..64e7a3d --- /dev/null +++ b/backend/app/scripts/sample.py @@ -0,0 +1,23 @@ +import json +import os + +_data_path = os.path.join(os.path.dirname(__file__), 'sample_data.json') + +with open(_data_path, 'r', encoding='utf-8') as f: + SCRIPTS = json.load(f) + +BUILTIN_SCRIPTS = SCRIPTS + + +def get_script_by_id(script_id: str) -> dict | None: + for s in SCRIPTS: + if s.get("id") == script_id: + return s + return None + + +def get_script_by_title(title: str) -> dict | None: + for s in SCRIPTS: + if s["title"] == title: + return s + return None diff --git a/backend/app/scripts/sample_data.json b/backend/app/scripts/sample_data.json new file mode 100644 index 0000000..bea0d2e --- /dev/null +++ b/backend/app/scripts/sample_data.json @@ -0,0 +1,710 @@ +[ + { + "id": "script_fog", + "title": "雾港庄园谋杀案", + "type": "hardcore", + "difficulty": 2, + "playerCount": { + "min": 1, + "max": 5 + }, + "duration": "60-90min", + "background": "1940年深秋,雾港庄园的主人——富商爱德华·格雷伯爵在一场暴雨夜中被发现死于书房。房门反锁,窗户紧闭,这是一间密室。在座的每一位客人都有嫌疑,每一个微笑背后都藏着秘密。", + "phases": [ + { + "name": "第一幕:暴雨夜宴", + "description": "暴雨之夜,格雷伯爵邀请诸位宾客参加晚宴。席间伯爵面色凝重,似乎在隐藏什么。午夜钟声响起,仆人发现伯爵死于书房。", + "publicInfo": "所有人都在客厅等待,仆人老张去书房叫伯爵时发现尸体。", + "clues": [ + "clue_1", + "clue_2" + ] + }, + { + "name": "第二幕:迷雾重重", + "description": "众人震惊之余,开始回忆当晚的细节。每个人似乎都在隐瞒什么。", + "publicInfo": "法医初步判断死亡时间为晚上10点到11点之间,死因是中毒。", + "clues": [ + "clue_3", + "clue_4", + "clue_5" + ] + }, + { + "name": "第三幕:真相浮现", + "description": "随着调查深入,更多线索浮出水面。密室的真相、每个人的动机,一切即将揭晓。", + "publicInfo": "书房的门锁是老旧式样的机械锁,从内部反锁需要转动钥匙。", + "clues": [ + "clue_6", + "clue_7" + ] + }, + { + "name": "最终幕:投票与结局", + "description": "所有线索已呈现,是时候指认真凶了。", + "publicInfo": "请各位做出最终判断。", + "clues": [] + } + ], + "roles": [ + { + "id": "role_detective", + "name": "侦探·林默", + "publicProfile": "一位来自伦敦的私家侦探,受伯爵邀请前来参加晚宴。", + "secretProfile": "你是爱德华的旧友,半年前曾帮他处理过一起勒索案。你怀疑爱德华最近又被人威胁了。", + "secret": "你其实一直在暗中调查爱德华,怀疑他与一桩军火走私案有关。", + "goal": "查明真凶,揭露真相。", + "winCondition": "正确指认凶手", + "personality": "冷静理智、善于观察" + }, + { + "id": "role_white", + "name": "白若溪", + "publicProfile": "伯爵的私人秘书,跟随伯爵五年,深得信任。", + "secretProfile": "你深爱着爱德华,但他却对你不冷不热。最近你发现他准备解雇你,因为你知道的太多了。", + "secret": "你偷了伯爵保险柜里的一封密信,信中揭露了军火交易的细节。", + "goal": "保护自己,不要让任何人发现你偷了信。", + "winCondition": "不被指认为凶手", + "personality": "温柔优雅但内心焦虑" + }, + { + "id": "role_black", + "name": "黑蛇", + "publicProfile": "一位神秘的东方商人,当晚第一次拜访庄园。", + "secretProfile": "你是爱德华的生意伙伴,但最近一批货出了问题,爱德华拒绝赔偿你的损失。", + "secret": "你今晚在伯爵的酒杯里下了安眠药,本想等他睡着后去书房偷一份合约,但没想到他死了。", + "goal": "洗脱嫌疑,不要让任何人知道你下药的事。", + "winCondition": "不被指认为凶手", + "personality": "深沉狡诈、善于言辞" + }, + { + "id": "role_red", + "name": "红玫瑰", + "publicProfile": "一位过气的歌剧演员,自称是伯爵的远房表妹。", + "secretProfile": "你根本不是伯爵的表妹。你是一个骗子,想利用伯爵的社交圈东山再起。爱德华已经发现了你的真实身份。", + "secret": "你曾在10点左右去书房找伯爵求情,发现他正在和一个神秘人激烈争吵。你躲在门外偷听,听到了“军火”和“灭口”等词。", + "goal": "维持你的假身份,找出伯爵之死的真相趁机牟利。", + "winCondition": "不被指认为凶手", + "personality": "戏剧化、爱出风头" + }, + { + "id": "role_green", + "name": "翡翠", + "publicProfile": "庄园的女仆,已经在庄园工作了三年。", + "secretProfile": "你的弟弟曾经是爱德华工厂的工人,因工伤去世,爱德华只赔了一笔小钱。你来到庄园做女仆,是为了收集爱德华的罪证。", + "secret": "你在晚宴开始前清理书房时,在书架后面发现了一把手枪。你偷偷藏了起来。", + "goal": "为弟弟讨回公道,找出爱德华的犯罪证据。", + "winCondition": "找出爱德华的全部罪证", + "personality": "沉默寡言、内心坚毅" + }, + { + "id": "role_old_zhang", + "name": "老张", + "publicProfile": "庄园的老管家,服务了格雷家族三十年。", + "secretProfile": "你看着爱德华长大,知道他所有的秘密。你一直帮他掩盖那些见不得光的事,但最近你良心不安。", + "secret": "是你帮爱德华处理了那封勒索信的送信人。你把这个秘密埋在心底。", + "goal": "保护庄园的名誉,但也要守住自己的秘密。", + "winCondition": "真相不被完全揭开", + "personality": "忠诚但矛盾、老谋深算" + } + ], + "clues": [ + { + "id": "clue_1", + "name": "破碎的酒杯", + "description": "书房地毯上有一个破碎的高脚杯,检测出残留的安眠药成分。", + "phase": 1 + }, + { + "id": "clue_2", + "name": "反锁的门", + "description": "书房的门从内部反锁,钥匙还插在锁孔里。但窗户有一道缝隙,勉强可以穿过一根细线。", + "phase": 1 + }, + { + "id": "clue_3", + "name": "神秘信件", + "description": "书桌上有一封没有寄出的信,收件人是“伦敦警察厅”,内容提到“军火走私”和“灭口”。", + "phase": 2 + }, + { + "id": "clue_4", + "name": "壁炉里的灰烬", + "description": "壁炉里有烧毁的文件残片,依稀可辨“合约”和“5000英镑”字样。", + "phase": 2 + }, + { + "id": "clue_5", + "name": "时间线记录", + "description": "根据众人的证词整理的时间线:8点晚宴开始,9点伯爵离席去书房,10点半红玫瑰去书房,11点黑蛇去书房,11点半老张发现尸体。", + "phase": 2 + }, + { + "id": "clue_6", + "name": "书架后的手枪", + "description": "书架后面发现一把手枪,已经擦去了指纹。经检测,这把枪近期发射过。", + "phase": 3 + }, + { + "id": "clue_7", + "name": "密室的真相", + "description": "仔细观察门锁结构,发现可以用鱼线从门缝下方套住钥匙,从外部反锁房门。", + "phase": 3 + } + ], + "truth": "真凶是老张。他利用鱼线从门缝制造了密室,在伯爵的酒杯里下毒。动机:伯爵发现了老张三十年前的一桩旧案,以此要挟他。老张为了保护自己,先下手为强。" + }, + { + "id": "script_zero", + "title": "零号舱的悖论", + "type": "hardcore", + "difficulty": 4, + "playerCount": { + "min": 1, + "max": 5 + }, + "duration": "90-120min", + "background": "公元2157年,人类第一艘超光速飞船“零号舱”在试飞中消失。72小时后,飞船突然出现在木星轨道上,船体完好但全员失忆。更诡异的是,船员们的记忆碎片拼接后发现——他们中有一个人,根本不属于这个时空。", + "phases": [ + { + "name": "第一幕:失忆的船员", + "description": "你在飞船上醒来,发现自己失去了部分记忆。面前是5个同样茫然的同伴。飞船AI报告:有人篡改了航行日志。", + "publicInfo": "飞船AI显示航行日志被篡改,最后一个条目写着“他不是我们中的一员”。", + "clues": [ + "zc_clue_1", + "zc_clue_2" + ] + }, + { + "name": "第二幕:记忆碎片", + "description": "随着交流深入,每个人的记忆开始恢复片段。但奇怪的是,有些人的记忆互相矛盾。", + "publicInfo": "船上有5人:船长、工程师、医疗官、生物学家、通讯员。其中一人的记忆与其他四人不在同一条时间线。", + "clues": [ + "zc_clue_3", + "zc_clue_4", + "zc_clue_5" + ] + }, + { + "name": "第三幕:悖论的核心", + "description": "计算显示,超光速飞行导致了时间裂缝。两个平行宇宙的人在那一刻被融合到了一起。", + "publicInfo": "飞船AI复原了部分航行数据,证明发生过时空异常。", + "clues": [ + "zc_clue_6", + "zc_clue_7" + ] + }, + { + "name": "最终幕:投票与结局", + "description": "必须找出那个不属于这个时空的人,否则飞船的能量核心将在15分钟后过载。", + "publicInfo": "请做出最终判断。", + "clues": [] + } + ], + "roles": [ + { + "id": "zc_captain", + "name": "艾伦船长", + "publicProfile": "“零号舱”的船长,深空探索计划的王牌飞行员。", + "secretProfile": "你记得起飞前收到过一封密令:这次飞行有秘密任务——测试一种量子纠缠通讯装置。但你在飞行日志里找不到任何记录。", + "secret": "你其实是另一个平行宇宙的艾伦船长。在那个宇宙,飞行失败了,全员遇难。你不知道为什么你还活着。", + "goal": "找出时空异常者,保护飞船。", + "winCondition": "正确投出异常者", + "personality": "果断坚毅、有领导力" + }, + { + "id": "zc_engineer", + "name": "伊娃工程师", + "publicProfile": "天才工程师,负责飞船动力系统。", + "secretProfile": "你在飞行前偷偷在飞船主控系统里植入了一个后门程序——为了监视船长的秘密任务。", + "secret": "你发现飞船的能量读数异常:实际消耗比记录多了300%。有人在偷偷使用额外能量。", + "goal": "弄清楚秘密任务的真相。", + "winCondition": "查明全部真相", + "personality": "聪明谨慎、略带神经质" + }, + { + "id": "zc_medic", + "name": "王医疗官", + "publicProfile": "飞船医疗官,也是心理学专家。", + "secretProfile": "你在飞行前注意到所有船员都有轻微的记忆偏差,但你以为只是压力导致的。", + "secret": "你给自己做了记忆扫描,发现自己有一段被植入的虚假记忆——关于某个船员的背景。", + "goal": "保护船员心理健康,找出真相。", + "winCondition": "正确投出异常者", + "personality": "温和理性、善于倾听" + }, + { + "id": "zc_bio", + "name": "萨沙生物学家", + "publicProfile": "研究外星微生物的科学家,本次飞行是为了采集木卫二冰层下的样本。", + "secretProfile": "你其实不是真正的萨沙。真正的萨沙在起飞前三天失踪了,你是被秘密机构派来顶替的。", + "secret": "你知道零号舱的真正任务:寻找外星生命样本——但不能让其他船员知道。", + "goal": "完成样本采集任务,不暴露身份。", + "winCondition": "不被发现真实身份", + "personality": "神秘疏离、知识渊博" + }, + { + "id": "zc_comms", + "name": "李通讯官", + "publicProfile": "飞船通讯官,负责与地球保持联系。", + "secretProfile": "你发现通讯系统被人为干扰了。地球发来的信号中有重复的摩斯密码,内容是“小心”。", + "secret": "你在失忆前最后一刻发送了一条求救信号。但现在通讯系统显示:信号从未发出过。", + "goal": "恢复通讯,向地球报告情况。", + "winCondition": "让飞船安全返航", + "personality": "活泼外向、容易紧张" + } + ], + "clues": [ + { + "id": "zc_clue_1", + "name": "矛盾的照片", + "description": "船员合影中,有一个人出现在了不该出现的位置——合影拍摄时他应该在飞船另一侧。", + "phase": 1 + }, + { + "id": "zc_clue_2", + "name": "篡改的日志", + "description": "航行日志被修改了37次,修改者ID显示为“所有人”。", + "phase": 1 + }, + { + "id": "zc_clue_3", + "name": "能量异常", + "description": "飞船能量消耗记录显示,有额外的300%能量被消耗,但找不到去向。", + "phase": 2 + }, + { + "id": "zc_clue_4", + "name": "双重记忆", + "description": "两个船员对同一事件的记忆完全不同——一个记得船长在驾驶舱,另一个记得船长在餐厅。", + "phase": 2 + }, + { + "id": "zc_clue_5", + "name": "量子纠缠信号", + "description": "飞船接收到一组量子纠缠信号,编码方式不属于已知的任何人类通讯协议。", + "phase": 2 + }, + { + "id": "zc_clue_6", + "name": "平行宇宙理论", + "description": "飞船AI数据库中有被加密访问的记录:关于平行宇宙融合的物理学论文,访问时间正是起飞前。", + "phase": 3 + }, + { + "id": "zc_clue_7", + "name": "最后的录像", + "description": "在飞船黑匣子里发现一段录像:一个船员对着镜头说“我是唯一真实的人”,随后录像被删除。", + "phase": 3 + } + ], + "truth": "真正的异常者是艾伦船长。他来自另一个平行宇宙——在那个宇宙中零号舱爆炸了,他的意识在爆炸瞬间穿越到了这个宇宙的艾伦身上。两个艾伦的意识在同一具身体里共存,导致记忆混乱。" + }, + { + "id": "script_photo", + "title": "遗忘照相馆", + "type": "emotional", + "difficulty": 1, + "playerCount": { + "min": 1, + "max": 4 + }, + "duration": "45-60min", + "background": "在城市的老街拐角,有一家不起眼的照相馆。店主是一位老人,据说他拍的照片能让人看到最想念的人。每个走进这家照相馆的人,都带着一段未了的故事。", + "phases": [ + { + "name": "第一幕:走进照相馆", + "description": "你推开那扇吱呀作响的木门,风铃响起。店内陈列着许多黑白照片,每一张都像在诉说一个故事。", + "publicInfo": "照相馆内光线昏暗,墙上挂满了照片。柜台后坐着一位白发老人。", + "clues": [ + "ph_clue_1" + ] + }, + { + "name": "第二幕:照片中的秘密", + "description": "老人让你挑选一张照片。你伸手触碰的瞬间,照片中的人影开始晃动——你看到了自己最深处的记忆。", + "publicInfo": "每一张照片都对应着一个人内心最深的牵挂。", + "clues": [ + "ph_clue_2", + "ph_clue_3" + ] + }, + { + "name": "第三幕:未说出口的话", + "description": "照片中的人开始说话,那是你一直想见却再也见不到的人。你有机会说出当年没说完的话。", + "publicInfo": "照片中的影像只能持续十分钟。", + "clues": [ + "ph_clue_4" + ] + }, + { + "name": "最终幕:告别", + "description": "十分钟到了,照片中的人影渐渐淡去。但你知道,有些话终于说出了口。", + "publicInfo": "老人微笑着看着你,眼角有泪光。", + "clues": [] + } + ], + "roles": [ + { + "id": "ph_old_man", + "name": "照相馆老人", + "publicProfile": "照相馆的老板,一位白发苍苍的老人,眼神深邃。", + "secretProfile": "你其实已经去世十年了。这家照相馆是你留在人间的执念——帮助那些有遗憾的人完成告别。", + "secret": "每一张照片都是你用自己的记忆编织的幻象。每帮一个人,你的存在就会消散一分。", + "goal": "在彻底消失之前,帮助尽可能多的人。", + "winCondition": "帮助客人完成心愿", + "personality": "慈祥智慧、略带忧伤" + }, + { + "id": "ph_guest_1", + "name": "寻女的母亲", + "publicProfile": "一位中年妇女,神色憔悴,手中紧握一张泛黄的全家福。", + "secretProfile": "你的女儿在五年前的一场事故中去世了。你一直无法接受这个事实,每年她的生日你都会来这家照相馆。", + "secret": "你今天带来了女儿最喜欢的那条红围巾。你想再给她围上一次。", + "goal": "再见女儿一面,告诉她妈妈永远爱她。", + "winCondition": "完成与女儿的最后告别", + "personality": "温柔坚强、隐忍" + }, + { + "id": "ph_guest_2", + "name": "愧疚的丈夫", + "publicProfile": "一位三十多岁的男人,西装革履但满脸疲惫。", + "secretProfile": "你的妻子在生宝宝时难产去世了。这几年你一直忙于工作,用忙碌麻痹自己。", + "secret": "你从来没敢给宝宝看妈妈的照片。你怕自己先哭出来。", + "goal": "告诉妻子你有多想她,告诉她宝宝长得像她。", + "winCondition": "说出藏在心里的话", + "personality": "表面坚强、内心脆弱" + }, + { + "id": "ph_guest_3", + "name": "寻兄的少年", + "publicProfile": "一个十几岁的少年,背着书包,眼神倔强。", + "secretProfile": "你的哥哥是消防员,去年在一次火灾中牺牲了。你们最后一次吵架是因为一台游戏机。", + "secret": "你一直偷偷留着哥哥那台旧游戏机,但你再也没打开过。", + "goal": "跟哥哥说对不起,告诉他你是他的骄傲。", + "winCondition": "与哥哥和解", + "personality": "倔强叛逆、内心柔软" + } + ], + "clues": [ + { + "id": "ph_clue_1", + "name": "泛黄的相册", + "description": "柜台上放着一本手工相册,扉页上写着“送给每一个需要告别的人”。", + "phase": 1 + }, + { + "id": "ph_clue_2", + "name": "会动的照片", + "description": "当你触碰照片时,画面中的人像开始活动,就像一段被封存的记忆被重新播放。", + "phase": 2 + }, + { + "id": "ph_clue_3", + "name": "老人的倒影", + "description": "墙上的镜子里,老人没有倒影。但你不敢细想。", + "phase": 2 + }, + { + "id": "ph_clue_4", + "name": "消散的边缘", + "description": "老人的身影在灯光下渐渐变得透明,像一张正在褪色的照片。", + "phase": 3 + } + ], + "truth": "照相馆老人是一个未能完成心愿的亡灵。他生前是这家照相馆的老板,去世前最遗憾的是没能和女儿好好告别。于是他化作照相馆的一部分,用最后的能力帮助每一个带着遗憾而来的人。当最后一位客人完成告别时,老人的身影化作星光消散——他终于可以安息了。" + }, + { + "id": "script_jade", + "title": "翡翠城的交易", + "type": "欢乐", + "difficulty": 2, + "playerCount": { + "min": 1, + "max": 6 + }, + "duration": "60-90min", + "background": "翡翠城是沙漠中唯一的不夜城。今晚,城中最有权势的商人金大牙将在他的赌场举办一场盛大的拍卖会。传闻一件价值连城的宝物“翡翠之心”将在今晚易主。但在拍卖开始前,金大牙的保险柜被洗劫一空。", + "phases": [ + { + "name": "第一幕:拍卖之夜", + "description": "赌场内灯火辉煌,宾客云集。金大牙站在舞台上宣布拍卖开始,但当他想展示“翡翠之心”时,保险柜已经空了。", + "publicInfo": "保险柜完好无损,密码锁没有被破坏的痕迹——这意味着是知道密码的人干的。", + "clues": [ + "jd_clue_1", + "jd_clue_2" + ] + }, + { + "name": "第二幕:各怀鬼胎", + "description": "金大牙封锁了赌场,所有人都有嫌疑。每个宾客似乎都有不可告人的目的。", + "publicInfo": "金大牙宣布:在找到“翡翠之心”之前,谁也别想离开。", + "clues": [ + "jd_clue_3", + "jd_clue_4" + ] + }, + { + "name": "第三幕:谁在说谎", + "description": "通过搜证和盘问,宾客们发现每个人都在打“翡翠之心”的主意,但真正的窃贼只有一个。", + "publicInfo": "有人匿名送来了一张纸条:“翡翠之心已经在拍卖会前就被调包了。”", + "clues": [ + "jd_clue_5", + "jd_clue_6" + ] + }, + { + "name": "最终幕:指认窃贼", + "description": "所有线索都指向了真相。谁才是真正的窃贼?", + "publicInfo": "金大牙给出了最后的机会:主动交出翡翠之心,既往不咎。", + "clues": [] + } + ], + "roles": [ + { + "id": "jd_gold", + "name": "金大牙", + "publicProfile": "赌场老板,翡翠城首富,以精明的商业头脑和黄金大牙闻名。", + "secretProfile": "你根本没有“翡翠之心”。你在上个月就已经把它卖掉了,今晚的拍卖会只是一个幌子——为了骗保险。", + "secret": "保险柜是你自己派人偷偷清空的,目的是获取巨额保险金。但你没想到,真的有人在你行动之前就把东西拿走了。", + "goal": "查出是谁拿走了本应被你拿走的东西。", + "winCondition": "找回失物且不被发现骗保", + "personality": "精明圆滑、嚣张跋扈" + }, + { + "id": "jd_blind", + "name": "盲眼算师", + "publicProfile": "赌场的首席算牌师,虽然双目失明但听力超群。", + "secretProfile": "你的眼睛其实没有问题。你装瞎是为了收集赌场的内幕信息——你是竞争对手派来的商业间谍。", + "secret": "你昨晚目睹了一个人鬼鬼祟祟地接近金大牙的办公室。但因为你是“盲人”,你不能说出来。", + "goal": "偷取金大牙的客户名单。", + "winCondition": "获取名单且不暴露身份", + "personality": "神秘莫测、话中有话" + }, + { + "id": "jd_dancer", + "name": "红舞娘", + "publicProfile": "赌场的驻场舞者,以曼妙的舞姿倾倒无数客人。", + "secretProfile": "你曾经是金大牙的情人,但他抛弃了你。你来参加今晚的拍卖会是为了报复。", + "secret": "你在金大牙的酒里下了泻药。本想让他在拍卖会上出丑,没想到发生了失窃案。", + "goal": "让金大牙身败名裂。", + "winCondition": "揭露金大牙的丑闻", + "personality": "风情万种、敢爱敢恨" + }, + { + "id": "jd_traveler", + "name": "沙漠旅人", + "publicProfile": "一个路过的旅行商人,自称是第一次来翡翠城。", + "secretProfile": "你是一名职业盗贼,专偷富商。你已经在翡翠城踩点了一个月。", + "secret": "你确实打算偷“翡翠之心”,但当你潜入保险柜时,发现里面已经空了!你被人抢先一步。", + "goal": "洗脱嫌疑,找到真正的窃贼。", + "winCondition": "不被当成替罪羊", + "personality": "洒脱不羁、玩世不恭" + }, + { + "id": "jd_waiter", + "name": "阿福", + "publicProfile": "赌场的侍者,在赌场工作了十年,老实本分。", + "secretProfile": "你是金大牙的私生子,但你妈从来没让你认他。你来赌场工作只是为了离这个父亲近一点。", + "secret": "你有金大牙办公室的备用钥匙。你经常在深夜偷偷进去,只是想在父亲待过的地方坐一坐。", + "goal": "保护金大牙,虽然他不知道自己是谁。", + "winCondition": "不暴露自己的真实身份", + "personality": "老实憨厚、内心敏感" + }, + { + "id": "jd_inspector", + "name": "陈探长", + "publicProfile": "翡翠城的警长,金大牙的老朋友。今晚以宾客身份出席。", + "secretProfile": "你一直在调查金大牙的走私活动。今晚的拍卖会是你搜集证据的好机会。", + "secret": "你其实已经拿到了金大牙的走私证据,只差人赃并获。失窃案打乱了你的计划。", + "goal": "查出失窃案真相,同时搜集金大牙的罪证。", + "winCondition": "将罪犯绳之以法", + "personality": "正义凛然、老谋深算" + } + ], + "clues": [ + { + "id": "jd_clue_1", + "name": "完好的保险柜", + "description": "保险柜没有被撬的痕迹,密码锁显示最后一次输入密码是在昨晚11点。", + "phase": 1 + }, + { + "id": "jd_clue_2", + "name": "监控录像", + "description": "监控录像显示昨晚11点到12点之间有一段画面被覆盖了。覆盖的人对监控系统非常熟悉。", + "phase": 1 + }, + { + "id": "jd_clue_3", + "name": "酒里的泻药", + "description": "金大牙的酒杯里检测出泻药成分。有人想让他出丑。", + "phase": 2 + }, + { + "id": "jd_clue_4", + "name": "指纹对比", + "description": "保险柜上提取到多组指纹,其中一组不属于任何已知的赌场员工。", + "phase": 2 + }, + { + "id": "jd_clue_5", + "name": "空盒子", + "description": "在金大牙办公室的暗格里发现了一个盒子,标签写着“翡翠之心”,但里面是空的。", + "phase": 3 + }, + { + "id": "jd_clue_6", + "name": "匿名信", + "description": "一封没有署名的信:“翡翠之心早就不在保险柜里了。查查金大牙最近的银行流水。”", + "phase": 3 + } + ], + "truth": "真正的窃贼是金大牙自己。他早就把翡翠之心卖掉了,安排了一场假拍卖来骗保。但沙漠旅人的确试图行窃,发现保险柜空了——这就是为什么会有陌生指纹。盲眼算师目睹了沙漠旅人接近办公室,但以为是金大牙的人。整个事件是一场连环套:金大牙骗保、旅人偷窃未遂、算师商业间谍,三件事撞在了一起。" + }, + { + "id": "script_rain", + "title": "阴雨公寓", + "type": "恐怖", + "difficulty": 3, + "playerCount": { + "min": 1, + "max": 5 + }, + "duration": "60-90min", + "background": "城中村有一栋老旧的公寓楼,每逢雨天,顶楼就会传来诡异的敲击声。三个月前,一个住户在雨夜失踪了。今晚又是暴雨,新搬来的住户们发现:这栋楼里的住户,似乎比他们以为的要多。", + "phases": [ + { + "name": "第一幕:雨夜搬入", + "description": "暴雨如注,你搬进了这栋老旧的公寓。楼道里的灯忽明忽暗,电梯似乎永远停在顶楼。前台的老太太用奇怪的眼神看着你。", + "publicInfo": "公寓共7层,你住在3楼。据前台说,整栋楼只有5户有人住。", + "clues": [ + "ra_clue_1", + "ra_clue_2" + ] + }, + { + "name": "第二幕:邻居们", + "description": "你开始认识楼里的其他住户。每个人似乎都有秘密。深夜,顶楼再次传来了敲击声。", + "publicInfo": "敲击声有规律:三长两短,像是某种信号。", + "clues": [ + "ra_clue_3", + "ra_clue_4" + ] + }, + { + "name": "第三幕:消失的住户", + "description": "你发现失踪的住户并没有搬走——他的房间里一切如常,只是人不见了。他的手机最后一条信息是“我在顶楼”。", + "publicInfo": "顶楼的门锁着,需要钥匙才能上去。前台说顶楼已经十年没人住了。", + "clues": [ + "ra_clue_5", + "ra_clue_6" + ] + }, + { + "name": "最终幕:顶楼的秘密", + "description": "你终于拿到了顶楼的钥匙。敲击声就在门后。当你打开门的瞬间,你看到了……", + "publicInfo": "真相就在这扇门后面。", + "clues": [ + "ra_clue_7" + ] + } + ], + "roles": [ + { + "id": "ra_reporter", + "name": "记者·方晴", + "publicProfile": "调查记者,为了调查三个月前的失踪案搬进公寓。", + "secretProfile": "你怀疑这栋公寓和近三年来的五起失踪案有关。你伪装成普通住户来调查。", + "secret": "你查到了失踪者的共同点:他们都曾在失踪前提到过“顶楼的敲击声”。", + "goal": "查明失踪案的真相,写出独家报道。", + "winCondition": "揭开全部秘密", + "personality": "勇敢执着、敏锐" + }, + { + "id": "ra_teacher", + "name": "张老师", + "publicProfile": "退休教师,在公寓住了二十年。和蔼可亲。", + "secretProfile": "你知道顶楼的秘密——十年前,顶楼住着一对夫妇,妻子在雨夜被杀,丈夫失踪了。从那以后,每逢雨夜就有敲击声。", + "secret": "你其实知道顶楼的钥匙在哪。但你不敢说,因为你怕那个“丈夫”还在楼里。", + "goal": "保护自己和邻居的安全。", + "winCondition": "真相不危害到活着的人", + "personality": "慈祥温和、欲言又止" + }, + { + "id": "ra_doctor", + "name": "林医生", + "publicProfile": "年轻的心理医生,刚搬来不久。", + "secretProfile": "你根本不是心理医生。你是失踪者的弟弟,来寻找哥哥的下落。", + "secret": "你哥哥失踪前给你打过电话,说“这栋楼里有一个不存在的人”。你以为是他的精神出了问题。", + "goal": "找到哥哥,无论死活。", + "winCondition": "查明哥哥的下落", + "personality": "冷静理智、内心焦虑" + }, + { + "id": "ra_landlord", + "name": "包租婆", + "publicProfile": "公寓的管理员兼前台,看似刻薄但热心肠。", + "secretProfile": "你当年目睹了顶楼的谋杀案。你一直保持沉默因为凶手威胁要伤害你的家人。", + "secret": "是你把顶楼锁起来的。每月的15号你会在顶楼门口放一碗米饭——那是你赎罪的方式。", + "goal": "守住这个秘密,保护楼里的住户。", + "winCondition": "秘密不被发现", + "personality": "刀子嘴豆腐心、担惊受怕" + }, + { + "id": "ra_stranger", + "name": "神秘男子", + "publicProfile": "一个沉默寡言的年轻人,住在404室。几乎不出门。", + "secretProfile": "你就是顶楼那个“死了”的丈夫。你当年失手杀了妻子,然后伪造了自己的死亡。这些年你一直躲在404室的密室里。", + "secret": "敲击声是你发出的信号——你在提醒自己“她还在这里”。你已经开始产生幻觉,觉得妻子的鬼魂在跟着你。", + "goal": "不暴露身份,继续躲藏。", + "winCondition": "不被发现真实身份", + "personality": "阴郁沉默、精神恍惚" + } + ], + "clues": [ + { + "id": "ra_clue_1", + "name": "住户登记表", + "description": "前台登记表显示,这栋楼登记住户有6人,但前台说只有5户有人住。多出来的一个是谁?", + "phase": 1 + }, + { + "id": "ra_clue_2", + "name": "电梯的异常", + "description": "电梯的楼层按钮中,“4”和“顶楼”的按钮是新的,其他按钮都已褪色。", + "phase": 1 + }, + { + "id": "ra_clue_3", + "name": "墙上的划痕", + "description": "404室的墙角有指甲划出的痕迹,看起来像是有人被拖行过。", + "phase": 2 + }, + { + "id": "ra_clue_4", + "name": "顶楼的灯光", + "description": "每晚12点,从对面楼可以看到顶楼的窗户亮灯一分钟,然后熄灭。", + "phase": 2 + }, + { + "id": "ra_clue_5", + "name": "失踪者的房间", + "description": "失踪者的房间里,日历停在了三个月前的那一天。桌上有一张照片,照片中有6个人,但你只认识5个。", + "phase": 3 + }, + { + "id": "ra_clue_6", + "name": "密室的入口", + "description": "404室的衣柜后面发现了一道暗门。暗门通向一个狭小的隔间,里面有生活痕迹。", + "phase": 3 + }, + { + "id": "ra_clue_7", + "name": "顶楼的真相", + "description": "顶楼的房间里,墙上贴满了同一个女人的照片——是404室那个“神秘男子”的妻子。桌上有一本日记,最后一页写着“她回来了”。", + "phase": 4 + } + ], + "truth": "真正恐怖的是:那个“神秘男子”(404室住户)确实是顶楼命案的凶手。他在十年前雨夜杀死了妻子,伪造了自己的死亡,躲在404室的密室里。但三个月前失踪的住户不是他杀的——那个失踪者在调查顶楼秘密时,被包租婆误认为是入室窃贼而失手打死。包租婆把尸体藏在了顶楼的水箱里。所以这栋楼里有两个秘密:一个是十年前的谋杀案,一个是三个月前的误杀案。而敲击声——是水箱里尸体随水流撞击内壁的声音。" + } +] \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..a0f339f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +sqlalchemy==2.0.35 +aiosqlite==0.20.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +pydantic==2.9.0 +pydantic-settings==2.5.0 +python-multipart==0.0.12 +httpx==0.27.0 +websockets==13.0 \ No newline at end of file diff --git a/backend/test_npc.py b/backend/test_npc.py new file mode 100644 index 0000000..d938422 --- /dev/null +++ b/backend/test_npc.py @@ -0,0 +1,29 @@ +import httpx, json, time + +c = httpx.Client() +base = 'http://localhost:8000' + +g = c.post(base + '/api/games/create', json={'script_id': 'script_fog', 'human_role_id': 'role_detective'}).json() +gid = g['game_id'] +print('Game:', gid[:8]) + +s = c.get(base + f'/api/games/{gid}/state').json() +for p in s['players']: + print(f' {p["role_name"]} (human={p["is_human"]})') + +c.post(base + f'/api/games/{gid}/start') +print('Started') + +ch = c.post(base + f'/api/games/{gid}/chat', json={ + 'sender_role_id': 'role_detective', 'type': 'public', 'content': '各位,案发时你们在哪?' +}).json() +print('Chat sent:', ch['status']) + +time.sleep(1.5) + +s2 = c.get(base + f'/api/games/{gid}/state').json() +print(f'\nMessages ({len(s2["messages"])}):') +for m in s2['messages'][-6:]: + print(f' [{m["sender_name"]}] {m["content"][:60]}') + +print('\n=== NPC auto-response test passed ===') diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0d0c2a8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + AI剧本杀 + + +
+ + + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a5610b7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "mmgame-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.0", + "vite": "^5.4.0" + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f621796 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,28 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { Home } from './pages/Home' +import { Login } from './pages/Login' +import { ScriptList } from './pages/ScriptList' +import { ScriptDetail } from './pages/ScriptDetail' +import { Game } from './pages/Game' +import { Replay } from './pages/Replay' +import { History } from './pages/History' + +function App() { + return ( + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ ) +} + +export default App \ No newline at end of file diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..b4d1dc2 --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,30 @@ +import { Link, useNavigate } from 'react-router-dom' +import { useStore } from '../store' + +export function Navbar() { + const { user, logout } = useStore() + const navigate = useNavigate() + + const handleLogout = () => { + logout() + navigate('/') + } + + return ( + + ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..c208969 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './styles/global.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) \ No newline at end of file diff --git a/frontend/src/pages/Game.tsx b/frontend/src/pages/Game.tsx new file mode 100644 index 0000000..f362340 --- /dev/null +++ b/frontend/src/pages/Game.tsx @@ -0,0 +1,433 @@ +import { useEffect, useState, useRef, useCallback } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { api } from '../store/api' + +interface Player { + id: string + role_id: string + role_name: string + is_human: boolean + status: string +} + +interface Message { + id: string + sender_role_id: string + sender_name: string + message_type: string + content: string + target_role_id?: string + clue_id?: string + phase_index: number + created_at: string +} + +export function Game() { + const { id } = useParams() + const navigate = useNavigate() + const [gameState, setGameState] = useState(null) + const [players, setPlayers] = useState([]) + const [messages, setMessages] = useState([]) + const [clues, setClues] = useState<{ clue_id: string; status: string }[]>([]) + const [script, setScript] = useState(null) + const [phase, setPhase] = useState('') + const [phaseIndex, setPhaseIndex] = useState(0) + const [status, setStatus] = useState('') + const [inputText, setInputText] = useState('') + const [chatMode, setChatMode] = useState<'public' | 'private'>('public') + const [privateTarget, setPrivateTarget] = useState(null) + const [voteTarget, setVoteTarget] = useState('') + const [voteReason, setVoteReason] = useState('') + const [voted, setVoted] = useState(false) + const [votingOpen, setVotingOpen] = useState(false) + const [loading, setLoading] = useState(true) + const [wsConnected, setWsConnected] = useState(false) + const messagesEndRef = useRef(null) + const wsRef = useRef(null) + const [humanRoleId] = useState(() => sessionStorage.getItem('mm-role-id') || '') + + const addMessages = useCallback((newMsgs: Message[], replaceLocalId?: string, replaceWithRealId?: string) => { + setMessages((prev) => { + let msgs = prev + if (replaceLocalId && replaceWithRealId) { + msgs = msgs.map((m) => m.id === replaceLocalId ? { ...m, id: replaceWithRealId } : m) + } + const existingIds = new Set(msgs.map((m) => m.id)) + const toAdd: Message[] = [] + for (const msg of newMsgs) { + if (existingIds.has(msg.id)) continue + const localIdx = msgs.findIndex( + (m) => m.id.startsWith('local-') && m.sender_role_id === msg.sender_role_id && m.content === msg.content + ) + if (localIdx >= 0) { + msgs[localIdx] = { ...msg } + existingIds.add(msg.id) + } else { + toAdd.push(msg) + existingIds.add(msg.id) + } + } + return toAdd.length > 0 ? [...msgs, ...toAdd] : msgs + }) + }, []) + + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, []) + + useEffect(() => { + scrollToBottom() + }, [messages, scrollToBottom]) + + useEffect(() => { + if (!id) return + const load = async () => { + try { + const state = await api.games.state(id) + setGameState(state) + setPlayers(state.players) + setMessages(state.messages || []) + setClues(state.clues || []) + setScript(state.script) + setPhase(state.phase) + setPhaseIndex(state.phase_index) + setStatus(state.status) + } catch (err: any) { + alert('加载游戏失败: ' + err.message) + } finally { + setLoading(false) + } + } + load() + }, [id]) + + useEffect(() => { + if (!id) return + + const connectWs = () => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const wsUrl = `${protocol}//${window.location.host}/api/games/ws/${id}?role_id=${humanRoleId}` + const ws = new WebSocket(wsUrl) + + ws.onopen = () => setWsConnected(true) + ws.onclose = () => { + setWsConnected(false) + setTimeout(connectWs, 3000) + } + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + handleWsMessage(data) + } catch {} + } + wsRef.current = ws + } + + connectWs() + return () => { + wsRef.current?.close() + } + }, [id]) + + const handleWsMessage = (data: any) => { + switch (data.type) { + case 'chat_message': + addMessages([data.data as Message]) + break + case 'phase_change': + setPhase(data.data.phase) + setPhaseIndex(data.data.phase_index) + if (data.data.message) { + addMessages([data.data.message]) + } + if (data.data.new_clues) { + setClues((prev) => [...prev, ...data.data.new_clues.map((c: string) => ({ clue_id: c, status: 'released' }))]) + } + break + case 'clue_revealed': + setClues((prev) => + prev.map((c) => c.clue_id === data.data.clue_id ? { ...c, status: 'revealed' } : c) + ) + break + case 'vote_cast': + break + case 'vote_result': + setStatus('completed') + if (data.data.message) { + addMessages([data.data.message]) + } + setTimeout(() => navigate(`/replay/${id}`), 3000) + break + case 'game_started': + setStatus('playing') + if (data.data?.message) { + addMessages([data.data.message]) + } + break + } + } + + const handleStartGame = async () => { + if (!id) return + await api.games.start(id) + setStatus('playing') + } + + const handleSendMessage = async () => { + if (!inputText.trim() || !id || !humanRoleId) return + const targetId = chatMode === 'private' && privateTarget ? privateTarget : undefined + const localId = 'local-' + Date.now() + const newMsg: Message = { + id: localId, + sender_role_id: humanRoleId, + sender_name: players.find((p) => p.role_id === humanRoleId)?.role_name || humanRoleId, + message_type: chatMode, + content: inputText, + target_role_id: targetId, + phase_index: phaseIndex, + created_at: new Date().toISOString(), + } + addMessages([newMsg]) + setInputText('') + try { + const res = await api.games.chat(id, humanRoleId, chatMode, inputText, targetId) + addMessages([], localId, res.message_id) + if (res.npc_responses) { + addMessages(res.npc_responses.map((n: any) => n.data)) + } + } catch (err: any) { + setMessages((prev) => prev.map((m) => m.id === localId ? { ...m, content: m.content + ' (发送失败)' } : m)) + } + } + + const handleAdvancePhase = async () => { + if (!id) return + await api.games.advancePhase(id) + } + + const handleRevealClue = async (clueId: string) => { + if (!id) return + await api.games.revealClue(id, clueId) + } + + const handleVote = async () => { + if (!id || !voteTarget) return + await api.games.vote(id, humanRoleId, voteTarget, voteReason) + setVoted(true) + setVotingOpen(false) + } + + const handleEndVote = async () => { + if (!id) return + await api.games.endVote(id) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + if (loading) { + return

加载游戏中...

+ } + + const npcPlayers = players.filter((p) => !p.is_human) + const humanPlayer = players.find((p) => p.is_human) + const releasedClues = clues.filter((c) => c.status !== 'unreleased') + const revealedClues = clues.filter((c) => c.status === 'revealed') + + return ( +
+ {/* Left: Role Panel */} +
+

角色列表

+ {players.map((p) => ( +
{ + if (chatMode === 'private' && privateTarget === p.role_id) { + setChatMode('public') + setPrivateTarget(null) + } else { + setChatMode('private') + setPrivateTarget(p.role_id) + } + }} + style={{ + background: chatMode === 'private' && privateTarget === p.role_id ? '#0f3460' : undefined, + border: p.is_human ? '1px solid #e94560' : 'none', + borderRadius: 6, + }} + > +
{p.role_name[0]}
+
+
{p.role_name}
+
+ {p.is_human ? '你' : 'AI'} + {chatMode === 'private' && privateTarget === p.role_id ? ' (私聊中)' : ''} +
+
+
+
+ ))} +
+ +
+
+ + {/* Center: Chat Area */} +
+
+
+ {phase} + + {status === 'waiting' ? '等待开始' : status === 'playing' ? '进行中' : '已结束'} + +
+
+ + {status === 'waiting' && ( + + )} + {status === 'playing' && ( + <> + + + + )} + {status === 'playing' && voted && ( + + )} +
+
+ +
+ {messages.map((m) => ( +
+ {m.message_type !== 'system' && ( +
+ {m.sender_name} + {m.message_type === 'private' && m.target_role_id && ( + → 私聊 + )} +
+ )} +
{m.content}
+
+ ))} +
+
+ +
+ {chatMode === 'private' && ( + + 私聊 {players.find((p) => p.role_id === privateTarget)?.role_name} + + + )} + setInputText(e.target.value)} + onKeyDown={handleKeyDown} + /> + +
+
+ + {/* Right: Clue Panel */} +
+

线索牌堆

+ {releasedClues.length === 0 && ( +

暂无线索

+ )} + {releasedClues.map((c) => { + const clueInfo = script?.clues?.find((cl: any) => cl.id === c.clue_id) + const isRevealed = c.status === 'revealed' + return ( +
!isRevealed && handleRevealClue(c.clue_id)} + style={{ opacity: isRevealed ? 1 : 0.7, cursor: isRevealed ? 'default' : 'pointer' }} + > +
+ {clueInfo?.name || c.clue_id} + {isRevealed ? ' 🔍' : ' 📌'} +
+
+ {isRevealed ? clueInfo?.description || '' : '点击查看'} +
+
+ ) + })} + + {revealedClues.length > 0 && ( + <> +

已揭示的线索

+ {revealedClues.map((c) => { + const clueInfo = script?.clues?.find((cl: any) => cl.id === c.clue_id) + return ( +
+
{clueInfo?.name || c.clue_id}
+
{clueInfo?.description || ''}
+
+ ) + })} + + )} +
+ + {/* Vote Modal */} + {votingOpen && ( +
setVotingOpen(false)}> +
e.stopPropagation()}> +

投票指认

+ {players.map((p) => ( +
setVoteTarget(p.role_id)} + > +
+ {p.role_name[0]} +
+
+
{p.role_name}
+
{p.is_human ? '你' : 'AI NPC'}
+
+
+ ))} +