refactor: rewrite backend in Go, replacing Python FastAPI #1
@@ -25,6 +25,7 @@ logs/
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Binary files
|
||||
*.pdf
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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', '')}"
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
@@ -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, "剧本不存在")
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package agents
|
||||
|
||||
import "math/rand"
|
||||
|
||||
func randInt(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return rand.Intn(n)
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 ===')
|
||||
+3
-3
@@ -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
|
||||
go run .
|
||||
pause
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
Reference in New Issue
Block a user