Compare commits

1 Commits
dev ... go
Author SHA1 Message Date
gmh01 7614e79d12 refactor: rewrite backend in Go, replacing Python FastAPI
Port the FastAPI backend to Go 1.26, preserving all API functionality:
- auth (register/login/guest/logout/profile/history) with JWT + bcrypt
- scripts (list/detail) backed by embedded sample_data.json
- games (create/join/start/chat/phase/clue/vote/state/replay) + WebSocket
- rule-based NPC agent plus DeepSeek/Ollama AI backends
- SQLite persistence via modernc.org/sqlite (pure Go, no cgo)

Frontend and documents are unchanged.
2026-08-03 11:39:53 +08:00
43 changed files with 2498 additions and 1462 deletions
+1
View File
@@ -25,6 +25,7 @@ logs/
# Environment / secrets # Environment / secrets
.env .env
.env.* .env.*
!.env.example
# Binary files # Binary files
*.pdf *.pdf
+13
View File
@@ -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
View File
View File
-220
View File
@@ -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)
-24
View File
@@ -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', '')}"
-26
View File
@@ -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"}
-21
View File
@@ -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()
-24
View File
@@ -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)
-31
View File
@@ -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"}
View File
-94
View File
@@ -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)
-39
View File
@@ -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)
-27
View File
@@ -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)
View File
-144
View File
@@ -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
]
-694
View File
@@ -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
-45
View File
@@ -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, "剧本不存在")
View File
-23
View File
@@ -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
+23
View File
@@ -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
)
+59
View File
@@ -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=
+322
View File
@@ -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))]
}
+38
View File
@@ -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)
}
+38
View File
@@ -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"}
}
+10
View File
@@ -0,0 +1,10 @@
package agents
import "math/rand"
func randInt(n int) int {
if n <= 0 {
return 0
}
return rand.Intn(n)
}
+83
View File
@@ -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"),
}
}
+118
View File
@@ -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
}
+263
View File
@@ -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)
}
+866
View File
@@ -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,
})
}
+218
View File
@@ -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
}
+36
View File
@@ -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)
}
+121
View File
@@ -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
}
}
}
}
+103
View File
@@ -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
}
+80
View File
@@ -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
}
+25
View File
@@ -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()
}
+71
View File
@@ -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)
})
}
-11
View File
@@ -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
-29
View File
@@ -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
View File
@@ -1,8 +1,8 @@
@echo off @echo off
chcp 65001 >nul chcp 65001 >nul
title AI 剧本杀 - 仅启动后端 title AI 剧本杀 - 仅启动后端 (Go)
echo [AI 剧本杀] 启动后端服务 (端口 8000)... echo [AI 剧本杀] 启动后端服务 (Go, 端口 8000)...
cd /d "%~dp0backend" cd /d "%~dp0backend"
uvicorn app.main:app --reload --port 8000 go run .
pause pause
+3 -3
View File
@@ -1,5 +1,5 @@
@echo off @echo off
title AI Murder Mystery MVP title AI Murder Mystery MVP (Go Backend)
echo ======================================== echo ========================================
echo AI Murder Mystery MVP - Launcher echo AI Murder Mystery MVP - Launcher
@@ -8,8 +8,8 @@ echo.
set "ROOT=%~dp0" set "ROOT=%~dp0"
echo [1/2] Starting backend (port 8000)... echo [1/2] Starting Go backend (port 8000)...
start "MM-Backend" /D "%ROOT%backend" uvicorn app.main:app --reload --port 8000 start "MM-Backend" /D "%ROOT%backend" go run .
ping 127.0.0.1 -n 3 >nul ping 127.0.0.1 -n 3 >nul
+2 -2
View File
@@ -1,3 +1,3 @@
@echo off @echo off
cd /d "E:\Temp\Anby\MMGame\backend" cd /d "%~dp0backend"
uvicorn app.main:app --host 0.0.0.0 --port 8000 > backend.log 2>&1 go run . > backend.log 2>&1