From 7614e79d1209e9dda1b69edc1d60df29c87dbd00 Mon Sep 17 00:00:00 2001 From: gmh01 Date: Mon, 3 Aug 2026 11:39:53 +0800 Subject: [PATCH] refactor: rewrite backend in Go, replacing Python FastAPI Port the FastAPI backend to Go 1.26, preserving all API functionality: - auth (register/login/guest/logout/profile/history) with JWT + bcrypt - scripts (list/detail) backed by embedded sample_data.json - games (create/join/start/chat/phase/clue/vote/state/replay) + WebSocket - rule-based NPC agent plus DeepSeek/Ollama AI backends - SQLite persistence via modernc.org/sqlite (pure Go, no cgo) Frontend and documents are unchanged. --- .gitignore | 1 + backend/.env.example | 13 + backend/app/__init__.py | 0 backend/app/agents/__init__.py | 0 backend/app/agents/ai.py | 220 ----- backend/app/agents/dm.py | 24 - backend/app/agents/npc.py | 26 - backend/app/config.py | 21 - backend/app/database.py | 24 - backend/app/main.py | 31 - backend/app/models/__init__.py | 0 backend/app/models/game.py | 94 -- backend/app/models/script.py | 39 - backend/app/models/user.py | 27 - backend/app/routers/__init__.py | 0 backend/app/routers/auth.py | 144 --- backend/app/routers/games.py | 694 -------------- backend/app/routers/scripts.py | 45 - backend/app/scripts/__init__.py | 0 backend/app/scripts/sample.py | 23 - backend/go.mod | 23 + backend/go.sum | 59 ++ backend/internal/agents/ai.go | 322 +++++++ backend/internal/agents/dm.go | 38 + backend/internal/agents/npc.go | 38 + backend/internal/agents/random.go | 10 + backend/internal/config/config.go | 83 ++ backend/internal/database/database.go | 118 +++ backend/internal/handlers/auth.go | 263 ++++++ backend/internal/handlers/games.go | 866 ++++++++++++++++++ backend/internal/handlers/helpers.go | 218 +++++ backend/internal/handlers/scripts.go | 36 + backend/internal/handlers/ws.go | 121 +++ backend/internal/models/models.go | 103 +++ .../scripts/sample_data.json | 0 backend/internal/scripts/scripts.go | 80 ++ backend/internal/util/timeutil.go | 25 + backend/main.go | 71 ++ backend/requirements.txt | 11 - backend/test_npc.py | 29 - start-backend.bat | 8 +- start.bat | 8 +- start_backend.bat | 4 +- 43 files changed, 2498 insertions(+), 1462 deletions(-) create mode 100644 backend/.env.example delete mode 100644 backend/app/__init__.py delete mode 100644 backend/app/agents/__init__.py delete mode 100644 backend/app/agents/ai.py delete mode 100644 backend/app/agents/dm.py delete mode 100644 backend/app/agents/npc.py delete mode 100644 backend/app/config.py delete mode 100644 backend/app/database.py delete mode 100644 backend/app/main.py delete mode 100644 backend/app/models/__init__.py delete mode 100644 backend/app/models/game.py delete mode 100644 backend/app/models/script.py delete mode 100644 backend/app/models/user.py delete mode 100644 backend/app/routers/__init__.py delete mode 100644 backend/app/routers/auth.py delete mode 100644 backend/app/routers/games.py delete mode 100644 backend/app/routers/scripts.py delete mode 100644 backend/app/scripts/__init__.py delete mode 100644 backend/app/scripts/sample.py create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/agents/ai.go create mode 100644 backend/internal/agents/dm.go create mode 100644 backend/internal/agents/npc.go create mode 100644 backend/internal/agents/random.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/database/database.go create mode 100644 backend/internal/handlers/auth.go create mode 100644 backend/internal/handlers/games.go create mode 100644 backend/internal/handlers/helpers.go create mode 100644 backend/internal/handlers/scripts.go create mode 100644 backend/internal/handlers/ws.go create mode 100644 backend/internal/models/models.go rename backend/{app => internal}/scripts/sample_data.json (100%) create mode 100644 backend/internal/scripts/scripts.go create mode 100644 backend/internal/util/timeutil.go create mode 100644 backend/main.go delete mode 100644 backend/requirements.txt delete mode 100644 backend/test_npc.py diff --git a/.gitignore b/.gitignore index a388b99..11e1a4a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ logs/ # Environment / secrets .env .env.* +!.env.example # Binary files *.pdf diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..497c3e6 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,13 @@ +DATABASE_URL=sqlite+aiosqlite:///./mmgame.db +SECRET_KEY=mmgame-dev-secret-key-change-in-production + +AI_BACKEND=rule +# AI_BACKEND=deepseek +# AI_BACKEND=ollama + +DEEPSEEK_API_KEY= +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_MODEL=deepseek-chat + +OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_MODEL=qwen2.5:7b \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/agents/__init__.py b/backend/app/agents/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/agents/ai.py b/backend/app/agents/ai.py deleted file mode 100644 index a0bcbad..0000000 --- a/backend/app/agents/ai.py +++ /dev/null @@ -1,220 +0,0 @@ -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 deleted file mode 100644 index e3b9be0..0000000 --- a/backend/app/agents/dm.py +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index fd979da..0000000 --- a/backend/app/agents/npc.py +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index f7c71cd..0000000 --- a/backend/app/config.py +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index bd482f7..0000000 --- a/backend/app/database.py +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 7003ea9..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/models/game.py b/backend/app/models/game.py deleted file mode 100644 index 35acdf9..0000000 --- a/backend/app/models/game.py +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index e4d147b..0000000 --- a/backend/app/models/script.py +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 26456cb..0000000 --- a/backend/app/models/user.py +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py deleted file mode 100644 index 390e019..0000000 --- a/backend/app/routers/auth.py +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index 13b2940..0000000 --- a/backend/app/routers/games.py +++ /dev/null @@ -1,694 +0,0 @@ -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 deleted file mode 100644 index 54a0735..0000000 --- a/backend/app/routers/scripts.py +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/scripts/sample.py b/backend/app/scripts/sample.py deleted file mode 100644 index 64e7a3d..0000000 --- a/backend/app/scripts/sample.py +++ /dev/null @@ -1,23 +0,0 @@ -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/go.mod b/backend/go.mod new file mode 100644 index 0000000..1f431dc --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,23 @@ +module mmgame + +go 1.26 + +require ( + github.com/go-chi/chi/v5 v5.3.1 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gorilla/websocket v1.5.3 + golang.org/x/crypto v0.54.0 + modernc.org/sqlite v1.55.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..51d2e25 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,59 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/agents/ai.go b/backend/internal/agents/ai.go new file mode 100644 index 0000000..fde4de0 --- /dev/null +++ b/backend/internal/agents/ai.go @@ -0,0 +1,322 @@ +package agents + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + + "mmgame/internal/config" + "mmgame/internal/scripts" +) + +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type AIBackend struct { + backend string +} + +var ai = &AIBackend{backend: config.Cfg.AIBackend} + +func (a *AIBackend) chat(messages []ChatMessage, temperature float64, maxTokens int) string { + switch a.backend { + case "ollama": + return a.ollamaChat(messages, temperature, maxTokens) + case "deepseek": + return a.deepseekChat(messages, temperature, maxTokens) + default: + return "" + } +} + +func (a *AIBackend) ollamaChat(messages []ChatMessage, temperature float64, maxTokens int) string { + body := map[string]interface{}{ + "model": config.Cfg.OllamaModel, + "messages": messages, + "stream": false, + "options": map[string]interface{}{"temperature": temperature, "num_predict": maxTokens}, + } + data, err := json.Marshal(body) + if err != nil { + return "" + } + resp, err := http.Post(config.Cfg.OllamaBaseURL+"/api/chat", "application/json", bytes.NewReader(data)) + if err != nil { + return "" + } + defer resp.Body.Close() + var result struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "" + } + return result.Message.Content +} + +func (a *AIBackend) deepseekChat(messages []ChatMessage, temperature float64, maxTokens int) string { + body := map[string]interface{}{ + "model": config.Cfg.DeepSeekModel, + "messages": messages, + "temperature": temperature, + "max_tokens": maxTokens, + } + data, err := json.Marshal(body) + if err != nil { + return "" + } + req, err := http.NewRequest("POST", strings.TrimSuffix(config.Cfg.DeepSeekBaseURL, "/")+"/v1/chat/completions", bytes.NewReader(data)) + if err != nil { + return "" + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+config.Cfg.DeepSeekAPIKey) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + var result struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "" + } + if len(result.Choices) > 0 { + return result.Choices[0].Message.Content + } + return "" +} + +var dmIntros = map[string]string{ + "hardcore": "欢迎来到这场推理盛宴。真相就藏在你面前的线索之中,仔细观察,大胆推理。", + "emotional": "这是一个关于人心的故事。有时候,真相并不重要,重要的是你在这个过程中感受到了什么。", + "欢乐": "欢迎来到这场欢乐的聚会!记住,每个人都在演戏,但真相只有一个!", + "恐怖": "黑暗中有什么在注视着你……准备好了吗?", +} + +func cleanJSON(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "```json") + s = strings.TrimSuffix(s, "```") + return strings.TrimSpace(s) +} + +func DMGenerate(script *scripts.Script, phaseIndex int, releasedClues []string, context string) map[string]interface{} { + phases := script.Phases + var phase *scripts.Phase + if phaseIndex < len(phases) { + phase = &phases[phaseIndex] + } else { + phase = &phases[len(phases)-1] + } + title := script.Title + scriptType := script.Type + typeIntro := dmIntros[scriptType] + cluesStr := "暂无" + if len(releasedClues) > 0 { + cluesStr = strings.Join(releasedClues, ", ") + } + prompt := "你是一个剧本杀主持人(DM),请以主持人的身份发言。\n" + + "当前剧本: " + title + "\n" + + "当前阶段: " + phase.Name + "\n" + + "阶段描述: " + phase.Description + "\n" + + "已发放线索: " + cluesStr + "\n" + + "上下文: " + context + "\n\n" + + "请简短发言(50字以内),推动剧情发展。" + + result := ai.chat([]ChatMessage{{Role: "user", Content: prompt}}, 0.7, 512) + if result != "" { + cleaned := cleanJSON(result) + var m map[string]interface{} + if err := json.Unmarshal([]byte(cleaned), &m); err == nil { + return m + } + return map[string]interface{}{"action": "narrate", "content": result} + } + + voice := phase.Description + if len(voice) > 80 { + voice = voice[:80] + } + if phaseIndex == 0 && context == "opening" { + bg := script.Background + if len(bg) > 100 { + bg = bg[:100] + } + voice = typeIntro + "\n\n欢迎来到【" + title + "】。" + bg + } + return map[string]interface{}{"action": "narrate", "content": "【" + phase.Name + "】" + voice} +} + +func NPCGenerate(role *scripts.Role, background, phaseName string, recentMessages []string, playerMessage string, isPrivate bool, knownClues []string, phaseGoal string, allRoles []*scripts.Role) map[string]interface{} { + personality := role.Personality + if personality == "" { + personality = role.Name + } + cluesStr := "暂无线索" + if len(knownClues) > 0 { + var last []string + if len(knownClues) > 5 { + last = knownClues[len(knownClues)-5:] + } else { + last = knownClues + } + cluesStr = strings.Join(last, " | ") + } + rolesStr := "" + if len(allRoles) > 0 { + var names []string + for _, r := range allRoles { + if r.ID != role.ID { + names = append(names, r.Name) + } + } + if len(names) > 0 { + rolesStr = "其他玩家: " + strings.Join(names, ", ") + } + } + bg := background + if len(bg) > 80 { + bg = bg[:80] + "..." + } + recent := "" + if len(recentMessages) > 0 { + var last []string + if len(recentMessages) > 4 { + last = recentMessages[len(recentMessages)-4:] + } else { + last = recentMessages + } + recent = strings.Join(last, " | ") + } + goalLine := "" + if phaseGoal != "" { + goalLine = "阶段目标: " + phaseGoal + } + privLine := "" + if isPrivate && playerMessage != "" { + privLine = "【私聊】" + playerMessage + } else if playerMessage != "" { + privLine = "【有人对你说】" + playerMessage + } + + prompt := "你正在扮演一个剧本杀角色。请严格按照角色设定发言。\n\n" + + "角色信息:\n" + + "- 姓名: " + role.Name + "\n" + + "- 公开身份: " + role.PublicProfile + "\n" + + "- 性格: " + personality + "\n" + + "- 你的目标: " + role.Goal + "\n" + + "- 你的秘密: " + role.SecretProfile + "\n\n" + + "当前背景: " + bg + "\n" + + "当前阶段: " + phaseName + "\n" + + goalLine + "\n" + + rolesStr + "\n\n" + + "你已知道的线索:\n" + cluesStr + "\n\n" + + "最近发言:\n" + recent + "\n\n" + + privLine + "\n\n" + + "【发言规则】\n" + + "1. 以角色身份说人话,简短自然(30字以内)\n" + + "2. 你的秘密绝对不能主动说出来\n" + + "3. 被问到时可以撒谎、回避、转移话题\n" + + "4. 结合已公开的线索来推理和回应\n" + + "5. 如果你知道某些线索的真相(如你就是凶手),可以故意误导他人\n\n" + + "直接输出你的角色发言内容,不要JSON、不要解释。" + + result := ai.chat([]ChatMessage{{Role: "user", Content: prompt}}, 0.7, 512) + if result != "" { + cleaned := cleanJSON(result) + var m map[string]interface{} + if err := json.Unmarshal([]byte(cleaned), &m); err == nil { + return m + } + return map[string]interface{}{"action": "speak", "content": result} + } + return map[string]interface{}{"action": "speak", "content": ruleNPCReply(role, playerMessage, isPrivate, knownClues)} +} + +func ruleNPCReply(role *scripts.Role, playerMessage string, isPrivate bool, knownClues []string) string { + name := role.Name + clues := knownClues + + if playerMessage == "" { + if len(clues) > 0 { + replies := []string{ + name + "沉思道:\"这些线索……我觉得需要重新梳理一下。\"", + name + "说:\"我注意到了一些细节,但现在还不方便说。\"", + name + "看了看法医报告:\"时间线和线索对不上,肯定有人撒谎。\"", + } + return replies[randInt(len(replies))] + } + replies := []string{ + name + "环顾四周,若有所思。", + name + "清了清嗓子:\"各位,我觉得我们应该整理一下思路。\"", + name + "沉默地看着大家。", + name + "低声说:\"这件事没有那么简单……\"", + } + return replies[randInt(len(replies))] + } + + if strings.Contains(playerMessage, "凶手") || strings.Contains(playerMessage, "杀人") || strings.Contains(playerMessage, "你杀") { + denials := []string{ + name + "脸色一变:\"你凭什么这么说?证据呢?\"", + name + "冷笑一声:\"如果我是凶手,我还会坐在这里?\"", + name + "摇头:\"我没有理由杀他。\"", + } + return denials[randInt(len(denials))] + } + + if strings.Contains(playerMessage, "时间") || strings.Contains(playerMessage, "在哪") || strings.Contains(playerMessage, "案发") { + times := []string{ + name + "回忆道:\"我当时在……让我想想。\"", + name + "说:\"那段时间我一个人在房间里。\"", + name + "皱眉:\"我不太确定具体时间,但我确实听到了什么声音。\"", + } + return times[randInt(len(times))] + } + + if strings.Contains(playerMessage, "线索") || strings.Contains(playerMessage, "证据") || strings.Contains(playerMessage, "发现") { + clueReplies := []string{ + name + "点头:\"这个线索确实值得注意。\"", + name + "沉思:\"但这个线索也可能是在误导我们。\"", + name + "说:\"我也有一个发现,但还不确定是否相关。\"", + } + return clueReplies[randInt(len(clueReplies))] + } + + if strings.Contains(playerMessage, "知道") || strings.Contains(playerMessage, "秘密") { + secrets := []string{ + name + "回避了你的目光:\"我什么也不知道。\"", + name + "沉默了一会儿:\"每个人都有不想说的秘密,不是吗?\"", + name + "说:\"我只能告诉你,事情不是你看到的那样。\"", + } + return secrets[randInt(len(secrets))] + } + + if strings.Contains(playerMessage, "动机") || strings.Contains(playerMessage, "为什么") || strings.Contains(playerMessage, "目的") { + motives := []string{ + name + "说:\"每个人都有自己的理由,但有些理由……\"", + name + "耸耸肩:\"动机?也许我们需要先搞清楚发生了什么。\"", + name + "看着你:\"你确定你想知道真正的动机?\"", + } + return motives[randInt(len(motives))] + } + + generic := []string{ + name + "思考了一下:\"这个嘛,我说不好。\"", + name + "回答:\"我不太确定,但我觉得我们应该继续调查。\"", + name + "说:\"你说得有一定道理,但可能还有其他可能性。\"", + name + "点头:\"有意思,继续说。\"", + } + return generic[randInt(len(generic))] +} diff --git a/backend/internal/agents/dm.go b/backend/internal/agents/dm.go new file mode 100644 index 0000000..e2d1c39 --- /dev/null +++ b/backend/internal/agents/dm.go @@ -0,0 +1,38 @@ +package agents + +import ( + "fmt" + + "mmgame/internal/scripts" +) + +type DMAgent struct { + Script *scripts.Script + currentPhaseIndex int + releasedClues []string +} + +func NewDMAgent(script *scripts.Script) *DMAgent { + return &DMAgent{Script: script} +} + +func (a *DMAgent) GetOpening() string { + return fmt.Sprintf("欢迎来到【%s】。%s", a.Script.Title, a.Script.Background) +} + +func (a *DMAgent) Act(phaseIndex int, playerProgress, context string) map[string]interface{} { + result := DMGenerate(a.Script, phaseIndex, a.releasedClues, context) + if len(result) > 0 { + return result + } + phases := a.Script.Phases + phase := &phases[len(phases)-1] + if phaseIndex < len(phases) { + phase = &phases[phaseIndex] + } + return map[string]interface{}{"action": "narrate", "content": "【" + phase.Name + "】" + phase.Description} +} + +func (a *DMAgent) GetReplaySummary() string { + return fmt.Sprintf("剧本:%s\n真相:%s", a.Script.Title, a.Script.Truth) +} diff --git a/backend/internal/agents/npc.go b/backend/internal/agents/npc.go new file mode 100644 index 0000000..3f11ca5 --- /dev/null +++ b/backend/internal/agents/npc.go @@ -0,0 +1,38 @@ +package agents + +import ( + "fmt" + + "mmgame/internal/scripts" +) + +type NPCAgent struct { + Role *scripts.Role + Background string + PublicInfo string + memory []ChatMessage +} + +func NewNPCAgent(role *scripts.Role, background, publicInfo string) *NPCAgent { + return &NPCAgent{Role: role, Background: background, PublicInfo: publicInfo} +} + +func (a *NPCAgent) Respond(phaseName string, recentMessages []map[string]string, playerMessage string, isPrivate bool, knownClues []string, phaseGoal string, allRoles []*scripts.Role) map[string]interface{} { + if len(recentMessages) > 5 { + recentMessages = recentMessages[len(recentMessages)-5:] + } + var recent []string + for _, m := range recentMessages { + sender := m["sender_name"] + if sender == "" { + sender = m["sender"] + } + recent = append(recent, fmt.Sprintf("%s: %s", sender, m["content"])) + } + result := NPCGenerate(a.Role, a.Background, phaseName, recent, playerMessage, isPrivate, knownClues, phaseGoal, allRoles) + if content, ok := result["content"].(string); ok && content != "" { + a.memory = append(a.memory, ChatMessage{Role: "assistant", Content: content}) + return result + } + return map[string]interface{}{"content": a.Role.Name + "陷入了沉思……", "action": "speak"} +} diff --git a/backend/internal/agents/random.go b/backend/internal/agents/random.go new file mode 100644 index 0000000..2652d88 --- /dev/null +++ b/backend/internal/agents/random.go @@ -0,0 +1,10 @@ +package agents + +import "math/rand" + +func randInt(n int) int { + if n <= 0 { + return 0 + } + return rand.Intn(n) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..2b7d698 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,83 @@ +package config + +import ( + "bufio" + "os" + "strconv" + "strings" +) + +type Config struct { + DatabaseURL string + SecretKey string + Algorithm string + AccessTokenExpireMinutes int + + AIBackend string + DeepSeekAPIKey string + DeepSeekBaseURL string + DeepSeekModel string + OllamaBaseURL string + OllamaModel string +} + +var Cfg = load() + +func loadDotEnv() { + f, err := os.Open(".env") + if err != nil { + return + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.Index(line, "=") + if idx <= 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.Trim(strings.TrimSpace(line[idx+1:]), "\"'") + if _, ok := os.LookupEnv(key); !ok { + os.Setenv(key, val) + } + } +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func getenvInt(key string, def int) int { + v := os.Getenv(key) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} + +func load() *Config { + loadDotEnv() + return &Config{ + DatabaseURL: getenv("DATABASE_URL", "sqlite+aiosqlite:///./mmgame.db"), + SecretKey: getenv("SECRET_KEY", "mmgame-dev-secret-key-change-in-production"), + Algorithm: getenv("ALGORITHM", "HS256"), + AccessTokenExpireMinutes: getenvInt("ACCESS_TOKEN_EXPIRE_MINUTES", 60*24*7), + AIBackend: getenv("AI_BACKEND", "rule"), + DeepSeekAPIKey: getenv("DEEPSEEK_API_KEY", ""), + DeepSeekBaseURL: getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"), + DeepSeekModel: getenv("DEEPSEEK_MODEL", "deepseek-chat"), + OllamaBaseURL: getenv("OLLAMA_BASE_URL", "http://localhost:11434"), + OllamaModel: getenv("OLLAMA_MODEL", "qwen2.5:7b"), + } +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go new file mode 100644 index 0000000..9f275aa --- /dev/null +++ b/backend/internal/database/database.go @@ -0,0 +1,118 @@ +package database + +import ( + "database/sql" + "strings" + + _ "modernc.org/sqlite" + + "mmgame/internal/config" +) + +var DB *sql.DB + +func normalizeDSN(url string) string { + if url == "" { + return "mmgame.db" + } + for _, prefix := range []string{"sqlite+aiosqlite:///", "sqlite:///"} { + if strings.HasPrefix(url, prefix) { + path := strings.TrimPrefix(url, prefix) + if path == ":memory:" { + return "file::memory:?cache=shared" + } + return path + } + } + return url +} + +func Init() error { + dsn := normalizeDSN(config.Cfg.DatabaseURL) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return err + } + db.SetMaxOpenConns(1) + if err := db.Ping(); err != nil { + return err + } + DB = db + return createTables() +} + +func createTables() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(36) PRIMARY KEY, + email VARCHAR(255) UNIQUE, + nickname VARCHAR(50), + avatar_url TEXT, + password_hash VARCHAR(255), + role VARCHAR(20) DEFAULT 'player', + is_guest BOOLEAN DEFAULT 0, + game_count INTEGER DEFAULT 0, + token_version INTEGER DEFAULT 0, + created_at TEXT + )`, + `CREATE TABLE IF NOT EXISTS game_sessions ( + id VARCHAR(36) PRIMARY KEY, + script_id VARCHAR(36), + status VARCHAR(20) DEFAULT 'waiting', + phase VARCHAR(100) DEFAULT '', + phase_index INTEGER DEFAULT 0, + started_at TEXT, + completed_at TEXT, + config TEXT, + created_at TEXT + )`, + `CREATE TABLE IF NOT EXISTS session_players ( + id VARCHAR(36) PRIMARY KEY, + session_id VARCHAR(36), + user_id VARCHAR(36), + role_id VARCHAR(100), + role_name VARCHAR(100) DEFAULT '', + is_human BOOLEAN DEFAULT 1, + is_ready BOOLEAN DEFAULT 0, + avatar_url TEXT, + status VARCHAR(20) DEFAULT 'active', + joined_at TEXT + )`, + `CREATE TABLE IF NOT EXISTS chat_messages ( + id VARCHAR(36) PRIMARY KEY, + session_id VARCHAR(36), + sender_role_id VARCHAR(100), + sender_name VARCHAR(100) DEFAULT '', + message_type VARCHAR(20) DEFAULT 'public', + content TEXT, + target_role_id VARCHAR(100), + clue_id VARCHAR(100), + phase_index INTEGER DEFAULT 0, + created_at TEXT + )`, + `CREATE TABLE IF NOT EXISTS clues_state ( + id VARCHAR(36) PRIMARY KEY, + session_id VARCHAR(36), + clue_id VARCHAR(100), + status VARCHAR(20) DEFAULT 'unreleased', + revealed_by VARCHAR(100), + released_at TEXT, + created_at TEXT + )`, + `CREATE TABLE IF NOT EXISTS votes ( + id VARCHAR(36) PRIMARY KEY, + session_id VARCHAR(36), + round INTEGER DEFAULT 1, + voter_id VARCHAR(36), + target_id VARCHAR(36), + reason TEXT, + created_at TEXT + )`, + } + for _, s := range stmts { + if _, err := DB.Exec(s); err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go new file mode 100644 index 0000000..4cf3bfc --- /dev/null +++ b/backend/internal/handlers/auth.go @@ -0,0 +1,263 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + + "mmgame/internal/config" + "mmgame/internal/database" + "mmgame/internal/models" + "mmgame/internal/util" +) + +var errUnauthorized = errors.New("unauthorized") + +func createToken(userID string) (string, error) { + exp := time.Now().Add(time.Duration(config.Cfg.AccessTokenExpireMinutes) * time.Minute) + claims := jwt.MapClaims{ + "sub": userID, + "exp": exp.Unix(), + "v": 0, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(config.Cfg.SecretKey)) +} + +func currentUser(r *http.Request) (*models.User, error) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return nil, errUnauthorized + } + tokenStr := strings.TrimPrefix(auth, "Bearer ") + claims := jwt.MapClaims{} + token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { + return []byte(config.Cfg.SecretKey), nil + }, jwt.WithValidMethods([]string{config.Cfg.Algorithm})) + if err != nil || !token.Valid { + return nil, errUnauthorized + } + userID, _ := claims["sub"].(string) + if userID == "" { + return nil, errUnauthorized + } + row := database.DB.QueryRow( + `SELECT id, email, nickname, avatar_url, password_hash, role, is_guest, game_count, token_version, created_at + FROM users WHERE id = ?`, userID) + var u models.User + var email, nickname, avatarURL, passwordHash, createdAt sql.NullString + if err := row.Scan(&u.ID, &email, &nickname, &avatarURL, &passwordHash, &u.Role, + &u.IsGuest, &u.GameCount, &u.TokenVersion, &createdAt); err != nil { + return nil, errUnauthorized + } + if email.Valid { + u.Email = &email.String + } + if nickname.Valid { + u.Nickname = &nickname.String + } + if avatarURL.Valid { + u.AvatarURL = &avatarURL.String + } + if passwordHash.Valid { + u.PasswordHash = &passwordHash.String + } + u.CreatedAt = util.ParseTime(createdAt.String) + return &u, nil +} + +func Register(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + Password string `json:"password"` + Nickname string `json:"nickname"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 422, "请求格式错误") + return + } + var existingID string + err := database.DB.QueryRow(`SELECT id FROM users WHERE email = ?`, req.Email).Scan(&existingID) + if err == nil { + writeError(w, 400, "邮箱已注册") + return + } + if err != sql.ErrNoRows { + writeError(w, 500, "服务器错误") + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + writeError(w, 500, "服务器错误") + return + } + userID := newID() + _, err = database.DB.Exec( + `INSERT INTO users (id, email, nickname, password_hash, role, is_guest, game_count, token_version, created_at) + VALUES (?, ?, ?, ?, ?, 0, 0, 0, ?)`, + userID, req.Email, req.Nickname, string(hash), models.RolePlayer, util.NowStr()) + if err != nil { + writeError(w, 500, "服务器错误") + return + } + token, _ := createToken(userID) + writeJSON(w, 200, map[string]interface{}{ + "token": token, + "user_id": userID, + "nickname": req.Nickname, + "is_guest": false, + }) +} + +func Login(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 422, "请求格式错误") + return + } + var id, email, nickname, passwordHash string + err := database.DB.QueryRow( + `SELECT id, email, nickname, password_hash FROM users WHERE email = ?`, req.Email). + Scan(&id, &email, &nickname, &passwordHash) + if err == sql.ErrNoRows || passwordHash == "" { + writeError(w, 401, "邮箱或密码错误") + return + } + if err != nil { + writeError(w, 500, "服务器错误") + return + } + if bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)) != nil { + writeError(w, 401, "邮箱或密码错误") + return + } + token, _ := createToken(id) + writeJSON(w, 200, map[string]interface{}{ + "token": token, + "user_id": id, + "nickname": nickname, + "is_guest": false, + }) +} + +func GuestLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + Nickname string `json:"nickname"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if req.Nickname == "" { + req.Nickname = "游客" + } + userID := newID() + _, err := database.DB.Exec( + `INSERT INTO users (id, nickname, role, is_guest, game_count, token_version, created_at) + VALUES (?, ?, ?, 1, 0, 0, ?)`, + userID, req.Nickname, models.RolePlayer, util.NowStr()) + if err != nil { + writeError(w, 500, "服务器错误") + return + } + token, _ := createToken(userID) + writeJSON(w, 200, map[string]interface{}{ + "token": token, + "user_id": userID, + "nickname": req.Nickname, + "is_guest": true, + }) +} + +func Logout(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]string{"status": "ok"}) +} + +func GetProfile(w http.ResponseWriter, r *http.Request) { + user, err := currentUser(r) + if err != nil { + writeError(w, 401, "未登录") + return + } + writeJSON(w, 200, map[string]interface{}{ + "user_id": user.ID, + "email": nullStr(user.Email), + "nickname": nullStr(user.Nickname), + "avatar_url": nullStr(user.AvatarURL), + "is_guest": user.IsGuest, + "role": user.Role, + "game_count": user.GameCount, + "created_at": util.TimeStr(user.CreatedAt), + }) +} + +func UpdateProfile(w http.ResponseWriter, r *http.Request) { + user, err := currentUser(r) + if err != nil { + writeError(w, 401, "未登录") + return + } + var data map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&data) + + var nickname, avatarURL *string + if v, ok := data["nickname"].(string); ok { + nickname = &v + } + if v, ok := data["avatar_url"].(string); ok { + avatarURL = &v + } + if nickname != nil { + if _, err := database.DB.Exec(`UPDATE users SET nickname = ? WHERE id = ?`, *nickname, user.ID); err != nil { + writeError(w, 500, "服务器错误") + return + } + } + if avatarURL != nil { + if _, err := database.DB.Exec(`UPDATE users SET avatar_url = ? WHERE id = ?`, *avatarURL, user.ID); err != nil { + writeError(w, 500, "服务器错误") + return + } + } + writeJSON(w, 200, map[string]string{"status": "ok"}) +} + +func GetHistory(w http.ResponseWriter, r *http.Request) { + if _, err := currentUser(r); err != nil { + writeError(w, 401, "未登录") + return + } + rows, err := database.DB.Query( + `SELECT id, script_id, status, phase, phase_index, started_at, completed_at, created_at + FROM game_sessions ORDER BY created_at DESC LIMIT 50`) + if err != nil { + writeError(w, 500, "服务器错误") + return + } + defer rows.Close() + var items []map[string]interface{} + for rows.Next() { + var id, scriptID, status, phase, startedAt, completedAt, createdAt sql.NullString + var phaseIndex int + if err := rows.Scan(&id, &scriptID, &status, &phase, &phaseIndex, &startedAt, &completedAt, &createdAt); err != nil { + continue + } + items = append(items, map[string]interface{}{ + "id": id.String, + "script_id": scriptID.String, + "status": status.String, + "phase": phase.String, + "phase_index": phaseIndex, + "started_at": startedAt.String, + "completed_at": completedAt.String, + "created_at": createdAt.String, + }) + } + writeJSON(w, 200, items) +} diff --git a/backend/internal/handlers/games.go b/backend/internal/handlers/games.go new file mode 100644 index 0000000..679cf4c --- /dev/null +++ b/backend/internal/handlers/games.go @@ -0,0 +1,866 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "fmt" + "math/rand" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "mmgame/internal/agents" + "mmgame/internal/database" + "mmgame/internal/models" + "mmgame/internal/scripts" + "mmgame/internal/util" +) + +func CreateGame(w http.ResponseWriter, r *http.Request) { + var req struct { + ScriptID string `json:"script_id"` + HumanRoleID string `json:"human_role_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 422, "请求格式错误") + return + } + script := scripts.GetByID(req.ScriptID) + if script == nil { + writeError(w, 404, "剧本不存在") + return + } + gameID := newID() + phase := "" + if len(script.Phases) > 0 { + phase = script.Phases[0].Name + } + configJSON, _ := json.Marshal(map[string]interface{}{"script": script}) + _, err := database.DB.Exec( + `INSERT INTO game_sessions (id, script_id, status, phase, phase_index, config, created_at) + VALUES (?, ?, ?, ?, 0, ?, ?)`, + gameID, req.ScriptID, models.GameStatusWaiting, phase, string(configJSON), util.NowStr()) + if err != nil { + writeError(w, 500, "服务器错误") + return + } + + for _, role := range script.Roles { + isHuman := role.ID == req.HumanRoleID + if _, err := database.DB.Exec( + `INSERT INTO session_players (id, session_id, role_id, role_name, is_human, is_ready, avatar_url, status, joined_at) + VALUES (?, ?, ?, ?, ?, 0, '', ?, ?)`, + newID(), gameID, role.ID, role.Name, isHuman, models.PlayerStatusActive, util.NowStr()); err != nil { + writeError(w, 500, "服务器错误") + return + } + } + + if len(script.Phases) > 0 { + for _, clueID := range script.Phases[0].Clues { + if _, err := database.DB.Exec( + `INSERT INTO clues_state (id, session_id, clue_id, status, created_at) + VALUES (?, ?, ?, 'released', ?)`, + newID(), gameID, clueID, util.NowStr()); err != nil { + writeError(w, 500, "服务器错误") + return + } + } + } + + writeJSON(w, 200, map[string]string{"game_id": gameID}) +} + +func JoinGame(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "game_id") + var req struct { + RoleID string `json:"role_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 422, "请求格式错误") + return + } + var exists int + err := database.DB.QueryRow(`SELECT 1 FROM game_sessions WHERE id = ?`, gameID).Scan(&exists) + if err != nil { + writeError(w, 404, "游戏不存在") + return + } + + playerID := newID() + if _, err := database.DB.Exec( + `INSERT INTO session_players (id, session_id, role_id, is_human, is_ready, status, joined_at) + VALUES (?, ?, ?, 1, 0, ?, ?)`, + playerID, gameID, req.RoleID, models.PlayerStatusActive, util.NowStr()); err != nil { + writeError(w, 500, "服务器错误") + return + } + + var scriptID string + _ = database.DB.QueryRow(`SELECT script_id FROM game_sessions WHERE id = ?`, gameID).Scan(&scriptID) + script := scripts.GetByID(scriptID) + if script != nil { + for _, role := range script.Roles { + if role.ID == req.RoleID { + writeJSON(w, 200, map[string]interface{}{ + "player_id": playerID, + "role_id": req.RoleID, + "role_name": role.Name, + "public_profile": role.PublicProfile, + "secret_profile": role.SecretProfile, + "secret": role.Secret, + "goal": role.Goal, + }) + return + } + } + } + writeJSON(w, 200, map[string]interface{}{"player_id": playerID, "role_id": req.RoleID}) +} + +func StartGame(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "game_id") + game, err := getGame(gameID) + if err != nil { + writeError(w, 404, "游戏不存在") + return + } + + _, err = database.DB.Exec( + `UPDATE game_sessions SET status = ?, started_at = ? WHERE id = ?`, + models.GameStatusPlaying, util.NowStr(), gameID) + if err != nil { + writeError(w, 500, "服务器错误") + return + } + + script := scriptFromConfig(game.Config) + background := "" + title := "" + if script != nil { + background = script.Background + title = script.Title + } + content := fmt.Sprintf("欢迎来到【%s】。\n%s\n\n请各位玩家阅读角色信息,做好准备后,在公屏中发送「准备好了」开始游戏。", title, background) + msgID := newID() + now := util.NowStr() + if _, err := database.DB.Exec( + `INSERT INTO chat_messages (id, session_id, sender_role_id, sender_name, message_type, content, phase_index, created_at) + VALUES (?, ?, 'dm', 'DM', ?, ?, ?, ?)`, + msgID, gameID, models.MessageTypeSystem, content, game.PhaseIndex, now); err != nil { + writeError(w, 500, "服务器错误") + return + } + + broadcast(gameID, map[string]interface{}{ + "type": "game_started", + "data": map[string]interface{}{ + "phase": game.Phase, + "phase_index": game.PhaseIndex, + "message": map[string]interface{}{ + "id": msgID, + "sender_name": "DM", + "sender_role_id": "dm", + "message_type": models.MessageTypeSystem, + "content": content, + "created_at": now, + }, + }, + }) + writeJSON(w, 200, map[string]interface{}{"status": "started", "phase": game.Phase}) +} + +type npcResponse struct { + msg *models.ChatMessage + broadcast map[string]interface{} +} + +func generateNPCResponses(gameID string, senderRoleID, content, msgType string, targetRoleID *string, script *scripts.Script, phaseName string, phaseIndex int) []npcResponse { + var responses []npcResponse + if script == nil { + return responses + } + + var npcRoles []*scripts.Role + for i := range script.Roles { + if script.Roles[i].ID != senderRoleID { + npcRoles = append(npcRoles, &script.Roles[i]) + } + } + rand.Shuffle(len(npcRoles), func(i, j int) { npcRoles[i], npcRoles[j] = npcRoles[j], npcRoles[i] }) + if len(npcRoles) == 0 { + return responses + } + + recentMsgs := queryMessages(gameID) + recentList := []map[string]string{} + if len(recentMsgs) > 10 { + recentMsgs = recentMsgs[len(recentMsgs)-10:] + } + for _, m := range recentMsgs { + recentList = append(recentList, map[string]string{"sender_name": m.SenderName, "content": m.Content}) + } + + knownClues := []string{} + for _, c := range queryClues(gameID) { + if c.Status == "released" && script != nil { + for _, sc := range script.Clues { + if sc.ID == c.ClueID { + knownClues = append(knownClues, sc.Description) + break + } + } + } + } + + var phaseGoal string + if phaseIndex < len(script.Phases) { + phaseGoal = script.Phases[phaseIndex].Description + } + + allRoles := []*scripts.Role{} + for i := range script.Roles { + allRoles = append(allRoles, &script.Roles[i]) + } + + count := 2 + if len(npcRoles) < count { + count = len(npcRoles) + } + for _, npcRole := range npcRoles[:count] { + agent := agents.NewNPCAgent(npcRole, script.Background, "") + isPrivate := msgType == "private" && targetRoleID != nil && *targetRoleID == npcRole.ID + response := agent.Respond(phaseName, recentList, content, isPrivate, knownClues, phaseGoal, allRoles) + reply, _ := response["content"].(string) + action, _ := response["action"].(string) + + if reply == "" || action == "remain_silent" { + continue + } + + var npcTarget *string + if isPrivate { + npcTarget = &senderRoleID + } + npcMsgType := models.MessageTypePublic + if isPrivate { + npcMsgType = models.MessageTypePrivate + } + now := util.NowStr() + npcMsg := &models.ChatMessage{ + ID: newID(), + SessionID: gameID, + SenderRoleID: npcRole.ID, + SenderName: npcRole.Name, + MessageType: npcMsgType, + Content: reply, + TargetRoleID: npcTarget, + PhaseIndex: phaseIndex, + CreatedAt: util.ParseTime(now), + } + responses = append(responses, npcResponse{ + msg: npcMsg, + broadcast: map[string]interface{}{ + "type": "chat_message", + "data": map[string]interface{}{ + "id": npcMsg.ID, + "sender_role_id": npcRole.ID, + "sender_name": npcRole.Name, + "message_type": npcMsgType, + "content": reply, + "target_role_id": npcTarget, + "phase_index": phaseIndex, + "created_at": now, + }, + }, + }) + } + return responses +} + +func SendChat(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "game_id") + var req struct { + Type string `json:"type"` + Content string `json:"content"` + TargetRoleID *string `json:"target_role_id"` + SenderRoleID string `json:"sender_role_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 422, "请求格式错误") + return + } + game, err := getGame(gameID) + if err != nil { + writeError(w, 404, "游戏不存在") + return + } + + senderName := req.SenderRoleID + script := scriptFromConfig(game.Config) + if script != nil { + for _, role := range script.Roles { + if role.ID == req.SenderRoleID { + senderName = role.Name + break + } + } + } + + msgType := req.Type + if msgType == "" { + msgType = models.MessageTypePublic + } + msgID := newID() + now := util.NowStr() + if _, err := database.DB.Exec( + `INSERT INTO chat_messages (id, session_id, sender_role_id, sender_name, message_type, content, target_role_id, phase_index, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + msgID, gameID, req.SenderRoleID, senderName, msgType, req.Content, req.TargetRoleID, game.PhaseIndex, now); err != nil { + writeError(w, 500, "服务器错误") + return + } + + npcResponses := generateNPCResponses(gameID, req.SenderRoleID, req.Content, msgType, req.TargetRoleID, script, game.Phase, game.PhaseIndex) + for _, n := range npcResponses { + if n.msg != nil { + if _, err := database.DB.Exec( + `INSERT INTO chat_messages (id, session_id, sender_role_id, sender_name, message_type, content, target_role_id, phase_index, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + n.msg.ID, n.msg.SessionID, n.msg.SenderRoleID, n.msg.SenderName, n.msg.MessageType, + n.msg.Content, n.msg.TargetRoleID, n.msg.PhaseIndex, util.TimeStr(n.msg.CreatedAt)); err != nil { + continue + } + } + } + + msgData := map[string]interface{}{ + "type": "chat_message", + "data": map[string]interface{}{ + "id": msgID, + "sender_role_id": req.SenderRoleID, + "sender_name": senderName, + "message_type": msgType, + "content": req.Content, + "target_role_id": req.TargetRoleID, + "phase_index": game.PhaseIndex, + "created_at": now, + }, + } + + if msgType == "private" && req.TargetRoleID != nil { + broadcastToRole(gameID, req.SenderRoleID, msgData) + broadcastToRole(gameID, *req.TargetRoleID, msgData) + } else { + broadcast(gameID, msgData) + } + + for _, n := range npcResponses { + b := n.broadcast + data, _ := b["data"].(map[string]interface{}) + if data != nil { + if data["message_type"] == "private" { + target, _ := data["target_role_id"].(string) + if target != "" { + broadcastToRole(gameID, req.SenderRoleID, b) + broadcastToRole(gameID, target, b) + continue + } + } + } + broadcast(gameID, b) + } + + npcList := []map[string]interface{}{} + for _, n := range npcResponses { + npcList = append(npcList, n.broadcast) + } + writeJSON(w, 200, map[string]interface{}{ + "status": "ok", + "message_id": msgID, + "npc_responses": npcList, + }) +} + +func AdvancePhase(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "game_id") + game, err := getGame(gameID) + if err != nil { + writeError(w, 404, "游戏不存在") + return + } + + script := scriptFromConfig(game.Config) + var phases []scripts.Phase + if script != nil { + phases = script.Phases + } + nextIdx := game.PhaseIndex + 1 + if nextIdx >= len(phases) { + writeJSON(w, 200, map[string]string{"status": "already_at_end"}) + return + } + + _, err = database.DB.Exec( + `UPDATE game_sessions SET phase_index = ?, phase = ? WHERE id = ?`, + nextIdx, phases[nextIdx].Name, gameID) + if err != nil { + writeError(w, 500, "服务器错误") + return + } + + phase := &phases[nextIdx] + content := fmt.Sprintf("【%s】\n%s\n\n%s", phase.Name, phase.Description, phase.PublicInfo) + msgID := newID() + now := util.NowStr() + if _, err := database.DB.Exec( + `INSERT INTO chat_messages (id, session_id, sender_role_id, sender_name, message_type, content, phase_index, created_at) + VALUES (?, ?, 'dm', 'DM', ?, ?, ?, ?)`, + msgID, gameID, models.MessageTypeSystem, content, nextIdx, now); err != nil { + writeError(w, 500, "服务器错误") + return + } + + cluesToRelease := phase.Clues + for _, clueID := range cluesToRelease { + var existingID string + err := database.DB.QueryRow( + `SELECT id FROM clues_state WHERE session_id = ? AND clue_id = ?`, gameID, clueID).Scan(&existingID) + if err == sql.ErrNoRows { + if _, err := database.DB.Exec( + `INSERT INTO clues_state (id, session_id, clue_id, status, created_at) + VALUES (?, ?, ?, 'released', ?)`, + newID(), gameID, clueID, util.NowStr()); err != nil { + continue + } + } + } + + broadcast(gameID, map[string]interface{}{ + "type": "phase_change", + "data": map[string]interface{}{ + "phase": phase.Name, + "phase_index": nextIdx, + "message": map[string]interface{}{ + "id": msgID, + "sender_name": "DM", + "sender_role_id": "dm", + "message_type": models.MessageTypeSystem, + "content": content, + "created_at": now, + }, + "new_clues": cluesToRelease, + }, + }) + writeJSON(w, 200, map[string]interface{}{ + "status": "ok", + "phase": phase.Name, + "phase_index": nextIdx, + }) +} + +func RevealClue(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "game_id") + clueID := chi.URLParam(r, "clue_id") + + var stateID string + err := database.DB.QueryRow( + `SELECT id FROM clues_state WHERE session_id = ? AND clue_id = ?`, gameID, clueID).Scan(&stateID) + if err == sql.ErrNoRows { + writeError(w, 404, "线索不存在或未发放") + return + } + if err != nil { + writeError(w, 500, "服务器错误") + return + } + if _, err := database.DB.Exec(`UPDATE clues_state SET status = 'revealed' WHERE id = ?`, stateID); err != nil { + writeError(w, 500, "服务器错误") + return + } + + game, _ := getGame(gameID) + script := scriptFromConfig(game.Config) + var clueInfo interface{} + if script != nil { + for _, c := range script.Clues { + if c.ID == clueID { + clueInfo = c + break + } + } + } + + broadcast(gameID, map[string]interface{}{ + "type": "clue_revealed", + "data": map[string]interface{}{"clue_id": clueID, "clue": clueInfo}, + }) + writeJSON(w, 200, map[string]interface{}{"status": "revealed", "clue": clueInfo}) +} + +func SubmitVote(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "game_id") + var req struct { + TargetRoleID string `json:"target_role_id"` + Reason string `json:"reason"` + VoterRoleID string `json:"voter_role_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 422, "请求格式错误") + return + } + + var voterID, voterName string + err := database.DB.QueryRow( + `SELECT id, role_name FROM session_players WHERE session_id = ? AND role_id = ?`, + gameID, req.VoterRoleID).Scan(&voterID, &voterName) + if err != nil { + writeError(w, 404, "投票者不存在") + return + } + + var targetID string + err = database.DB.QueryRow( + `SELECT id FROM session_players WHERE session_id = ? AND role_id = ?`, + gameID, req.TargetRoleID).Scan(&targetID) + if err != nil { + writeError(w, 404, "投票目标不存在") + return + } + + if _, err := database.DB.Exec( + `INSERT INTO votes (id, session_id, round, voter_id, target_id, reason, created_at) + VALUES (?, ?, 1, ?, ?, ?, ?)`, + newID(), gameID, voterID, targetID, req.Reason, util.NowStr()); err != nil { + writeError(w, 500, "服务器错误") + return + } + + broadcast(gameID, map[string]interface{}{ + "type": "vote_cast", + "data": map[string]interface{}{ + "voter_role_id": req.VoterRoleID, + "target_role_id": req.TargetRoleID, + "voter_name": voterName, + }, + }) + writeJSON(w, 200, map[string]string{"status": "ok"}) +} + +func EndVote(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "game_id") + game, err := getGame(gameID) + if err != nil { + writeError(w, 404, "游戏不存在") + return + } + + script := scriptFromConfig(game.Config) + var allClues map[string]scripts.Clue + var roleByID map[string]*scripts.Role + if script != nil { + allClues = map[string]scripts.Clue{} + for _, c := range script.Clues { + allClues[c.ID] = c + } + roleByID = map[string]*scripts.Role{} + for i := range script.Roles { + roleByID[script.Roles[i].ID] = &script.Roles[i] + } + } + + existingVotes := queryVotes(gameID) + existingVoterIDs := map[string]bool{} + for _, v := range existingVotes { + existingVoterIDs[v.VoterID] = true + } + + playersList := queryPlayers(gameID) + playersByID := map[string]*models.SessionPlayer{} + for _, p := range playersList { + playersByID[p.ID] = p + } + + releasedClueIDs := map[string]bool{} + for _, c := range queryClues(gameID) { + if c.Status == "released" { + releasedClueIDs[c.ClueID] = true + } + } + knownClues := []string{} + if allClues != nil { + for cid := range releasedClueIDs { + if c, ok := allClues[cid]; ok { + knownClues = append(knownClues, c.Description) + } + } + } + + var npcPlayers []*models.SessionPlayer + for _, p := range playersList { + if !p.IsHuman && !existingVoterIDs[p.ID] { + npcPlayers = append(npcPlayers, p) + } + } + + if script != nil { + for _, npc := range npcPlayers { + var targets []*models.SessionPlayer + for _, p := range playersList { + if p.ID != npc.ID { + targets = append(targets, p) + } + } + if len(targets) == 0 { + continue + } + + var targetID string + if npcRole, ok := roleByID[npc.RoleID]; ok { + agent := agents.NewNPCAgent(npcRole, script.Background, "") + voteReason := agent.Respond("投票阶段", []map[string]string{}, "请投票选出你最怀疑的人,并说明理由", false, knownClues, "", nil) + voteContent, _ := voteReason["content"].(string) + targetID = targets[rand.Intn(len(targets))].ID + for _, t := range targets { + if t.RoleName != "" && strings.Contains(voteContent, t.RoleName) { + targetID = t.ID + break + } + } + } else { + targetID = targets[rand.Intn(len(targets))].ID + } + + if _, err := database.DB.Exec( + `INSERT INTO votes (id, session_id, round, voter_id, target_id, reason, created_at) + VALUES (?, ?, 1, ?, ?, '', ?)`, + newID(), gameID, npc.ID, targetID, util.NowStr()); err != nil { + continue + } + + targetPlayer := playersByID[targetID] + if targetPlayer != nil { + broadcast(gameID, map[string]interface{}{ + "type": "vote_cast", + "data": map[string]interface{}{ + "voter_role_id": npc.RoleID, + "voter_name": npc.RoleName, + "target_role_id": targetPlayer.RoleID, + "target_name": targetPlayer.RoleName, + }, + }) + } + } + } + + votes := queryVotes(gameID) + tally := map[string][]string{} + for _, v := range votes { + targetRole := "unknown" + if p, ok := playersByID[v.TargetID]; ok { + targetRole = p.RoleID + } + voterRole := "unknown" + if p, ok := playersByID[v.VoterID]; ok { + voterRole = p.RoleID + } + tally[targetRole] = append(tally[targetRole], voterRole) + } + + maxVotes := 0 + accusedRoleID := "" + for roleID, voters := range tally { + if len(voters) > maxVotes { + maxVotes = len(voters) + accusedRoleID = roleID + } + } + + accusedName := accusedRoleID + if script != nil { + for _, role := range script.Roles { + if role.ID == accusedRoleID { + accusedName = role.Name + break + } + } + } + truth := "" + if script != nil { + truth = script.Truth + } + + if _, err := database.DB.Exec( + `UPDATE game_sessions SET status = ?, completed_at = ? WHERE id = ?`, + models.GameStatusCompleted, util.NowStr(), gameID); err != nil { + writeError(w, 500, "服务器错误") + return + } + + content := fmt.Sprintf("投票结束!最多票指向:%s(%d票)\n\n真相:%s", accusedName, maxVotes, truth) + msgID := newID() + now := util.NowStr() + if _, err := database.DB.Exec( + `INSERT INTO chat_messages (id, session_id, sender_role_id, sender_name, message_type, content, phase_index, created_at) + VALUES (?, ?, 'dm', 'DM', ?, ?, ?, ?)`, + msgID, gameID, models.MessageTypeSystem, content, game.PhaseIndex, now); err != nil { + writeError(w, 500, "服务器错误") + return + } + + broadcast(gameID, map[string]interface{}{ + "type": "vote_result", + "data": map[string]interface{}{ + "tally": tally, + "accused_role_id": accusedRoleID, + "accused_name": accusedName, + "truth": truth, + "game_id": gameID, + "message": map[string]interface{}{ + "id": msgID, + "sender_name": "DM", + "sender_role_id": "dm", + "message_type": models.MessageTypeSystem, + "content": content, + "created_at": now, + }, + }, + }) + + removeGameConns(gameID) + + writeJSON(w, 200, map[string]interface{}{ + "status": "completed", + "accused": accusedName, + "truth": truth, + }) +} + +func GetGameState(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "game_id") + game, err := getGame(gameID) + if err != nil { + writeError(w, 404, "游戏不存在") + return + } + + players := queryPlayers(gameID) + clues := queryClues(gameID) + msgs := queryMessages(gameID) + script := scriptFromConfig(game.Config) + + playersOut := []map[string]interface{}{} + for _, p := range players { + playersOut = append(playersOut, map[string]interface{}{ + "id": p.ID, + "role_id": p.RoleID, + "role_name": p.RoleName, + "is_human": p.IsHuman, + "status": p.Status, + }) + } + cluesOut := []map[string]interface{}{} + for _, c := range clues { + cluesOut = append(cluesOut, map[string]interface{}{"clue_id": c.ClueID, "status": c.Status}) + } + msgsOut := []map[string]interface{}{} + for _, m := range msgs { + msgsOut = append(msgsOut, messageDict(m)) + } + + var scriptOut interface{} + if script != nil { + scriptOut = script + } else { + scriptOut = map[string]interface{}{} + } + + writeJSON(w, 200, map[string]interface{}{ + "id": game.ID, + "script_id": game.ScriptID, + "status": game.Status, + "phase": game.Phase, + "phase_index": game.PhaseIndex, + "script": scriptOut, + "players": playersOut, + "clues": cluesOut, + "messages": msgsOut, + }) +} + +func GetReplay(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "game_id") + game, err := getGame(gameID) + if err != nil { + writeError(w, 404, "游戏不存在") + return + } + + msgs := queryMessages(gameID) + votes := queryVotes(gameID) + players := map[string]*models.SessionPlayer{} + for _, p := range queryPlayers(gameID) { + players[p.ID] = p + } + script := scriptFromConfig(game.Config) + + scriptTitle := game.ScriptID + truth := "" + var rolesOut []map[string]interface{} + if script != nil { + scriptTitle = script.Title + truth = script.Truth + for _, role := range script.Roles { + rolesOut = append(rolesOut, map[string]interface{}{ + "id": role.ID, + "name": role.Name, + "publicProfile": role.PublicProfile, + "secretProfile": role.SecretProfile, + "goal": role.Goal, + }) + } + } + + msgsOut := []map[string]interface{}{} + for _, m := range msgs { + msgsOut = append(msgsOut, map[string]interface{}{ + "id": m.ID, + "sender_name": m.SenderName, + "content": m.Content, + "type": m.MessageType, + "time": util.TimeStr(m.CreatedAt), + }) + } + voteDetails := []map[string]interface{}{} + for _, v := range votes { + voter := players[v.VoterID] + target := players[v.TargetID] + voterName := "unknown" + if voter != nil { + voterName = voter.RoleName + } + targetName := "unknown" + if target != nil { + targetName = target.RoleName + } + reason := "" + if v.Reason != nil { + reason = *v.Reason + } + voteDetails = append(voteDetails, map[string]interface{}{ + "voter_name": voterName, + "target_name": targetName, + "reason": reason, + }) + } + + writeJSON(w, 200, map[string]interface{}{ + "game_id": gameID, + "script_title": scriptTitle, + "truth": truth, + "roles": rolesOut, + "messages": msgsOut, + "votes": voteDetails, + }) +} diff --git a/backend/internal/handlers/helpers.go b/backend/internal/handlers/helpers.go new file mode 100644 index 0000000..68b7bfd --- /dev/null +++ b/backend/internal/handlers/helpers.go @@ -0,0 +1,218 @@ +package handlers + +import ( + "crypto/rand" + "database/sql" + "encoding/json" + "fmt" + "net/http" + + "mmgame/internal/database" + "mmgame/internal/models" + "mmgame/internal/scripts" + "mmgame/internal/util" +) + +func newID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, detail string) { + writeJSON(w, status, map[string]string{"detail": detail}) +} + +func nullStr(s *string) string { + if s == nil { + return "" + } + return *s +} + +func Health(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]string{"status": "ok"}) +} + +func parseConfig(configStr string) map[string]interface{} { + if configStr == "" { + return map[string]interface{}{} + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(configStr), &m); err != nil { + return map[string]interface{}{} + } + return m +} + +func scriptFromConfig(configStr string) *scripts.Script { + cfg := parseConfig(configStr) + raw, ok := cfg["script"] + if !ok || raw == nil { + return nil + } + b, err := json.Marshal(raw) + if err != nil { + return nil + } + var s scripts.Script + if err := json.Unmarshal(b, &s); err != nil { + return nil + } + return &s +} + +func getGame(gameID string) (*models.GameSession, error) { + row := database.DB.QueryRow( + `SELECT id, script_id, status, phase, phase_index, started_at, completed_at, config, created_at + FROM game_sessions WHERE id = ?`, gameID) + var g models.GameSession + var startedAt, completedAt, configStr, createdAt sql.NullString + err := row.Scan(&g.ID, &g.ScriptID, &g.Status, &g.Phase, &g.PhaseIndex, + &startedAt, &completedAt, &configStr, &createdAt) + if err != nil { + return nil, err + } + if startedAt.Valid { + t := util.ParseTime(startedAt.String) + g.StartedAt = &t + } + if completedAt.Valid { + t := util.ParseTime(completedAt.String) + g.CompletedAt = &t + } + g.Config = configStr.String + g.CreatedAt = util.ParseTime(createdAt.String) + return &g, nil +} + +func queryPlayers(gameID string) []*models.SessionPlayer { + rows, err := database.DB.Query( + `SELECT id, session_id, user_id, role_id, role_name, is_human, is_ready, avatar_url, status, joined_at + FROM session_players WHERE session_id = ?`, gameID) + if err != nil { + return nil + } + defer rows.Close() + var list []*models.SessionPlayer + for rows.Next() { + p := &models.SessionPlayer{} + var userID, avatarURL, joinedAt sql.NullString + if err := rows.Scan(&p.ID, &p.SessionID, &userID, &p.RoleID, &p.RoleName, + &p.IsHuman, &p.IsReady, &avatarURL, &p.Status, &joinedAt); err != nil { + continue + } + if userID.Valid { + p.UserID = &userID.String + } + if avatarURL.Valid { + p.AvatarURL = &avatarURL.String + } + p.JoinedAt = util.ParseTime(joinedAt.String) + list = append(list, p) + } + return list +} + +func queryMessages(gameID string) []*models.ChatMessage { + rows, err := database.DB.Query( + `SELECT id, session_id, sender_role_id, sender_name, message_type, content, target_role_id, clue_id, phase_index, created_at + FROM chat_messages WHERE session_id = ? ORDER BY created_at`, gameID) + if err != nil { + return nil + } + defer rows.Close() + var list []*models.ChatMessage + for rows.Next() { + m := &models.ChatMessage{} + var targetRoleID, clueID, createdAt sql.NullString + if err := rows.Scan(&m.ID, &m.SessionID, &m.SenderRoleID, &m.SenderName, &m.MessageType, + &m.Content, &targetRoleID, &clueID, &m.PhaseIndex, &createdAt); err != nil { + continue + } + if targetRoleID.Valid { + m.TargetRoleID = &targetRoleID.String + } + if clueID.Valid { + m.ClueID = &clueID.String + } + m.CreatedAt = util.ParseTime(createdAt.String) + list = append(list, m) + } + return list +} + +func queryClues(gameID string) []*models.ClueState { + rows, err := database.DB.Query( + `SELECT id, session_id, clue_id, status, revealed_by, released_at, created_at + FROM clues_state WHERE session_id = ?`, gameID) + if err != nil { + return nil + } + defer rows.Close() + var list []*models.ClueState + for rows.Next() { + c := &models.ClueState{} + var revealedBy, releasedAt, createdAt sql.NullString + if err := rows.Scan(&c.ID, &c.SessionID, &c.ClueID, &c.Status, &revealedBy, &releasedAt, &createdAt); err != nil { + continue + } + if revealedBy.Valid { + c.RevealedBy = &revealedBy.String + } + if releasedAt.Valid { + t := util.ParseTime(releasedAt.String) + c.ReleasedAt = &t + } + c.CreatedAt = util.ParseTime(createdAt.String) + list = append(list, c) + } + return list +} + +func queryVotes(gameID string) []*models.Vote { + rows, err := database.DB.Query( + `SELECT id, session_id, round, voter_id, target_id, reason, created_at + FROM votes WHERE session_id = ?`, gameID) + if err != nil { + return nil + } + defer rows.Close() + var list []*models.Vote + for rows.Next() { + v := &models.Vote{} + var reason, createdAt sql.NullString + if err := rows.Scan(&v.ID, &v.SessionID, &v.Round, &v.VoterID, &v.TargetID, &reason, &createdAt); err != nil { + continue + } + if reason.Valid { + v.Reason = &reason.String + } + v.CreatedAt = util.ParseTime(createdAt.String) + list = append(list, v) + } + return list +} + +func messageDict(m *models.ChatMessage) map[string]interface{} { + d := map[string]interface{}{ + "id": m.ID, + "sender_role_id": m.SenderRoleID, + "sender_name": m.SenderName, + "message_type": m.MessageType, + "content": m.Content, + "target_role_id": m.TargetRoleID, + "clue_id": m.ClueID, + "phase_index": m.PhaseIndex, + "created_at": util.TimeStr(m.CreatedAt), + } + return d +} diff --git a/backend/internal/handlers/scripts.go b/backend/internal/handlers/scripts.go new file mode 100644 index 0000000..e9d9ae7 --- /dev/null +++ b/backend/internal/handlers/scripts.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "mmgame/internal/scripts" +) + +func ListScripts(w http.ResponseWriter, r *http.Request) { + items := []map[string]interface{}{} + for _, s := range scripts.SCRIPTS { + items = append(items, map[string]interface{}{ + "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, + }) + } + writeJSON(w, 200, items) +} + +func GetScript(w http.ResponseWriter, r *http.Request) { + scriptID := chi.URLParam(r, "script_id") + s := scripts.GetByID(scriptID) + if s == nil { + writeError(w, 404, "剧本不存在") + return + } + writeJSON(w, 200, s) +} diff --git a/backend/internal/handlers/ws.go b/backend/internal/handlers/ws.go new file mode 100644 index 0000000..c79b06d --- /dev/null +++ b/backend/internal/handlers/ws.go @@ -0,0 +1,121 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" +) + +type Client struct { + Conn *websocket.Conn + RoleID string +} + +var ( + connsMu sync.RWMutex + activeConns = map[string][]*Client{} +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func removeConn(gameID string, c *Client) { + connsMu.Lock() + defer connsMu.Unlock() + clients := activeConns[gameID] + for i, x := range clients { + if x == c { + activeConns[gameID] = append(clients[:i], clients[i+1:]...) + break + } + } + if len(activeConns[gameID]) == 0 { + delete(activeConns, gameID) + } +} + +func removeGameConns(gameID string) { + connsMu.Lock() + defer connsMu.Unlock() + if clients, ok := activeConns[gameID]; ok { + for _, c := range clients { + _ = c.Conn.Close() + } + } + delete(activeConns, gameID) +} + +func broadcast(gameID string, message interface{}) { + data, err := json.Marshal(message) + if err != nil { + return + } + connsMu.RLock() + clients := append([]*Client{}, activeConns[gameID]...) + connsMu.RUnlock() + for _, c := range clients { + if err := c.Conn.WriteMessage(websocket.TextMessage, data); err != nil { + removeConn(gameID, c) + } + } +} + +func broadcastToRole(gameID, targetRoleID string, message interface{}) { + data, err := json.Marshal(message) + if err != nil { + return + } + connsMu.RLock() + clients := append([]*Client{}, activeConns[gameID]...) + connsMu.RUnlock() + for _, c := range clients { + if c.RoleID != "" && c.RoleID != targetRoleID { + continue + } + if err := c.Conn.WriteMessage(websocket.TextMessage, data); err != nil { + removeConn(gameID, c) + } + } +} + +func GameWebSocket(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "game_id") + roleID := r.URL.Query().Get("role_id") + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + client := &Client{Conn: conn, RoleID: roleID} + + connsMu.Lock() + activeConns[gameID] = append(activeConns[gameID], client) + connsMu.Unlock() + + defer func() { + removeConn(gameID, client) + _ = conn.Close() + }() + + for { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + var msg struct { + Type string `json:"type"` + } + if err := json.Unmarshal(data, &msg); err != nil { + continue + } + if msg.Type == "ping" { + if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"pong"}`)); err != nil { + return + } + } + } +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..fc40576 --- /dev/null +++ b/backend/internal/models/models.go @@ -0,0 +1,103 @@ +package models + +import "time" + +const ( + RolePlayer = "player" + RoleCreator = "creator" + RoleAdmin = "admin" + + ScriptSourceBuiltin = "builtin" + ScriptSourceAIGenerated = "ai_generated" + ScriptSourceUploaded = "uploaded" + ScriptSourceCreator = "creator" + + ScriptStatusDraft = "draft" + ScriptStatusPublished = "published" + ScriptStatusDisabled = "disabled" + + GameStatusWaiting = "waiting" + GameStatusPlaying = "playing" + GameStatusPaused = "paused" + GameStatusCompleted = "completed" + + PlayerStatusActive = "active" + PlayerStatusLeft = "left" + PlayerStatusEliminated = "eliminated" + + MessageTypePublic = "public" + MessageTypePrivate = "private" + MessageTypeSystem = "system" + MessageTypeClue = "clue" +) + +type User struct { + ID string + Email *string + Nickname *string + AvatarURL *string + PasswordHash *string + Role string + IsGuest bool + GameCount int + TokenVersion int + CreatedAt time.Time +} + +type GameSession struct { + ID string + ScriptID string + Status string + Phase string + PhaseIndex int + StartedAt *time.Time + CompletedAt *time.Time + Config string + CreatedAt time.Time +} + +type SessionPlayer struct { + ID string + SessionID string + UserID *string + RoleID string + RoleName string + IsHuman bool + IsReady bool + AvatarURL *string + Status string + JoinedAt time.Time +} + +type ChatMessage struct { + ID string + SessionID string + SenderRoleID string + SenderName string + MessageType string + Content string + TargetRoleID *string + ClueID *string + PhaseIndex int + CreatedAt time.Time +} + +type ClueState struct { + ID string + SessionID string + ClueID string + Status string + RevealedBy *string + ReleasedAt *time.Time + CreatedAt time.Time +} + +type Vote struct { + ID string + SessionID string + Round int + VoterID string + TargetID string + Reason *string + CreatedAt time.Time +} diff --git a/backend/app/scripts/sample_data.json b/backend/internal/scripts/sample_data.json similarity index 100% rename from backend/app/scripts/sample_data.json rename to backend/internal/scripts/sample_data.json diff --git a/backend/internal/scripts/scripts.go b/backend/internal/scripts/scripts.go new file mode 100644 index 0000000..ae1e9c8 --- /dev/null +++ b/backend/internal/scripts/scripts.go @@ -0,0 +1,80 @@ +package scripts + +import ( + _ "embed" + "encoding/json" + "log" +) + +//go:embed sample_data.json +var dataJSON []byte + +var SCRIPTS []Script + +func init() { + if err := json.Unmarshal(dataJSON, &SCRIPTS); err != nil { + log.Fatalf("failed to parse sample_data.json: %v", err) + } +} + +type Script struct { + ID string `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Difficulty int `json:"difficulty"` + PlayerCount PlayerCount `json:"playerCount"` + Duration string `json:"duration"` + Background string `json:"background"` + Phases []Phase `json:"phases"` + Roles []Role `json:"roles"` + Clues []Clue `json:"clues"` + Truth string `json:"truth"` +} + +type PlayerCount struct { + Min int `json:"min"` + Max int `json:"max"` +} + +type Phase struct { + Name string `json:"name"` + Description string `json:"description"` + PublicInfo string `json:"publicInfo"` + Clues []string `json:"clues"` +} + +type Role struct { + ID string `json:"id"` + Name string `json:"name"` + PublicProfile string `json:"publicProfile"` + SecretProfile string `json:"secretProfile"` + Secret string `json:"secret"` + Goal string `json:"goal"` + WinCondition string `json:"winCondition"` + Personality string `json:"personality"` +} + +type Clue struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Phase int `json:"phase"` +} + +func GetByID(id string) *Script { + for i := range SCRIPTS { + if SCRIPTS[i].ID == id { + return &SCRIPTS[i] + } + } + return nil +} + +func GetByTitle(title string) *Script { + for i := range SCRIPTS { + if SCRIPTS[i].Title == title { + return &SCRIPTS[i] + } + } + return nil +} diff --git a/backend/internal/util/timeutil.go b/backend/internal/util/timeutil.go new file mode 100644 index 0000000..e879753 --- /dev/null +++ b/backend/internal/util/timeutil.go @@ -0,0 +1,25 @@ +package util + +import "time" + +const PyTimeLayout = "2006-01-02T15:04:05.999999" + +func NowStr() string { + return time.Now().UTC().Format(PyTimeLayout) +} + +func TimeStr(t time.Time) string { + return t.UTC().Format(PyTimeLayout) +} + +func ParseTime(s string) time.Time { + if s == "" { + return time.Now().UTC() + } + for _, layout := range []string{PyTimeLayout, "2006-01-02T15:04:05", "2006-01-02 15:04:05", time.RFC3339} { + if t, err := time.Parse(layout, s); err == nil { + return t + } + } + return time.Now().UTC() +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..aa0512b --- /dev/null +++ b/backend/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "log" + "net/http" + + "github.com/go-chi/chi/v5" + + "mmgame/internal/database" + "mmgame/internal/handlers" +) + +func main() { + if err := database.Init(); err != nil { + log.Fatalf("database init failed: %v", err) + } + + r := chi.NewRouter() + r.Use(cors) + + r.Get("/api/health", handlers.Health) + + // auth + r.Post("/api/auth/register", handlers.Register) + r.Post("/api/auth/login", handlers.Login) + r.Post("/api/auth/guest", handlers.GuestLogin) + r.Post("/api/auth/logout", handlers.Logout) + r.Get("/api/auth/profile", handlers.GetProfile) + r.Put("/api/auth/profile", handlers.UpdateProfile) + r.Get("/api/auth/history", handlers.GetHistory) + + // scripts + r.Get("/api/scripts", handlers.ListScripts) + r.Get("/api/scripts/{script_id}", handlers.GetScript) + + // games + r.Post("/api/games/create", handlers.CreateGame) + r.Route("/api/games/{game_id}", func(rr chi.Router) { + rr.Post("/join", handlers.JoinGame) + rr.Post("/start", handlers.StartGame) + rr.Post("/chat", handlers.SendChat) + rr.Post("/phase/advance", handlers.AdvancePhase) + rr.Post("/clue/{clue_id}/reveal", handlers.RevealClue) + rr.Post("/vote", handlers.SubmitVote) + rr.Post("/vote/end", handlers.EndVote) + rr.Get("/state", handlers.GetGameState) + rr.Get("/replay", handlers.GetReplay) + }) + r.Get("/api/games/ws/{game_id}", handlers.GameWebSocket) + + log.Println("MMGame Go backend running on http://localhost:8000") + log.Fatal(http.ListenAndServe(":8000", r)) +} + +func cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" { + origin = "*" + } + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index a0f339f..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index d938422..0000000 --- a/backend/test_npc.py +++ /dev/null @@ -1,29 +0,0 @@ -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/start-backend.bat b/start-backend.bat index 2bc0b81..ea28208 100644 --- a/start-backend.bat +++ b/start-backend.bat @@ -1,8 +1,8 @@ @echo off chcp 65001 >nul -title AI 剧本杀 - 仅启动后端 +title AI 剧本杀 - 仅启动后端 (Go) -echo [AI 剧本杀] 启动后端服务 (端口 8000)... +echo [AI 剧本杀] 启动后端服务 (Go, 端口 8000)... cd /d "%~dp0backend" -uvicorn app.main:app --reload --port 8000 -pause +go run . +pause \ No newline at end of file diff --git a/start.bat b/start.bat index 7777823..262cbe7 100644 --- a/start.bat +++ b/start.bat @@ -1,5 +1,5 @@ @echo off -title AI Murder Mystery MVP +title AI Murder Mystery MVP (Go Backend) echo ======================================== echo AI Murder Mystery MVP - Launcher @@ -8,8 +8,8 @@ echo. set "ROOT=%~dp0" -echo [1/2] Starting backend (port 8000)... -start "MM-Backend" /D "%ROOT%backend" uvicorn app.main:app --reload --port 8000 +echo [1/2] Starting Go backend (port 8000)... +start "MM-Backend" /D "%ROOT%backend" go run . ping 127.0.0.1 -n 3 >nul @@ -26,4 +26,4 @@ echo Frontend: http://localhost:5173 echo ======================================== start http://localhost:5173 -pause +pause \ No newline at end of file diff --git a/start_backend.bat b/start_backend.bat index 2854dee..eb466b8 100644 --- a/start_backend.bat +++ b/start_backend.bat @@ -1,3 +1,3 @@ @echo off -cd /d "E:\Temp\Anby\MMGame\backend" -uvicorn app.main:app --host 0.0.0.0 --port 8000 > backend.log 2>&1 +cd /d "%~dp0backend" +go run . > backend.log 2>&1 \ No newline at end of file -- 2.54.0