feat: add MMGame backend and frontend source code
This commit is contained in:
+61
@@ -0,0 +1,61 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Database / binary data files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Binary files
|
||||
*.pdf
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.jar
|
||||
*.war
|
||||
*.zip
|
||||
*.tar
|
||||
*.gz
|
||||
*.rar
|
||||
*.7z
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.ico
|
||||
*.bmp
|
||||
*.mp3
|
||||
*.mp4
|
||||
*.avi
|
||||
*.wav
|
||||
*.bin
|
||||
*.dat
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,220 @@
|
||||
import json
|
||||
import random
|
||||
import httpx
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class AIBackend:
|
||||
def __init__(self):
|
||||
self.backend = settings.AI_BACKEND
|
||||
|
||||
async def chat(self, messages: list[dict], temperature: float = 0.7, max_tokens: int = 512) -> str:
|
||||
if self.backend == "ollama":
|
||||
return await self._ollama_chat(messages, temperature, max_tokens)
|
||||
elif self.backend == "deepseek":
|
||||
return await self._deepseek_chat(messages, temperature, max_tokens)
|
||||
else:
|
||||
return ""
|
||||
|
||||
async def _ollama_chat(self, messages: list[dict], temperature: float, max_tokens: int) -> str:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.OLLAMA_BASE_URL}/api/chat",
|
||||
json={
|
||||
"model": settings.OLLAMA_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"temperature": temperature, "num_predict": max_tokens},
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
return data.get("message", {}).get("content", "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
async def _deepseek_chat(self, messages: list[dict], temperature: float, max_tokens: int) -> str:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.DEEPSEEK_BASE_URL}/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {settings.DEEPSEEK_API_KEY}"},
|
||||
json={"model": settings.DEEPSEEK_MODEL, "messages": messages, "temperature": temperature, "max_tokens": max_tokens},
|
||||
)
|
||||
result = resp.json()
|
||||
return result["choices"][0]["message"]["content"]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
ai_backend = AIBackend()
|
||||
|
||||
|
||||
DM_INTROS = {
|
||||
"hardcore": "欢迎来到这场推理盛宴。真相就藏在你面前的线索之中,仔细观察,大胆推理。",
|
||||
"emotional": "这是一个关于人心的故事。有时候,真相并不重要,重要的是你在这个过程中感受到了什么。",
|
||||
"欢乐": "欢迎来到这场欢乐的聚会!记住,每个人都在演戏,但真相只有一个!",
|
||||
"恐怖": "黑暗中有什么在注视着你……准备好了吗?",
|
||||
}
|
||||
|
||||
|
||||
async def dm_generate(script: dict, phase_index: int, released_clues: list[str], context: str = "") -> dict:
|
||||
phases = script.get("phases", [])
|
||||
phase = phases[phase_index] if phase_index < len(phases) else phases[-1]
|
||||
title = script["title"]
|
||||
script_type = script.get("type", "hardcore")
|
||||
|
||||
type_intro = DM_INTROS.get(script_type, "")
|
||||
clues_str = ", ".join(released_clues) if released_clues else "暂无"
|
||||
|
||||
prompt = f"""你是一个剧本杀主持人(DM),请以主持人的身份发言。
|
||||
当前剧本: {title}
|
||||
当前阶段: {phase['name']}
|
||||
阶段描述: {phase['description']}
|
||||
已发放线索: {clues_str}
|
||||
上下文: {context}
|
||||
|
||||
请简短发言(50字以内),推动剧情发展。"""
|
||||
|
||||
result = await ai_backend.chat([{"role": "user", "content": prompt}])
|
||||
if result:
|
||||
# Try to parse as JSON
|
||||
cleaned = result.strip().removeprefix("```json").removesuffix("```").strip()
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"action": "narrate", "content": result}
|
||||
|
||||
voice = phase["description"][:80]
|
||||
if phase_index == 0 and context == "opening":
|
||||
voice = f"{type_intro}\n\n欢迎来到【{title}】。{script.get('background', '')[:100]}"
|
||||
return {"action": "narrate", "content": f"【{phase['name']}】{voice}"}
|
||||
|
||||
|
||||
async def npc_generate(role: dict, background: str, phase_name: str, recent_messages: list[str],
|
||||
player_message: str = "", is_private: bool = False,
|
||||
known_clues: list[str] = None, phase_goal: str = "",
|
||||
all_roles: list[dict] = None) -> dict:
|
||||
personality = role.get("personality", role.get("name", ""))
|
||||
clues_str = " | ".join(known_clues[-5:]) if known_clues else "暂无线索"
|
||||
roles_str = ""
|
||||
if all_roles:
|
||||
rlist = [r["name"] for r in all_roles if r["id"] != role.get("id")]
|
||||
roles_str = "其他玩家: " + ", ".join(rlist)
|
||||
|
||||
prompt = f"""你正在扮演一个剧本杀角色。请严格按照角色设定发言。
|
||||
|
||||
角色信息:
|
||||
- 姓名: {role['name']}
|
||||
- 公开身份: {role.get('publicProfile', '')}
|
||||
- 性格: {personality}
|
||||
- 你的目标: {role.get('goal', '')}
|
||||
- 你的秘密: {role.get('secretProfile', '')}
|
||||
|
||||
当前背景: {background[:80]}...
|
||||
当前阶段: {phase_name}
|
||||
{("阶段目标: " + phase_goal) if phase_goal else ""}
|
||||
{roles_str}
|
||||
|
||||
你已知道的线索:
|
||||
{clues_str}
|
||||
|
||||
最近发言:
|
||||
{' | '.join(recent_messages[-4:])}
|
||||
|
||||
{'【私聊】' + player_message if is_private and player_message else ''}
|
||||
{'【有人对你说】' + player_message if not is_private and player_message else ''}
|
||||
|
||||
【发言规则】
|
||||
1. 以角色身份说人话,简短自然(30字以内)
|
||||
2. 你的秘密绝对不能主动说出来
|
||||
3. 被问到时可以撒谎、回避、转移话题
|
||||
4. 结合已公开的线索来推理和回应
|
||||
5. 如果你知道某些线索的真相(如你就是凶手),可以故意误导他人
|
||||
|
||||
直接输出你的角色发言内容,不要JSON、不要解释。"""
|
||||
|
||||
result = await ai_backend.chat([{"role": "user", "content": prompt}])
|
||||
if result:
|
||||
cleaned = result.strip().removeprefix("```json").removesuffix("```").strip()
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if result.startswith("{"):
|
||||
return {"action": "speak", "content": result}
|
||||
return {"action": "speak", "content": result}
|
||||
|
||||
return {"action": "speak", "content": _rule_npc_reply(role, player_message, is_private, known_clues)}
|
||||
|
||||
|
||||
def _rule_npc_reply(role: dict, player_message: str, is_private: bool, known_clues: list[str] = None) -> str:
|
||||
name = role["name"]
|
||||
personality = role.get("personality", "")
|
||||
goal = role.get("goal", "")
|
||||
clues = known_clues or []
|
||||
|
||||
if not player_message:
|
||||
if clues:
|
||||
replies = [
|
||||
f"{name}沉思道:\"这些线索……我觉得需要重新梳理一下。\"",
|
||||
f"{name}说:\"我注意到了一些细节,但现在还不方便说。\"",
|
||||
f"{name}看了看法医报告:\"时间线和线索对不上,肯定有人撒谎。\"",
|
||||
]
|
||||
return random.choice(replies)
|
||||
replies = [
|
||||
f"{name}环顾四周,若有所思。",
|
||||
f"{name}清了清嗓子:\"各位,我觉得我们应该整理一下思路。\"",
|
||||
f"{name}沉默地看着大家。",
|
||||
f"{name}低声说:\"这件事没有那么简单……\"",
|
||||
]
|
||||
return random.choice(replies)
|
||||
|
||||
if "凶手" in player_message or "杀人" in player_message or "你杀" in player_message:
|
||||
denials = [
|
||||
f"{name}脸色一变:\"你凭什么这么说?证据呢?\"",
|
||||
f"{name}冷笑一声:\"如果我是凶手,我还会坐在这里?\"",
|
||||
f"{name}摇头:\"我没有理由杀他。\"",
|
||||
]
|
||||
return random.choice(denials)
|
||||
|
||||
if "时间" in player_message or "在哪" in player_message or "案发" in player_message:
|
||||
times = [
|
||||
f"{name}回忆道:\"我当时在……让我想想。\"",
|
||||
f"{name}说:\"那段时间我一个人在房间里。\"",
|
||||
f"{name}皱眉:\"我不太确定具体时间,但我确实听到了什么声音。\"",
|
||||
]
|
||||
return random.choice(times)
|
||||
|
||||
if "线索" in player_message or "证据" in player_message or "发现" in player_message:
|
||||
clues_replies = [
|
||||
f"{name}点头:\"这个线索确实值得注意。\"",
|
||||
f"{name}沉思:\"但这个线索也可能是在误导我们。\"",
|
||||
f"{name}说:\"我也有一个发现,但还不确定是否相关。\"",
|
||||
]
|
||||
return random.choice(clues_replies)
|
||||
|
||||
if "知道" in player_message or "秘密" in player_message:
|
||||
secrets = [
|
||||
f"{name}回避了你的目光:\"我什么也不知道。\"",
|
||||
f"{name}沉默了一会儿:\"每个人都有不想说的秘密,不是吗?\"",
|
||||
f"{name}说:\"我只能告诉你,事情不是你看到的那样。\"",
|
||||
]
|
||||
return random.choice(secrets)
|
||||
|
||||
if "动机" in player_message or "为什么" in player_message or "目的" in player_message:
|
||||
motives = [
|
||||
f"{name}说:\"每个人都有自己的理由,但有些理由……\"",
|
||||
f"{name}耸耸肩:\"动机?也许我们需要先搞清楚发生了什么。\"",
|
||||
f"{name}看着你:\"你确定你想知道真正的动机?\"",
|
||||
]
|
||||
return random.choice(motives)
|
||||
|
||||
generic = [
|
||||
f"{name}思考了一下:\"这个嘛,我说不好。\"",
|
||||
f"{name}回答:\"我不太确定,但我觉得我们应该继续调查。\"",
|
||||
f"{name}说:\"你说得有一定道理,但可能还有其他可能性。\"",
|
||||
f"{name}点头:\"有意思,继续说。\"",
|
||||
]
|
||||
return random.choice(generic)
|
||||
@@ -0,0 +1,24 @@
|
||||
from app.agents.ai import dm_generate
|
||||
|
||||
|
||||
class DMAgent:
|
||||
def __init__(self, script: dict):
|
||||
self.script = script
|
||||
self.current_phase_index = 0
|
||||
self.released_clues = []
|
||||
|
||||
def get_opening(self) -> str:
|
||||
title = self.script["title"]
|
||||
bg = self.script.get("background", "")
|
||||
return f"欢迎来到【{title}】。{bg}"
|
||||
|
||||
async def act(self, phase_index: int, player_progress: str = "", context: str = "") -> dict:
|
||||
result = await dm_generate(self.script, phase_index, self.released_clues, context)
|
||||
if result:
|
||||
return result
|
||||
phases = self.script.get("phases", [])
|
||||
phase = phases[phase_index] if phase_index < len(phases) else phases[-1]
|
||||
return {"action": "narrate", "content": f"【{phase['name']}】{phase['description']}"}
|
||||
|
||||
def get_replay_summary(self) -> str:
|
||||
return f"剧本:{self.script['title']}\n真相:{self.script.get('truth', '')}"
|
||||
@@ -0,0 +1,26 @@
|
||||
from app.agents.ai import npc_generate
|
||||
|
||||
|
||||
class NPCAgent:
|
||||
def __init__(self, role: dict, background: str, public_info: str):
|
||||
self.role = role
|
||||
self.background = background
|
||||
self.public_info = public_info
|
||||
self.memory = []
|
||||
|
||||
async def respond(self, phase_name: str, recent_messages: list = None, player_message: str = "", is_private: bool = False,
|
||||
known_clues: list[str] = None, phase_goal: str = "", all_roles: list[dict] = None) -> dict:
|
||||
if recent_messages is None:
|
||||
recent_messages = []
|
||||
recent = [
|
||||
f"{m.get('sender_name', m.get('sender', ''))}: {m.get('content', '')}"
|
||||
for m in recent_messages[-5:]
|
||||
]
|
||||
result = await npc_generate(self.role, self.background, phase_name, recent, player_message, is_private,
|
||||
known_clues, phase_goal, all_roles)
|
||||
if result:
|
||||
content = result.get("content", "")
|
||||
if content:
|
||||
self.memory.append({"role": "assistant", "content": content})
|
||||
return result
|
||||
return {"content": f"{self.role['name']}陷入了沉思……", "action": "speak"}
|
||||
@@ -0,0 +1,21 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./mmgame.db"
|
||||
SECRET_KEY: str = "mmgame-dev-secret-key-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7
|
||||
|
||||
AI_BACKEND: str = "rule"
|
||||
DEEPSEEK_API_KEY: str = ""
|
||||
DEEPSEEK_BASE_URL: str = "https://api.deepseek.com"
|
||||
DEEPSEEK_MODEL: str = "deepseek-chat"
|
||||
OLLAMA_BASE_URL: str = "http://localhost:11434"
|
||||
OLLAMA_MODEL: str = "qwen2.5:7b"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
from app.models import user, script, game # noqa
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,31 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from app.database import init_db
|
||||
from app.routers import auth, scripts, games
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="AI剧本杀", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(scripts.router)
|
||||
app.include_router(games.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,94 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, Text, Boolean, DateTime, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class GameStatus(str, enum.Enum):
|
||||
waiting = "waiting"
|
||||
playing = "playing"
|
||||
paused = "paused"
|
||||
completed = "completed"
|
||||
|
||||
|
||||
class PlayerStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
left = "left"
|
||||
eliminated = "eliminated"
|
||||
|
||||
|
||||
class MessageType(str, enum.Enum):
|
||||
public = "public"
|
||||
private = "private"
|
||||
system = "system"
|
||||
clue = "clue"
|
||||
|
||||
|
||||
class GameSession(Base):
|
||||
__tablename__ = "game_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
script_id: Mapped[str] = mapped_column(String(36))
|
||||
status: Mapped[str] = mapped_column(String(20), default=GameStatus.waiting.value)
|
||||
phase: Mapped[str] = mapped_column(String(100), default="")
|
||||
phase_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
|
||||
completed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
|
||||
config: Mapped[str] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class SessionPlayer(Base):
|
||||
__tablename__ = "session_players"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
session_id: Mapped[str] = mapped_column(String(36))
|
||||
user_id: Mapped[str] = mapped_column(String(36), nullable=True)
|
||||
role_id: Mapped[str] = mapped_column(String(100))
|
||||
role_name: Mapped[str] = mapped_column(String(100), default="")
|
||||
is_human: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_ready: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
avatar_url: Mapped[str] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default=PlayerStatus.active.value)
|
||||
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
session_id: Mapped[str] = mapped_column(String(36))
|
||||
sender_role_id: Mapped[str] = mapped_column(String(100))
|
||||
sender_name: Mapped[str] = mapped_column(String(100), default="")
|
||||
message_type: Mapped[str] = mapped_column(String(20), default=MessageType.public.value)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
target_role_id: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||
clue_id: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||
phase_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ClueState(Base):
|
||||
__tablename__ = "clues_state"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
session_id: Mapped[str] = mapped_column(String(36))
|
||||
clue_id: Mapped[str] = mapped_column(String(100))
|
||||
status: Mapped[str] = mapped_column(String(20), default="unreleased")
|
||||
revealed_by: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||
released_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Vote(Base):
|
||||
__tablename__ = "votes"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
session_id: Mapped[str] = mapped_column(String(36))
|
||||
round: Mapped[int] = mapped_column(Integer, default=1)
|
||||
voter_id: Mapped[str] = mapped_column(String(36))
|
||||
target_id: Mapped[str] = mapped_column(String(36))
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,39 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, Text, DateTime, Float, JSON, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class ScriptSource(str, enum.Enum):
|
||||
builtin = "builtin"
|
||||
ai_generated = "ai_generated"
|
||||
uploaded = "uploaded"
|
||||
creator = "creator"
|
||||
|
||||
|
||||
class ScriptStatus(str, enum.Enum):
|
||||
draft = "draft"
|
||||
published = "published"
|
||||
disabled = "disabled"
|
||||
|
||||
|
||||
class Script(Base):
|
||||
__tablename__ = "scripts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
type: Mapped[str] = mapped_column(String(50))
|
||||
difficulty: Mapped[int] = mapped_column(Integer)
|
||||
min_players: Mapped[int] = mapped_column(Integer)
|
||||
max_players: Mapped[int] = mapped_column(Integer)
|
||||
duration_min: Mapped[int] = mapped_column(Integer)
|
||||
background: Mapped[str] = mapped_column(Text)
|
||||
config: Mapped[str] = mapped_column(JSON)
|
||||
source: Mapped[str] = mapped_column(String(20), default=ScriptSource.builtin.value)
|
||||
creator_id: Mapped[str] = mapped_column(String(36), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default=ScriptStatus.published.value)
|
||||
rating_avg: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
play_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, Text, Boolean, DateTime, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
player = "player"
|
||||
creator = "creator"
|
||||
admin = "admin"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=True)
|
||||
nickname: Mapped[str] = mapped_column(String(50), nullable=True)
|
||||
avatar_url: Mapped[str] = mapped_column(Text, nullable=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=True)
|
||||
role: Mapped[str] = mapped_column(String(20), default=UserRole.player.value)
|
||||
is_guest: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
game_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
token_version: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,144 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.game import GameSession
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
nickname: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class GuestRequest(BaseModel):
|
||||
nickname: str = "游客"
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
token: str
|
||||
user_id: str
|
||||
nickname: str
|
||||
is_guest: bool
|
||||
|
||||
|
||||
def create_token(user_id: str) -> str:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return jwt.encode({"sub": user_id, "exp": expire, "v": 0}, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
async def get_current_user(authorization: str = Header(""), db: AsyncSession = Depends(get_db)) -> User:
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise HTTPException(401, "未登录")
|
||||
token = authorization[7:]
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
user_id = payload.get("sub")
|
||||
except JWTError:
|
||||
raise HTTPException(401, "无效的token")
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(401, "用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == req.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(400, "邮箱已注册")
|
||||
user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email=req.email,
|
||||
nickname=req.nickname,
|
||||
password_hash=pwd_context.hash(req.password),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return AuthResponse(token=create_token(user.id), user_id=user.id, nickname=user.nickname, is_guest=False)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == req.email))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.password_hash or not pwd_context.verify(req.password, user.password_hash):
|
||||
raise HTTPException(401, "邮箱或密码错误")
|
||||
return AuthResponse(token=create_token(user.id), user_id=user.id, nickname=user.nickname, is_guest=False)
|
||||
|
||||
|
||||
@router.post("/guest")
|
||||
async def guest_login(req: GuestRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
nickname=req.nickname,
|
||||
is_guest=True,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return AuthResponse(token=create_token(user.id), user_id=user.id, nickname=user.nickname, is_guest=True)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_profile(user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"nickname": user.nickname,
|
||||
"avatar_url": user.avatar_url,
|
||||
"is_guest": user.is_guest,
|
||||
"role": user.role,
|
||||
"game_count": user.game_count,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else "",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/profile")
|
||||
async def update_profile(data: dict, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
if "nickname" in data:
|
||||
user.nickname = data["nickname"]
|
||||
if "avatar_url" in data:
|
||||
user.avatar_url = data["avatar_url"]
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_history(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(GameSession).order_by(GameSession.created_at.desc()).limit(50)
|
||||
)
|
||||
games = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": g.id,
|
||||
"script_id": g.script_id,
|
||||
"status": g.status,
|
||||
"phase": g.phase,
|
||||
"started_at": g.started_at.isoformat() if g.started_at else "",
|
||||
"completed_at": g.completed_at.isoformat() if g.completed_at else "",
|
||||
"created_at": g.created_at.isoformat(),
|
||||
}
|
||||
for g in games
|
||||
]
|
||||
@@ -0,0 +1,694 @@
|
||||
import json
|
||||
import random
|
||||
import uuid
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.models.game import GameSession, SessionPlayer, ChatMessage, ClueState, Vote, GameStatus, MessageType
|
||||
from app.scripts.sample import get_script_by_id, get_script_by_title
|
||||
from app.agents.npc import NPCAgent
|
||||
from app.agents.dm import DMAgent
|
||||
|
||||
router = APIRouter(prefix="/api/games", tags=["games"])
|
||||
|
||||
active_connections: dict[str, list[WebSocket]] = {}
|
||||
game_locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def get_lock(game_id: str) -> asyncio.Lock:
|
||||
if game_id not in game_locks:
|
||||
game_locks[game_id] = asyncio.Lock()
|
||||
return game_locks[game_id]
|
||||
|
||||
|
||||
async def broadcast(game_id: str, message: dict):
|
||||
if game_id in active_connections:
|
||||
data = json.dumps(message, ensure_ascii=False)
|
||||
for ws in active_connections[game_id][:]:
|
||||
try:
|
||||
await ws.send_text(data)
|
||||
except Exception:
|
||||
active_connections[game_id].remove(ws)
|
||||
|
||||
|
||||
async def broadcast_to_role(game_id: str, target_role_id: str, message: dict):
|
||||
if game_id in active_connections:
|
||||
data = json.dumps(message, ensure_ascii=False)
|
||||
for ws in active_connections[game_id][:]:
|
||||
try:
|
||||
role_id = getattr(ws, "role_id", None)
|
||||
if role_id is None or role_id == target_role_id:
|
||||
await ws.send_text(data)
|
||||
except Exception:
|
||||
active_connections[game_id].remove(ws)
|
||||
|
||||
|
||||
class CreateGameRequest(BaseModel):
|
||||
script_id: str
|
||||
human_role_id: str = ""
|
||||
|
||||
|
||||
class JoinGameRequest(BaseModel):
|
||||
role_id: str
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
type: str = "public"
|
||||
content: str
|
||||
target_role_id: str | None = None
|
||||
sender_role_id: str = ""
|
||||
|
||||
|
||||
class VoteRequest(BaseModel):
|
||||
target_role_id: str
|
||||
reason: str = ""
|
||||
voter_role_id: str = ""
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_game(req: CreateGameRequest, db: AsyncSession = Depends(get_db)):
|
||||
script = get_script_by_id(req.script_id)
|
||||
if not script:
|
||||
raise HTTPException(404, "剧本不存在")
|
||||
game = GameSession(
|
||||
id=str(uuid.uuid4()),
|
||||
script_id=req.script_id,
|
||||
status=GameStatus.waiting.value,
|
||||
phase=script["phases"][0]["name"] if script["phases"] else "",
|
||||
config={"script": script},
|
||||
)
|
||||
db.add(game)
|
||||
await db.commit()
|
||||
|
||||
for role in script["roles"]:
|
||||
is_human = role["id"] == req.human_role_id
|
||||
sp = SessionPlayer(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=game.id,
|
||||
role_id=role["id"],
|
||||
role_name=role["name"],
|
||||
is_human=is_human,
|
||||
avatar_url="",
|
||||
)
|
||||
db.add(sp)
|
||||
|
||||
first_phase_clues = script["phases"][0].get("clues", []) if script["phases"] else []
|
||||
for clue_id in first_phase_clues:
|
||||
cs = ClueState(id=str(uuid.uuid4()), session_id=game.id, clue_id=clue_id, status="released")
|
||||
db.add(cs)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"game_id": game.id}
|
||||
|
||||
|
||||
@router.post("/{game_id}/join")
|
||||
async def join_game(game_id: str, req: JoinGameRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GameSession).where(GameSession.id == game_id))
|
||||
game = result.scalar_one_or_none()
|
||||
if not game:
|
||||
raise HTTPException(404, "游戏不存在")
|
||||
player = SessionPlayer(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=game_id,
|
||||
role_id=req.role_id,
|
||||
is_human=True,
|
||||
)
|
||||
db.add(player)
|
||||
await db.commit()
|
||||
|
||||
script = get_script_by_id(game.script_id) or {}
|
||||
for role in script.get("roles", []):
|
||||
if role["id"] == req.role_id:
|
||||
return {
|
||||
"player_id": player.id,
|
||||
"role_id": req.role_id,
|
||||
"role_name": role["name"],
|
||||
"public_profile": role["publicProfile"],
|
||||
"secret_profile": role["secretProfile"],
|
||||
"secret": role.get("secret", ""),
|
||||
"goal": role["goal"],
|
||||
}
|
||||
return {"player_id": player.id, "role_id": req.role_id}
|
||||
|
||||
|
||||
@router.post("/{game_id}/start")
|
||||
async def start_game(game_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GameSession).where(GameSession.id == game_id))
|
||||
game = result.scalar_one_or_none()
|
||||
if not game:
|
||||
raise HTTPException(404, "游戏不存在")
|
||||
|
||||
game.status = GameStatus.playing.value
|
||||
game.started_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
script = game.config.get("script", {}) if game.config else {}
|
||||
background = script.get("background", "")
|
||||
title = script.get("title", "")
|
||||
|
||||
dm_msg = ChatMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=game_id,
|
||||
sender_role_id="dm",
|
||||
sender_name="DM",
|
||||
message_type=MessageType.system.value,
|
||||
content=f"欢迎来到【{title}】。\n{background}\n\n请各位玩家阅读角色信息,做好准备后,在公屏中发送「准备好了」开始游戏。",
|
||||
phase_index=game.phase_index,
|
||||
)
|
||||
db.add(dm_msg)
|
||||
await db.commit()
|
||||
|
||||
await broadcast(game_id, {
|
||||
"type": "game_started",
|
||||
"data": {
|
||||
"phase": game.phase,
|
||||
"phase_index": game.phase_index,
|
||||
"message": {
|
||||
"id": dm_msg.id,
|
||||
"sender_name": "DM",
|
||||
"sender_role_id": "dm",
|
||||
"message_type": "system",
|
||||
"content": dm_msg.content,
|
||||
"created_at": dm_msg.created_at.isoformat(),
|
||||
}
|
||||
}
|
||||
})
|
||||
return {"status": "started", "phase": game.phase}
|
||||
|
||||
|
||||
@router.post("/{game_id}/chat")
|
||||
async def send_chat(game_id: str, req: ChatRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GameSession).where(GameSession.id == game_id))
|
||||
game = result.scalar_one_or_none()
|
||||
if not game:
|
||||
raise HTTPException(404, "游戏不存在")
|
||||
|
||||
sender_name = req.sender_role_id
|
||||
players_result = await db.execute(
|
||||
select(SessionPlayer).where(SessionPlayer.session_id == game_id, SessionPlayer.role_id == req.sender_role_id)
|
||||
)
|
||||
player = players_result.scalar_one_or_none()
|
||||
script = game.config.get("script", {}) if game.config else {}
|
||||
for role in script.get("roles", []):
|
||||
if role["id"] == req.sender_role_id:
|
||||
sender_name = role["name"]
|
||||
break
|
||||
|
||||
msg = ChatMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=game_id,
|
||||
sender_role_id=req.sender_role_id,
|
||||
sender_name=sender_name,
|
||||
message_type=req.type,
|
||||
content=req.content,
|
||||
target_role_id=req.target_role_id,
|
||||
phase_index=game.phase_index,
|
||||
)
|
||||
db.add(msg)
|
||||
|
||||
npc_responses = await _generate_npc_responses_inline(game_id, req, script, sender_name, game.phase, game.phase_index, db)
|
||||
for npc_msg_data in npc_responses:
|
||||
if npc_msg_data["_msg"] is not None:
|
||||
db.add(npc_msg_data["_msg"])
|
||||
await db.commit()
|
||||
|
||||
msg_data = {
|
||||
"type": "chat_message",
|
||||
"data": {
|
||||
"id": msg.id,
|
||||
"sender_role_id": req.sender_role_id,
|
||||
"sender_name": sender_name,
|
||||
"message_type": req.type,
|
||||
"content": req.content,
|
||||
"target_role_id": req.target_role_id,
|
||||
"phase_index": game.phase_index,
|
||||
"created_at": msg.created_at.isoformat(),
|
||||
}
|
||||
}
|
||||
|
||||
if req.type == "private" and req.target_role_id:
|
||||
await broadcast_to_role(game_id, req.sender_role_id, msg_data)
|
||||
await broadcast_to_role(game_id, req.target_role_id, msg_data)
|
||||
else:
|
||||
await broadcast(game_id, msg_data)
|
||||
|
||||
for npc in npc_responses:
|
||||
npc_broadcast = {k: v for k, v in npc.items() if k != "_msg"}
|
||||
if npc["data"]["message_type"] == "private" and npc["data"].get("target_role_id"):
|
||||
await broadcast_to_role(game_id, req.sender_role_id, npc_broadcast)
|
||||
await broadcast_to_role(game_id, npc["data"]["target_role_id"], npc_broadcast)
|
||||
else:
|
||||
await broadcast(game_id, npc_broadcast)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"message_id": msg.id,
|
||||
"npc_responses": [
|
||||
{k: v for k, v in n.items() if k != "_msg"}
|
||||
for n in npc_responses
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{game_id}/phase/advance")
|
||||
async def advance_phase(game_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GameSession).where(GameSession.id == game_id))
|
||||
game = result.scalar_one_or_none()
|
||||
if not game:
|
||||
raise HTTPException(404, "游戏不存在")
|
||||
|
||||
script = game.config.get("script", {}) if game.config else {}
|
||||
phases = script.get("phases", [])
|
||||
next_idx = game.phase_index + 1
|
||||
|
||||
if next_idx >= len(phases):
|
||||
return {"status": "already_at_end"}
|
||||
|
||||
game.phase_index = next_idx
|
||||
game.phase = phases[next_idx]["name"]
|
||||
await db.commit()
|
||||
|
||||
phase = phases[next_idx]
|
||||
msg = ChatMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=game_id,
|
||||
sender_role_id="dm",
|
||||
sender_name="DM",
|
||||
message_type=MessageType.system.value,
|
||||
content=f"【{phase['name']}】\n{phase['description']}\n\n{phase.get('publicInfo', '')}",
|
||||
phase_index=next_idx,
|
||||
)
|
||||
db.add(msg)
|
||||
await db.commit()
|
||||
|
||||
clues_to_release = phase.get("clues", [])
|
||||
for clue_id in clues_to_release:
|
||||
existing = await db.execute(
|
||||
select(ClueState).where(ClueState.session_id == game_id, ClueState.clue_id == clue_id)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
cs = ClueState(id=str(uuid.uuid4()), session_id=game_id, clue_id=clue_id, status="released")
|
||||
db.add(cs)
|
||||
await db.commit()
|
||||
|
||||
await broadcast(game_id, {
|
||||
"type": "phase_change",
|
||||
"data": {
|
||||
"phase": game.phase,
|
||||
"phase_index": next_idx,
|
||||
"message": {
|
||||
"id": msg.id,
|
||||
"sender_name": "DM",
|
||||
"sender_role_id": "dm",
|
||||
"message_type": "system",
|
||||
"content": msg.content,
|
||||
"created_at": msg.created_at.isoformat(),
|
||||
},
|
||||
"new_clues": clues_to_release,
|
||||
}
|
||||
})
|
||||
return {"status": "ok", "phase": game.phase, "phase_index": next_idx}
|
||||
|
||||
|
||||
@router.post("/{game_id}/clue/{clue_id}/reveal")
|
||||
async def reveal_clue(game_id: str, clue_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(ClueState).where(ClueState.session_id == game_id, ClueState.clue_id == clue_id)
|
||||
)
|
||||
clue_state = result.scalar_one_or_none()
|
||||
if not clue_state:
|
||||
raise HTTPException(404, "线索不存在或未发放")
|
||||
|
||||
clue_state.status = "revealed"
|
||||
await db.commit()
|
||||
|
||||
game_result = await db.execute(select(GameSession).where(GameSession.id == game_id))
|
||||
game = game_result.scalar_one()
|
||||
script = game.config.get("script", {}) if game.config else {}
|
||||
clue_info = None
|
||||
for c in script.get("clues", []):
|
||||
if c["id"] == clue_id:
|
||||
clue_info = c
|
||||
break
|
||||
|
||||
await broadcast(game_id, {
|
||||
"type": "clue_revealed",
|
||||
"data": {"clue_id": clue_id, "clue": clue_info}
|
||||
})
|
||||
return {"status": "revealed", "clue": clue_info}
|
||||
|
||||
|
||||
@router.post("/{game_id}/vote")
|
||||
async def submit_vote(game_id: str, req: VoteRequest, db: AsyncSession = Depends(get_db)):
|
||||
voter = await db.execute(
|
||||
select(SessionPlayer).where(SessionPlayer.session_id == game_id, SessionPlayer.role_id == req.voter_role_id)
|
||||
)
|
||||
voter_player = voter.scalar_one_or_none()
|
||||
if not voter_player:
|
||||
raise HTTPException(404, "投票者不存在")
|
||||
|
||||
target = await db.execute(
|
||||
select(SessionPlayer).where(SessionPlayer.session_id == game_id, SessionPlayer.role_id == req.target_role_id)
|
||||
)
|
||||
target_player = target.scalar_one_or_none()
|
||||
if not target_player:
|
||||
raise HTTPException(404, "投票目标不存在")
|
||||
|
||||
vote = Vote(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=game_id,
|
||||
voter_id=voter_player.id,
|
||||
target_id=target_player.id,
|
||||
reason=req.reason,
|
||||
)
|
||||
db.add(vote)
|
||||
await db.commit()
|
||||
|
||||
await broadcast(game_id, {
|
||||
"type": "vote_cast",
|
||||
"data": {
|
||||
"voter_role_id": req.voter_role_id,
|
||||
"target_role_id": req.target_role_id,
|
||||
"voter_name": voter_player.role_name,
|
||||
}
|
||||
})
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/{game_id}/vote/end")
|
||||
async def end_vote(game_id: str, db: AsyncSession = Depends(get_db)):
|
||||
game_result = await db.execute(select(GameSession).where(GameSession.id == game_id))
|
||||
game = game_result.scalar_one_or_none()
|
||||
if not game:
|
||||
raise HTTPException(404, "游戏不存在")
|
||||
script = game.config.get("script", {}) if game.config else {}
|
||||
|
||||
existing_votes_result = await db.execute(select(Vote).where(Vote.session_id == game_id))
|
||||
existing_votes = existing_votes_result.scalars().all()
|
||||
existing_voter_ids = {v.voter_id for v in existing_votes}
|
||||
|
||||
players_result = await db.execute(select(SessionPlayer).where(SessionPlayer.session_id == game_id))
|
||||
players_list = players_result.scalars().all()
|
||||
players = {p.id: p for p in players_list}
|
||||
|
||||
clues_result = await db.execute(select(ClueState).where(ClueState.session_id == game_id, ClueState.status == "released"))
|
||||
released_clue_states = clues_result.scalars().all()
|
||||
released_clue_ids = {c.clue_id for c in released_clue_states}
|
||||
all_clues = {c["id"]: c for c in script.get("clues", [])}
|
||||
known_clues = [all_clues[cid]["description"] for cid in released_clue_ids if cid in all_clues]
|
||||
|
||||
npc_players = [p for p in players_list if not p.is_human and p.id not in existing_voter_ids]
|
||||
all_roles = script.get("roles", [])
|
||||
role_dict = {r["id"]: r for r in all_roles}
|
||||
|
||||
for npc in npc_players:
|
||||
targets = [p for p in players_list if p.id != npc.id]
|
||||
if not targets:
|
||||
continue
|
||||
|
||||
npc_role = role_dict.get(npc.role_id)
|
||||
if npc_role:
|
||||
agent = NPCAgent(npc_role, script.get("background", ""), "")
|
||||
vote_reason = await agent.respond("投票阶段", [], "请投票选出你最怀疑的人,并说明理由",
|
||||
known_clues=known_clues, all_roles=all_roles)
|
||||
vote_content = vote_reason.get("content", "")
|
||||
target_id = random.choice(targets).id
|
||||
for t in targets:
|
||||
if t.role_name and t.role_name in vote_content:
|
||||
target_id = t.id
|
||||
break
|
||||
else:
|
||||
target_id = random.choice(targets).id
|
||||
|
||||
vote = Vote(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=game_id,
|
||||
voter_id=npc.id,
|
||||
target_id=target_id,
|
||||
reason="",
|
||||
)
|
||||
db.add(vote)
|
||||
|
||||
target_player = players[target_id]
|
||||
await broadcast(game_id, {
|
||||
"type": "vote_cast",
|
||||
"data": {
|
||||
"voter_role_id": npc.role_id,
|
||||
"voter_name": npc.role_name,
|
||||
"target_role_id": target_player.role_id,
|
||||
"target_name": target_player.role_name,
|
||||
}
|
||||
})
|
||||
await db.commit()
|
||||
|
||||
votes_result = await db.execute(select(Vote).where(Vote.session_id == game_id))
|
||||
votes = votes_result.scalars().all()
|
||||
|
||||
tally: dict[str, list[str]] = {}
|
||||
for v in votes:
|
||||
target_role = players[v.target_id].role_id if v.target_id in players else "unknown"
|
||||
voter_role = players[v.voter_id].role_id if v.voter_id in players else "unknown"
|
||||
if target_role not in tally:
|
||||
tally[target_role] = []
|
||||
tally[target_role].append(voter_role)
|
||||
|
||||
max_votes = 0
|
||||
accused_role_id = ""
|
||||
for role_id, voters in tally.items():
|
||||
if len(voters) > max_votes:
|
||||
max_votes = len(voters)
|
||||
accused_role_id = role_id
|
||||
|
||||
accused_name = accused_role_id
|
||||
for role in script.get("roles", []):
|
||||
if role["id"] == accused_role_id:
|
||||
accused_name = role["name"]
|
||||
break
|
||||
|
||||
truth = script.get("truth", "")
|
||||
|
||||
game.status = GameStatus.completed.value
|
||||
game.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
result_msg = ChatMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=game_id,
|
||||
sender_role_id="dm",
|
||||
sender_name="DM",
|
||||
message_type=MessageType.system.value,
|
||||
content=f"投票结束!最多票指向:{accused_name}({max_votes}票)\n\n真相:{truth}",
|
||||
phase_index=game.phase_index,
|
||||
)
|
||||
db.add(result_msg)
|
||||
await db.commit()
|
||||
|
||||
await broadcast(game_id, {
|
||||
"type": "vote_result",
|
||||
"data": {
|
||||
"tally": {role_id: voters for role_id, voters in tally.items()},
|
||||
"accused_role_id": accused_role_id,
|
||||
"accused_name": accused_name,
|
||||
"truth": truth,
|
||||
"game_id": game_id,
|
||||
"message": {
|
||||
"id": result_msg.id,
|
||||
"sender_name": "DM",
|
||||
"sender_role_id": "dm",
|
||||
"message_type": "system",
|
||||
"content": result_msg.content,
|
||||
"created_at": result_msg.created_at.isoformat(),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if game_id in active_connections:
|
||||
del active_connections[game_id]
|
||||
|
||||
return {"status": "completed", "accused": accused_name, "truth": truth}
|
||||
|
||||
|
||||
@router.get("/{game_id}/state")
|
||||
async def get_game_state(game_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GameSession).where(GameSession.id == game_id))
|
||||
game = result.scalar_one_or_none()
|
||||
if not game:
|
||||
raise HTTPException(404, "游戏不存在")
|
||||
clues_result = await db.execute(select(ClueState).where(ClueState.session_id == game_id))
|
||||
clues = clues_result.scalars().all()
|
||||
players_result = await db.execute(select(SessionPlayer).where(SessionPlayer.session_id == game_id))
|
||||
players = players_result.scalars().all()
|
||||
msgs_result = await db.execute(
|
||||
select(ChatMessage).where(ChatMessage.session_id == game_id).order_by(ChatMessage.created_at)
|
||||
)
|
||||
msgs = msgs_result.scalars().all()
|
||||
|
||||
script = game.config.get("script", {}) if game.config else {}
|
||||
return {
|
||||
"id": game.id,
|
||||
"script_id": game.script_id,
|
||||
"status": game.status,
|
||||
"phase": game.phase,
|
||||
"phase_index": game.phase_index,
|
||||
"script": script,
|
||||
"players": [
|
||||
{"id": p.id, "role_id": p.role_id, "role_name": p.role_name, "is_human": p.is_human, "status": p.status}
|
||||
for p in players
|
||||
],
|
||||
"clues": [{"clue_id": c.clue_id, "status": c.status} for c in clues],
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id, "sender_role_id": m.sender_role_id, "sender_name": m.sender_name,
|
||||
"message_type": m.message_type, "content": m.content, "target_role_id": m.target_role_id,
|
||||
"clue_id": m.clue_id, "phase_index": m.phase_index,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
for m in msgs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{game_id}/replay")
|
||||
async def get_replay(game_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GameSession).where(GameSession.id == game_id))
|
||||
game = result.scalar_one_or_none()
|
||||
if not game:
|
||||
raise HTTPException(404, "游戏不存在")
|
||||
msgs_result = await db.execute(
|
||||
select(ChatMessage).where(ChatMessage.session_id == game_id).order_by(ChatMessage.created_at)
|
||||
)
|
||||
msgs = msgs_result.scalars().all()
|
||||
votes_result = await db.execute(
|
||||
select(Vote).where(Vote.session_id == game_id)
|
||||
)
|
||||
votes = votes_result.scalars().all()
|
||||
players_result = await db.execute(select(SessionPlayer).where(SessionPlayer.session_id == game_id))
|
||||
players = {p.id: p for p in players_result.scalars().all()}
|
||||
script = game.config.get("script", {}) if game.config else {}
|
||||
|
||||
vote_details = []
|
||||
for v in votes:
|
||||
voter = players.get(v.voter_id)
|
||||
target = players.get(v.target_id)
|
||||
vote_details.append({
|
||||
"voter_name": voter.role_name if voter else "unknown",
|
||||
"target_name": target.role_name if target else "unknown",
|
||||
"reason": v.reason,
|
||||
})
|
||||
|
||||
return {
|
||||
"game_id": game_id,
|
||||
"script_title": script.get("title", game.script_id),
|
||||
"truth": script.get("truth", ""),
|
||||
"roles": [
|
||||
{"id": r["id"], "name": r["name"], "publicProfile": r["publicProfile"],
|
||||
"secretProfile": r["secretProfile"], "goal": r["goal"]}
|
||||
for r in script.get("roles", [])
|
||||
],
|
||||
"messages": [
|
||||
{"id": m.id, "sender_name": m.sender_name, "content": m.content,
|
||||
"type": m.message_type, "time": m.created_at.isoformat()}
|
||||
for m in msgs
|
||||
],
|
||||
"votes": vote_details,
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/ws/{game_id}")
|
||||
async def game_websocket(websocket: WebSocket, game_id: str, role_id: str = Query("")):
|
||||
await websocket.accept()
|
||||
websocket.role_id = role_id
|
||||
if game_id not in active_connections:
|
||||
active_connections[game_id] = []
|
||||
active_connections[game_id].append(websocket)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
msg = json.loads(data)
|
||||
msg_type = msg.get("type", "")
|
||||
msg_data = msg.get("data", {})
|
||||
|
||||
if msg_type == "ping":
|
||||
await websocket.send_text(json.dumps({"type": "pong"}))
|
||||
except WebSocketDisconnect:
|
||||
if game_id in active_connections and websocket in active_connections[game_id]:
|
||||
active_connections[game_id].remove(websocket)
|
||||
except Exception:
|
||||
if game_id in active_connections and websocket in active_connections[game_id]:
|
||||
active_connections[game_id].remove(websocket)
|
||||
|
||||
|
||||
async def _generate_npc_responses_inline(game_id: str, req, script: dict, sender_name: str, phase_name: str, phase_index: int, db: AsyncSession) -> list[dict]:
|
||||
npc_responses = []
|
||||
try:
|
||||
npc_roles = [r for r in script.get("roles", []) if r["id"] != req.sender_role_id]
|
||||
random.shuffle(npc_roles)
|
||||
|
||||
if not npc_roles:
|
||||
return npc_responses
|
||||
|
||||
recent_msgs_result = await db.execute(
|
||||
select(ChatMessage).where(ChatMessage.session_id == game_id).order_by(ChatMessage.created_at.desc()).limit(10)
|
||||
)
|
||||
recent_msgs = recent_msgs_result.scalars().all()
|
||||
recent_list = [{"sender_name": m.sender_name, "content": m.content} for m in recent_msgs]
|
||||
|
||||
clues_result = await db.execute(select(ClueState).where(ClueState.session_id == game_id, ClueState.status == "released"))
|
||||
released_clue_states = clues_result.scalars().all()
|
||||
released_clue_ids = {c.clue_id for c in released_clue_states}
|
||||
all_clues = {c["id"]: c for c in script.get("clues", [])}
|
||||
known_clues = [all_clues[cid]["description"] for cid in released_clue_ids if cid in all_clues]
|
||||
|
||||
phases = script.get("phases", [])
|
||||
current_phase = phases[phase_index] if phase_index < len(phases) else None
|
||||
phase_goal = current_phase["description"] if current_phase else ""
|
||||
|
||||
all_roles = script.get("roles", [])
|
||||
|
||||
for npc_role in npc_roles[:2]:
|
||||
agent = NPCAgent(npc_role, script.get("background", ""), "")
|
||||
is_private = req.type == "private" and req.target_role_id == npc_role["id"]
|
||||
response = await agent.respond(phase_name, recent_list, req.content, is_private,
|
||||
known_clues=known_clues, phase_goal=phase_goal, all_roles=all_roles)
|
||||
content = response.get("content", "")
|
||||
action = response.get("action", "")
|
||||
|
||||
if not content or action == "remain_silent":
|
||||
continue
|
||||
|
||||
now = datetime.utcnow()
|
||||
npc_msg = ChatMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=game_id,
|
||||
sender_role_id=npc_role["id"],
|
||||
sender_name=npc_role["name"],
|
||||
message_type="private" if is_private else "public",
|
||||
content=content,
|
||||
target_role_id=req.sender_role_id if is_private else None,
|
||||
phase_index=phase_index,
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
npc_responses.append({
|
||||
"_msg": npc_msg,
|
||||
"type": "chat_message",
|
||||
"data": {
|
||||
"id": npc_msg.id,
|
||||
"sender_role_id": npc_role["id"],
|
||||
"sender_name": npc_role["name"],
|
||||
"message_type": npc_msg.message_type,
|
||||
"content": content,
|
||||
"target_role_id": npc_msg.target_role_id,
|
||||
"phase_index": phase_index,
|
||||
"created_at": npc_msg.created_at.isoformat(),
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return npc_responses
|
||||
@@ -0,0 +1,45 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.models.script import Script
|
||||
from app.scripts.sample import SCRIPTS
|
||||
|
||||
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
|
||||
|
||||
|
||||
class ScriptListItem(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
type: str
|
||||
difficulty: int
|
||||
min_players: int
|
||||
max_players: int
|
||||
duration: str
|
||||
background: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_scripts():
|
||||
items = []
|
||||
for s in SCRIPTS:
|
||||
items.append(ScriptListItem(
|
||||
id=s["id"],
|
||||
title=s["title"],
|
||||
type=s["type"],
|
||||
difficulty=s["difficulty"],
|
||||
min_players=s["playerCount"]["min"],
|
||||
max_players=s["playerCount"]["max"],
|
||||
duration=s["duration"],
|
||||
background=s["background"],
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{script_id}")
|
||||
async def get_script(script_id: str):
|
||||
for s in SCRIPTS:
|
||||
if s["id"] == script_id:
|
||||
return s
|
||||
raise HTTPException(404, "剧本不存在")
|
||||
@@ -0,0 +1,23 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
_data_path = os.path.join(os.path.dirname(__file__), 'sample_data.json')
|
||||
|
||||
with open(_data_path, 'r', encoding='utf-8') as f:
|
||||
SCRIPTS = json.load(f)
|
||||
|
||||
BUILTIN_SCRIPTS = SCRIPTS
|
||||
|
||||
|
||||
def get_script_by_id(script_id: str) -> dict | None:
|
||||
for s in SCRIPTS:
|
||||
if s.get("id") == script_id:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def get_script_by_title(title: str) -> dict | None:
|
||||
for s in SCRIPTS:
|
||||
if s["title"] == title:
|
||||
return s
|
||||
return None
|
||||
@@ -0,0 +1,710 @@
|
||||
[
|
||||
{
|
||||
"id": "script_fog",
|
||||
"title": "雾港庄园谋杀案",
|
||||
"type": "hardcore",
|
||||
"difficulty": 2,
|
||||
"playerCount": {
|
||||
"min": 1,
|
||||
"max": 5
|
||||
},
|
||||
"duration": "60-90min",
|
||||
"background": "1940年深秋,雾港庄园的主人——富商爱德华·格雷伯爵在一场暴雨夜中被发现死于书房。房门反锁,窗户紧闭,这是一间密室。在座的每一位客人都有嫌疑,每一个微笑背后都藏着秘密。",
|
||||
"phases": [
|
||||
{
|
||||
"name": "第一幕:暴雨夜宴",
|
||||
"description": "暴雨之夜,格雷伯爵邀请诸位宾客参加晚宴。席间伯爵面色凝重,似乎在隐藏什么。午夜钟声响起,仆人发现伯爵死于书房。",
|
||||
"publicInfo": "所有人都在客厅等待,仆人老张去书房叫伯爵时发现尸体。",
|
||||
"clues": [
|
||||
"clue_1",
|
||||
"clue_2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第二幕:迷雾重重",
|
||||
"description": "众人震惊之余,开始回忆当晚的细节。每个人似乎都在隐瞒什么。",
|
||||
"publicInfo": "法医初步判断死亡时间为晚上10点到11点之间,死因是中毒。",
|
||||
"clues": [
|
||||
"clue_3",
|
||||
"clue_4",
|
||||
"clue_5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第三幕:真相浮现",
|
||||
"description": "随着调查深入,更多线索浮出水面。密室的真相、每个人的动机,一切即将揭晓。",
|
||||
"publicInfo": "书房的门锁是老旧式样的机械锁,从内部反锁需要转动钥匙。",
|
||||
"clues": [
|
||||
"clue_6",
|
||||
"clue_7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "最终幕:投票与结局",
|
||||
"description": "所有线索已呈现,是时候指认真凶了。",
|
||||
"publicInfo": "请各位做出最终判断。",
|
||||
"clues": []
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"id": "role_detective",
|
||||
"name": "侦探·林默",
|
||||
"publicProfile": "一位来自伦敦的私家侦探,受伯爵邀请前来参加晚宴。",
|
||||
"secretProfile": "你是爱德华的旧友,半年前曾帮他处理过一起勒索案。你怀疑爱德华最近又被人威胁了。",
|
||||
"secret": "你其实一直在暗中调查爱德华,怀疑他与一桩军火走私案有关。",
|
||||
"goal": "查明真凶,揭露真相。",
|
||||
"winCondition": "正确指认凶手",
|
||||
"personality": "冷静理智、善于观察"
|
||||
},
|
||||
{
|
||||
"id": "role_white",
|
||||
"name": "白若溪",
|
||||
"publicProfile": "伯爵的私人秘书,跟随伯爵五年,深得信任。",
|
||||
"secretProfile": "你深爱着爱德华,但他却对你不冷不热。最近你发现他准备解雇你,因为你知道的太多了。",
|
||||
"secret": "你偷了伯爵保险柜里的一封密信,信中揭露了军火交易的细节。",
|
||||
"goal": "保护自己,不要让任何人发现你偷了信。",
|
||||
"winCondition": "不被指认为凶手",
|
||||
"personality": "温柔优雅但内心焦虑"
|
||||
},
|
||||
{
|
||||
"id": "role_black",
|
||||
"name": "黑蛇",
|
||||
"publicProfile": "一位神秘的东方商人,当晚第一次拜访庄园。",
|
||||
"secretProfile": "你是爱德华的生意伙伴,但最近一批货出了问题,爱德华拒绝赔偿你的损失。",
|
||||
"secret": "你今晚在伯爵的酒杯里下了安眠药,本想等他睡着后去书房偷一份合约,但没想到他死了。",
|
||||
"goal": "洗脱嫌疑,不要让任何人知道你下药的事。",
|
||||
"winCondition": "不被指认为凶手",
|
||||
"personality": "深沉狡诈、善于言辞"
|
||||
},
|
||||
{
|
||||
"id": "role_red",
|
||||
"name": "红玫瑰",
|
||||
"publicProfile": "一位过气的歌剧演员,自称是伯爵的远房表妹。",
|
||||
"secretProfile": "你根本不是伯爵的表妹。你是一个骗子,想利用伯爵的社交圈东山再起。爱德华已经发现了你的真实身份。",
|
||||
"secret": "你曾在10点左右去书房找伯爵求情,发现他正在和一个神秘人激烈争吵。你躲在门外偷听,听到了“军火”和“灭口”等词。",
|
||||
"goal": "维持你的假身份,找出伯爵之死的真相趁机牟利。",
|
||||
"winCondition": "不被指认为凶手",
|
||||
"personality": "戏剧化、爱出风头"
|
||||
},
|
||||
{
|
||||
"id": "role_green",
|
||||
"name": "翡翠",
|
||||
"publicProfile": "庄园的女仆,已经在庄园工作了三年。",
|
||||
"secretProfile": "你的弟弟曾经是爱德华工厂的工人,因工伤去世,爱德华只赔了一笔小钱。你来到庄园做女仆,是为了收集爱德华的罪证。",
|
||||
"secret": "你在晚宴开始前清理书房时,在书架后面发现了一把手枪。你偷偷藏了起来。",
|
||||
"goal": "为弟弟讨回公道,找出爱德华的犯罪证据。",
|
||||
"winCondition": "找出爱德华的全部罪证",
|
||||
"personality": "沉默寡言、内心坚毅"
|
||||
},
|
||||
{
|
||||
"id": "role_old_zhang",
|
||||
"name": "老张",
|
||||
"publicProfile": "庄园的老管家,服务了格雷家族三十年。",
|
||||
"secretProfile": "你看着爱德华长大,知道他所有的秘密。你一直帮他掩盖那些见不得光的事,但最近你良心不安。",
|
||||
"secret": "是你帮爱德华处理了那封勒索信的送信人。你把这个秘密埋在心底。",
|
||||
"goal": "保护庄园的名誉,但也要守住自己的秘密。",
|
||||
"winCondition": "真相不被完全揭开",
|
||||
"personality": "忠诚但矛盾、老谋深算"
|
||||
}
|
||||
],
|
||||
"clues": [
|
||||
{
|
||||
"id": "clue_1",
|
||||
"name": "破碎的酒杯",
|
||||
"description": "书房地毯上有一个破碎的高脚杯,检测出残留的安眠药成分。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "clue_2",
|
||||
"name": "反锁的门",
|
||||
"description": "书房的门从内部反锁,钥匙还插在锁孔里。但窗户有一道缝隙,勉强可以穿过一根细线。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "clue_3",
|
||||
"name": "神秘信件",
|
||||
"description": "书桌上有一封没有寄出的信,收件人是“伦敦警察厅”,内容提到“军火走私”和“灭口”。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "clue_4",
|
||||
"name": "壁炉里的灰烬",
|
||||
"description": "壁炉里有烧毁的文件残片,依稀可辨“合约”和“5000英镑”字样。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "clue_5",
|
||||
"name": "时间线记录",
|
||||
"description": "根据众人的证词整理的时间线:8点晚宴开始,9点伯爵离席去书房,10点半红玫瑰去书房,11点黑蛇去书房,11点半老张发现尸体。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "clue_6",
|
||||
"name": "书架后的手枪",
|
||||
"description": "书架后面发现一把手枪,已经擦去了指纹。经检测,这把枪近期发射过。",
|
||||
"phase": 3
|
||||
},
|
||||
{
|
||||
"id": "clue_7",
|
||||
"name": "密室的真相",
|
||||
"description": "仔细观察门锁结构,发现可以用鱼线从门缝下方套住钥匙,从外部反锁房门。",
|
||||
"phase": 3
|
||||
}
|
||||
],
|
||||
"truth": "真凶是老张。他利用鱼线从门缝制造了密室,在伯爵的酒杯里下毒。动机:伯爵发现了老张三十年前的一桩旧案,以此要挟他。老张为了保护自己,先下手为强。"
|
||||
},
|
||||
{
|
||||
"id": "script_zero",
|
||||
"title": "零号舱的悖论",
|
||||
"type": "hardcore",
|
||||
"difficulty": 4,
|
||||
"playerCount": {
|
||||
"min": 1,
|
||||
"max": 5
|
||||
},
|
||||
"duration": "90-120min",
|
||||
"background": "公元2157年,人类第一艘超光速飞船“零号舱”在试飞中消失。72小时后,飞船突然出现在木星轨道上,船体完好但全员失忆。更诡异的是,船员们的记忆碎片拼接后发现——他们中有一个人,根本不属于这个时空。",
|
||||
"phases": [
|
||||
{
|
||||
"name": "第一幕:失忆的船员",
|
||||
"description": "你在飞船上醒来,发现自己失去了部分记忆。面前是5个同样茫然的同伴。飞船AI报告:有人篡改了航行日志。",
|
||||
"publicInfo": "飞船AI显示航行日志被篡改,最后一个条目写着“他不是我们中的一员”。",
|
||||
"clues": [
|
||||
"zc_clue_1",
|
||||
"zc_clue_2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第二幕:记忆碎片",
|
||||
"description": "随着交流深入,每个人的记忆开始恢复片段。但奇怪的是,有些人的记忆互相矛盾。",
|
||||
"publicInfo": "船上有5人:船长、工程师、医疗官、生物学家、通讯员。其中一人的记忆与其他四人不在同一条时间线。",
|
||||
"clues": [
|
||||
"zc_clue_3",
|
||||
"zc_clue_4",
|
||||
"zc_clue_5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第三幕:悖论的核心",
|
||||
"description": "计算显示,超光速飞行导致了时间裂缝。两个平行宇宙的人在那一刻被融合到了一起。",
|
||||
"publicInfo": "飞船AI复原了部分航行数据,证明发生过时空异常。",
|
||||
"clues": [
|
||||
"zc_clue_6",
|
||||
"zc_clue_7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "最终幕:投票与结局",
|
||||
"description": "必须找出那个不属于这个时空的人,否则飞船的能量核心将在15分钟后过载。",
|
||||
"publicInfo": "请做出最终判断。",
|
||||
"clues": []
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"id": "zc_captain",
|
||||
"name": "艾伦船长",
|
||||
"publicProfile": "“零号舱”的船长,深空探索计划的王牌飞行员。",
|
||||
"secretProfile": "你记得起飞前收到过一封密令:这次飞行有秘密任务——测试一种量子纠缠通讯装置。但你在飞行日志里找不到任何记录。",
|
||||
"secret": "你其实是另一个平行宇宙的艾伦船长。在那个宇宙,飞行失败了,全员遇难。你不知道为什么你还活着。",
|
||||
"goal": "找出时空异常者,保护飞船。",
|
||||
"winCondition": "正确投出异常者",
|
||||
"personality": "果断坚毅、有领导力"
|
||||
},
|
||||
{
|
||||
"id": "zc_engineer",
|
||||
"name": "伊娃工程师",
|
||||
"publicProfile": "天才工程师,负责飞船动力系统。",
|
||||
"secretProfile": "你在飞行前偷偷在飞船主控系统里植入了一个后门程序——为了监视船长的秘密任务。",
|
||||
"secret": "你发现飞船的能量读数异常:实际消耗比记录多了300%。有人在偷偷使用额外能量。",
|
||||
"goal": "弄清楚秘密任务的真相。",
|
||||
"winCondition": "查明全部真相",
|
||||
"personality": "聪明谨慎、略带神经质"
|
||||
},
|
||||
{
|
||||
"id": "zc_medic",
|
||||
"name": "王医疗官",
|
||||
"publicProfile": "飞船医疗官,也是心理学专家。",
|
||||
"secretProfile": "你在飞行前注意到所有船员都有轻微的记忆偏差,但你以为只是压力导致的。",
|
||||
"secret": "你给自己做了记忆扫描,发现自己有一段被植入的虚假记忆——关于某个船员的背景。",
|
||||
"goal": "保护船员心理健康,找出真相。",
|
||||
"winCondition": "正确投出异常者",
|
||||
"personality": "温和理性、善于倾听"
|
||||
},
|
||||
{
|
||||
"id": "zc_bio",
|
||||
"name": "萨沙生物学家",
|
||||
"publicProfile": "研究外星微生物的科学家,本次飞行是为了采集木卫二冰层下的样本。",
|
||||
"secretProfile": "你其实不是真正的萨沙。真正的萨沙在起飞前三天失踪了,你是被秘密机构派来顶替的。",
|
||||
"secret": "你知道零号舱的真正任务:寻找外星生命样本——但不能让其他船员知道。",
|
||||
"goal": "完成样本采集任务,不暴露身份。",
|
||||
"winCondition": "不被发现真实身份",
|
||||
"personality": "神秘疏离、知识渊博"
|
||||
},
|
||||
{
|
||||
"id": "zc_comms",
|
||||
"name": "李通讯官",
|
||||
"publicProfile": "飞船通讯官,负责与地球保持联系。",
|
||||
"secretProfile": "你发现通讯系统被人为干扰了。地球发来的信号中有重复的摩斯密码,内容是“小心”。",
|
||||
"secret": "你在失忆前最后一刻发送了一条求救信号。但现在通讯系统显示:信号从未发出过。",
|
||||
"goal": "恢复通讯,向地球报告情况。",
|
||||
"winCondition": "让飞船安全返航",
|
||||
"personality": "活泼外向、容易紧张"
|
||||
}
|
||||
],
|
||||
"clues": [
|
||||
{
|
||||
"id": "zc_clue_1",
|
||||
"name": "矛盾的照片",
|
||||
"description": "船员合影中,有一个人出现在了不该出现的位置——合影拍摄时他应该在飞船另一侧。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "zc_clue_2",
|
||||
"name": "篡改的日志",
|
||||
"description": "航行日志被修改了37次,修改者ID显示为“所有人”。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "zc_clue_3",
|
||||
"name": "能量异常",
|
||||
"description": "飞船能量消耗记录显示,有额外的300%能量被消耗,但找不到去向。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "zc_clue_4",
|
||||
"name": "双重记忆",
|
||||
"description": "两个船员对同一事件的记忆完全不同——一个记得船长在驾驶舱,另一个记得船长在餐厅。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "zc_clue_5",
|
||||
"name": "量子纠缠信号",
|
||||
"description": "飞船接收到一组量子纠缠信号,编码方式不属于已知的任何人类通讯协议。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "zc_clue_6",
|
||||
"name": "平行宇宙理论",
|
||||
"description": "飞船AI数据库中有被加密访问的记录:关于平行宇宙融合的物理学论文,访问时间正是起飞前。",
|
||||
"phase": 3
|
||||
},
|
||||
{
|
||||
"id": "zc_clue_7",
|
||||
"name": "最后的录像",
|
||||
"description": "在飞船黑匣子里发现一段录像:一个船员对着镜头说“我是唯一真实的人”,随后录像被删除。",
|
||||
"phase": 3
|
||||
}
|
||||
],
|
||||
"truth": "真正的异常者是艾伦船长。他来自另一个平行宇宙——在那个宇宙中零号舱爆炸了,他的意识在爆炸瞬间穿越到了这个宇宙的艾伦身上。两个艾伦的意识在同一具身体里共存,导致记忆混乱。"
|
||||
},
|
||||
{
|
||||
"id": "script_photo",
|
||||
"title": "遗忘照相馆",
|
||||
"type": "emotional",
|
||||
"difficulty": 1,
|
||||
"playerCount": {
|
||||
"min": 1,
|
||||
"max": 4
|
||||
},
|
||||
"duration": "45-60min",
|
||||
"background": "在城市的老街拐角,有一家不起眼的照相馆。店主是一位老人,据说他拍的照片能让人看到最想念的人。每个走进这家照相馆的人,都带着一段未了的故事。",
|
||||
"phases": [
|
||||
{
|
||||
"name": "第一幕:走进照相馆",
|
||||
"description": "你推开那扇吱呀作响的木门,风铃响起。店内陈列着许多黑白照片,每一张都像在诉说一个故事。",
|
||||
"publicInfo": "照相馆内光线昏暗,墙上挂满了照片。柜台后坐着一位白发老人。",
|
||||
"clues": [
|
||||
"ph_clue_1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第二幕:照片中的秘密",
|
||||
"description": "老人让你挑选一张照片。你伸手触碰的瞬间,照片中的人影开始晃动——你看到了自己最深处的记忆。",
|
||||
"publicInfo": "每一张照片都对应着一个人内心最深的牵挂。",
|
||||
"clues": [
|
||||
"ph_clue_2",
|
||||
"ph_clue_3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第三幕:未说出口的话",
|
||||
"description": "照片中的人开始说话,那是你一直想见却再也见不到的人。你有机会说出当年没说完的话。",
|
||||
"publicInfo": "照片中的影像只能持续十分钟。",
|
||||
"clues": [
|
||||
"ph_clue_4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "最终幕:告别",
|
||||
"description": "十分钟到了,照片中的人影渐渐淡去。但你知道,有些话终于说出了口。",
|
||||
"publicInfo": "老人微笑着看着你,眼角有泪光。",
|
||||
"clues": []
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"id": "ph_old_man",
|
||||
"name": "照相馆老人",
|
||||
"publicProfile": "照相馆的老板,一位白发苍苍的老人,眼神深邃。",
|
||||
"secretProfile": "你其实已经去世十年了。这家照相馆是你留在人间的执念——帮助那些有遗憾的人完成告别。",
|
||||
"secret": "每一张照片都是你用自己的记忆编织的幻象。每帮一个人,你的存在就会消散一分。",
|
||||
"goal": "在彻底消失之前,帮助尽可能多的人。",
|
||||
"winCondition": "帮助客人完成心愿",
|
||||
"personality": "慈祥智慧、略带忧伤"
|
||||
},
|
||||
{
|
||||
"id": "ph_guest_1",
|
||||
"name": "寻女的母亲",
|
||||
"publicProfile": "一位中年妇女,神色憔悴,手中紧握一张泛黄的全家福。",
|
||||
"secretProfile": "你的女儿在五年前的一场事故中去世了。你一直无法接受这个事实,每年她的生日你都会来这家照相馆。",
|
||||
"secret": "你今天带来了女儿最喜欢的那条红围巾。你想再给她围上一次。",
|
||||
"goal": "再见女儿一面,告诉她妈妈永远爱她。",
|
||||
"winCondition": "完成与女儿的最后告别",
|
||||
"personality": "温柔坚强、隐忍"
|
||||
},
|
||||
{
|
||||
"id": "ph_guest_2",
|
||||
"name": "愧疚的丈夫",
|
||||
"publicProfile": "一位三十多岁的男人,西装革履但满脸疲惫。",
|
||||
"secretProfile": "你的妻子在生宝宝时难产去世了。这几年你一直忙于工作,用忙碌麻痹自己。",
|
||||
"secret": "你从来没敢给宝宝看妈妈的照片。你怕自己先哭出来。",
|
||||
"goal": "告诉妻子你有多想她,告诉她宝宝长得像她。",
|
||||
"winCondition": "说出藏在心里的话",
|
||||
"personality": "表面坚强、内心脆弱"
|
||||
},
|
||||
{
|
||||
"id": "ph_guest_3",
|
||||
"name": "寻兄的少年",
|
||||
"publicProfile": "一个十几岁的少年,背着书包,眼神倔强。",
|
||||
"secretProfile": "你的哥哥是消防员,去年在一次火灾中牺牲了。你们最后一次吵架是因为一台游戏机。",
|
||||
"secret": "你一直偷偷留着哥哥那台旧游戏机,但你再也没打开过。",
|
||||
"goal": "跟哥哥说对不起,告诉他你是他的骄傲。",
|
||||
"winCondition": "与哥哥和解",
|
||||
"personality": "倔强叛逆、内心柔软"
|
||||
}
|
||||
],
|
||||
"clues": [
|
||||
{
|
||||
"id": "ph_clue_1",
|
||||
"name": "泛黄的相册",
|
||||
"description": "柜台上放着一本手工相册,扉页上写着“送给每一个需要告别的人”。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "ph_clue_2",
|
||||
"name": "会动的照片",
|
||||
"description": "当你触碰照片时,画面中的人像开始活动,就像一段被封存的记忆被重新播放。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "ph_clue_3",
|
||||
"name": "老人的倒影",
|
||||
"description": "墙上的镜子里,老人没有倒影。但你不敢细想。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "ph_clue_4",
|
||||
"name": "消散的边缘",
|
||||
"description": "老人的身影在灯光下渐渐变得透明,像一张正在褪色的照片。",
|
||||
"phase": 3
|
||||
}
|
||||
],
|
||||
"truth": "照相馆老人是一个未能完成心愿的亡灵。他生前是这家照相馆的老板,去世前最遗憾的是没能和女儿好好告别。于是他化作照相馆的一部分,用最后的能力帮助每一个带着遗憾而来的人。当最后一位客人完成告别时,老人的身影化作星光消散——他终于可以安息了。"
|
||||
},
|
||||
{
|
||||
"id": "script_jade",
|
||||
"title": "翡翠城的交易",
|
||||
"type": "欢乐",
|
||||
"difficulty": 2,
|
||||
"playerCount": {
|
||||
"min": 1,
|
||||
"max": 6
|
||||
},
|
||||
"duration": "60-90min",
|
||||
"background": "翡翠城是沙漠中唯一的不夜城。今晚,城中最有权势的商人金大牙将在他的赌场举办一场盛大的拍卖会。传闻一件价值连城的宝物“翡翠之心”将在今晚易主。但在拍卖开始前,金大牙的保险柜被洗劫一空。",
|
||||
"phases": [
|
||||
{
|
||||
"name": "第一幕:拍卖之夜",
|
||||
"description": "赌场内灯火辉煌,宾客云集。金大牙站在舞台上宣布拍卖开始,但当他想展示“翡翠之心”时,保险柜已经空了。",
|
||||
"publicInfo": "保险柜完好无损,密码锁没有被破坏的痕迹——这意味着是知道密码的人干的。",
|
||||
"clues": [
|
||||
"jd_clue_1",
|
||||
"jd_clue_2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第二幕:各怀鬼胎",
|
||||
"description": "金大牙封锁了赌场,所有人都有嫌疑。每个宾客似乎都有不可告人的目的。",
|
||||
"publicInfo": "金大牙宣布:在找到“翡翠之心”之前,谁也别想离开。",
|
||||
"clues": [
|
||||
"jd_clue_3",
|
||||
"jd_clue_4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第三幕:谁在说谎",
|
||||
"description": "通过搜证和盘问,宾客们发现每个人都在打“翡翠之心”的主意,但真正的窃贼只有一个。",
|
||||
"publicInfo": "有人匿名送来了一张纸条:“翡翠之心已经在拍卖会前就被调包了。”",
|
||||
"clues": [
|
||||
"jd_clue_5",
|
||||
"jd_clue_6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "最终幕:指认窃贼",
|
||||
"description": "所有线索都指向了真相。谁才是真正的窃贼?",
|
||||
"publicInfo": "金大牙给出了最后的机会:主动交出翡翠之心,既往不咎。",
|
||||
"clues": []
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"id": "jd_gold",
|
||||
"name": "金大牙",
|
||||
"publicProfile": "赌场老板,翡翠城首富,以精明的商业头脑和黄金大牙闻名。",
|
||||
"secretProfile": "你根本没有“翡翠之心”。你在上个月就已经把它卖掉了,今晚的拍卖会只是一个幌子——为了骗保险。",
|
||||
"secret": "保险柜是你自己派人偷偷清空的,目的是获取巨额保险金。但你没想到,真的有人在你行动之前就把东西拿走了。",
|
||||
"goal": "查出是谁拿走了本应被你拿走的东西。",
|
||||
"winCondition": "找回失物且不被发现骗保",
|
||||
"personality": "精明圆滑、嚣张跋扈"
|
||||
},
|
||||
{
|
||||
"id": "jd_blind",
|
||||
"name": "盲眼算师",
|
||||
"publicProfile": "赌场的首席算牌师,虽然双目失明但听力超群。",
|
||||
"secretProfile": "你的眼睛其实没有问题。你装瞎是为了收集赌场的内幕信息——你是竞争对手派来的商业间谍。",
|
||||
"secret": "你昨晚目睹了一个人鬼鬼祟祟地接近金大牙的办公室。但因为你是“盲人”,你不能说出来。",
|
||||
"goal": "偷取金大牙的客户名单。",
|
||||
"winCondition": "获取名单且不暴露身份",
|
||||
"personality": "神秘莫测、话中有话"
|
||||
},
|
||||
{
|
||||
"id": "jd_dancer",
|
||||
"name": "红舞娘",
|
||||
"publicProfile": "赌场的驻场舞者,以曼妙的舞姿倾倒无数客人。",
|
||||
"secretProfile": "你曾经是金大牙的情人,但他抛弃了你。你来参加今晚的拍卖会是为了报复。",
|
||||
"secret": "你在金大牙的酒里下了泻药。本想让他在拍卖会上出丑,没想到发生了失窃案。",
|
||||
"goal": "让金大牙身败名裂。",
|
||||
"winCondition": "揭露金大牙的丑闻",
|
||||
"personality": "风情万种、敢爱敢恨"
|
||||
},
|
||||
{
|
||||
"id": "jd_traveler",
|
||||
"name": "沙漠旅人",
|
||||
"publicProfile": "一个路过的旅行商人,自称是第一次来翡翠城。",
|
||||
"secretProfile": "你是一名职业盗贼,专偷富商。你已经在翡翠城踩点了一个月。",
|
||||
"secret": "你确实打算偷“翡翠之心”,但当你潜入保险柜时,发现里面已经空了!你被人抢先一步。",
|
||||
"goal": "洗脱嫌疑,找到真正的窃贼。",
|
||||
"winCondition": "不被当成替罪羊",
|
||||
"personality": "洒脱不羁、玩世不恭"
|
||||
},
|
||||
{
|
||||
"id": "jd_waiter",
|
||||
"name": "阿福",
|
||||
"publicProfile": "赌场的侍者,在赌场工作了十年,老实本分。",
|
||||
"secretProfile": "你是金大牙的私生子,但你妈从来没让你认他。你来赌场工作只是为了离这个父亲近一点。",
|
||||
"secret": "你有金大牙办公室的备用钥匙。你经常在深夜偷偷进去,只是想在父亲待过的地方坐一坐。",
|
||||
"goal": "保护金大牙,虽然他不知道自己是谁。",
|
||||
"winCondition": "不暴露自己的真实身份",
|
||||
"personality": "老实憨厚、内心敏感"
|
||||
},
|
||||
{
|
||||
"id": "jd_inspector",
|
||||
"name": "陈探长",
|
||||
"publicProfile": "翡翠城的警长,金大牙的老朋友。今晚以宾客身份出席。",
|
||||
"secretProfile": "你一直在调查金大牙的走私活动。今晚的拍卖会是你搜集证据的好机会。",
|
||||
"secret": "你其实已经拿到了金大牙的走私证据,只差人赃并获。失窃案打乱了你的计划。",
|
||||
"goal": "查出失窃案真相,同时搜集金大牙的罪证。",
|
||||
"winCondition": "将罪犯绳之以法",
|
||||
"personality": "正义凛然、老谋深算"
|
||||
}
|
||||
],
|
||||
"clues": [
|
||||
{
|
||||
"id": "jd_clue_1",
|
||||
"name": "完好的保险柜",
|
||||
"description": "保险柜没有被撬的痕迹,密码锁显示最后一次输入密码是在昨晚11点。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "jd_clue_2",
|
||||
"name": "监控录像",
|
||||
"description": "监控录像显示昨晚11点到12点之间有一段画面被覆盖了。覆盖的人对监控系统非常熟悉。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "jd_clue_3",
|
||||
"name": "酒里的泻药",
|
||||
"description": "金大牙的酒杯里检测出泻药成分。有人想让他出丑。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "jd_clue_4",
|
||||
"name": "指纹对比",
|
||||
"description": "保险柜上提取到多组指纹,其中一组不属于任何已知的赌场员工。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "jd_clue_5",
|
||||
"name": "空盒子",
|
||||
"description": "在金大牙办公室的暗格里发现了一个盒子,标签写着“翡翠之心”,但里面是空的。",
|
||||
"phase": 3
|
||||
},
|
||||
{
|
||||
"id": "jd_clue_6",
|
||||
"name": "匿名信",
|
||||
"description": "一封没有署名的信:“翡翠之心早就不在保险柜里了。查查金大牙最近的银行流水。”",
|
||||
"phase": 3
|
||||
}
|
||||
],
|
||||
"truth": "真正的窃贼是金大牙自己。他早就把翡翠之心卖掉了,安排了一场假拍卖来骗保。但沙漠旅人的确试图行窃,发现保险柜空了——这就是为什么会有陌生指纹。盲眼算师目睹了沙漠旅人接近办公室,但以为是金大牙的人。整个事件是一场连环套:金大牙骗保、旅人偷窃未遂、算师商业间谍,三件事撞在了一起。"
|
||||
},
|
||||
{
|
||||
"id": "script_rain",
|
||||
"title": "阴雨公寓",
|
||||
"type": "恐怖",
|
||||
"difficulty": 3,
|
||||
"playerCount": {
|
||||
"min": 1,
|
||||
"max": 5
|
||||
},
|
||||
"duration": "60-90min",
|
||||
"background": "城中村有一栋老旧的公寓楼,每逢雨天,顶楼就会传来诡异的敲击声。三个月前,一个住户在雨夜失踪了。今晚又是暴雨,新搬来的住户们发现:这栋楼里的住户,似乎比他们以为的要多。",
|
||||
"phases": [
|
||||
{
|
||||
"name": "第一幕:雨夜搬入",
|
||||
"description": "暴雨如注,你搬进了这栋老旧的公寓。楼道里的灯忽明忽暗,电梯似乎永远停在顶楼。前台的老太太用奇怪的眼神看着你。",
|
||||
"publicInfo": "公寓共7层,你住在3楼。据前台说,整栋楼只有5户有人住。",
|
||||
"clues": [
|
||||
"ra_clue_1",
|
||||
"ra_clue_2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第二幕:邻居们",
|
||||
"description": "你开始认识楼里的其他住户。每个人似乎都有秘密。深夜,顶楼再次传来了敲击声。",
|
||||
"publicInfo": "敲击声有规律:三长两短,像是某种信号。",
|
||||
"clues": [
|
||||
"ra_clue_3",
|
||||
"ra_clue_4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第三幕:消失的住户",
|
||||
"description": "你发现失踪的住户并没有搬走——他的房间里一切如常,只是人不见了。他的手机最后一条信息是“我在顶楼”。",
|
||||
"publicInfo": "顶楼的门锁着,需要钥匙才能上去。前台说顶楼已经十年没人住了。",
|
||||
"clues": [
|
||||
"ra_clue_5",
|
||||
"ra_clue_6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "最终幕:顶楼的秘密",
|
||||
"description": "你终于拿到了顶楼的钥匙。敲击声就在门后。当你打开门的瞬间,你看到了……",
|
||||
"publicInfo": "真相就在这扇门后面。",
|
||||
"clues": [
|
||||
"ra_clue_7"
|
||||
]
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"id": "ra_reporter",
|
||||
"name": "记者·方晴",
|
||||
"publicProfile": "调查记者,为了调查三个月前的失踪案搬进公寓。",
|
||||
"secretProfile": "你怀疑这栋公寓和近三年来的五起失踪案有关。你伪装成普通住户来调查。",
|
||||
"secret": "你查到了失踪者的共同点:他们都曾在失踪前提到过“顶楼的敲击声”。",
|
||||
"goal": "查明失踪案的真相,写出独家报道。",
|
||||
"winCondition": "揭开全部秘密",
|
||||
"personality": "勇敢执着、敏锐"
|
||||
},
|
||||
{
|
||||
"id": "ra_teacher",
|
||||
"name": "张老师",
|
||||
"publicProfile": "退休教师,在公寓住了二十年。和蔼可亲。",
|
||||
"secretProfile": "你知道顶楼的秘密——十年前,顶楼住着一对夫妇,妻子在雨夜被杀,丈夫失踪了。从那以后,每逢雨夜就有敲击声。",
|
||||
"secret": "你其实知道顶楼的钥匙在哪。但你不敢说,因为你怕那个“丈夫”还在楼里。",
|
||||
"goal": "保护自己和邻居的安全。",
|
||||
"winCondition": "真相不危害到活着的人",
|
||||
"personality": "慈祥温和、欲言又止"
|
||||
},
|
||||
{
|
||||
"id": "ra_doctor",
|
||||
"name": "林医生",
|
||||
"publicProfile": "年轻的心理医生,刚搬来不久。",
|
||||
"secretProfile": "你根本不是心理医生。你是失踪者的弟弟,来寻找哥哥的下落。",
|
||||
"secret": "你哥哥失踪前给你打过电话,说“这栋楼里有一个不存在的人”。你以为是他的精神出了问题。",
|
||||
"goal": "找到哥哥,无论死活。",
|
||||
"winCondition": "查明哥哥的下落",
|
||||
"personality": "冷静理智、内心焦虑"
|
||||
},
|
||||
{
|
||||
"id": "ra_landlord",
|
||||
"name": "包租婆",
|
||||
"publicProfile": "公寓的管理员兼前台,看似刻薄但热心肠。",
|
||||
"secretProfile": "你当年目睹了顶楼的谋杀案。你一直保持沉默因为凶手威胁要伤害你的家人。",
|
||||
"secret": "是你把顶楼锁起来的。每月的15号你会在顶楼门口放一碗米饭——那是你赎罪的方式。",
|
||||
"goal": "守住这个秘密,保护楼里的住户。",
|
||||
"winCondition": "秘密不被发现",
|
||||
"personality": "刀子嘴豆腐心、担惊受怕"
|
||||
},
|
||||
{
|
||||
"id": "ra_stranger",
|
||||
"name": "神秘男子",
|
||||
"publicProfile": "一个沉默寡言的年轻人,住在404室。几乎不出门。",
|
||||
"secretProfile": "你就是顶楼那个“死了”的丈夫。你当年失手杀了妻子,然后伪造了自己的死亡。这些年你一直躲在404室的密室里。",
|
||||
"secret": "敲击声是你发出的信号——你在提醒自己“她还在这里”。你已经开始产生幻觉,觉得妻子的鬼魂在跟着你。",
|
||||
"goal": "不暴露身份,继续躲藏。",
|
||||
"winCondition": "不被发现真实身份",
|
||||
"personality": "阴郁沉默、精神恍惚"
|
||||
}
|
||||
],
|
||||
"clues": [
|
||||
{
|
||||
"id": "ra_clue_1",
|
||||
"name": "住户登记表",
|
||||
"description": "前台登记表显示,这栋楼登记住户有6人,但前台说只有5户有人住。多出来的一个是谁?",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "ra_clue_2",
|
||||
"name": "电梯的异常",
|
||||
"description": "电梯的楼层按钮中,“4”和“顶楼”的按钮是新的,其他按钮都已褪色。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "ra_clue_3",
|
||||
"name": "墙上的划痕",
|
||||
"description": "404室的墙角有指甲划出的痕迹,看起来像是有人被拖行过。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "ra_clue_4",
|
||||
"name": "顶楼的灯光",
|
||||
"description": "每晚12点,从对面楼可以看到顶楼的窗户亮灯一分钟,然后熄灭。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "ra_clue_5",
|
||||
"name": "失踪者的房间",
|
||||
"description": "失踪者的房间里,日历停在了三个月前的那一天。桌上有一张照片,照片中有6个人,但你只认识5个。",
|
||||
"phase": 3
|
||||
},
|
||||
{
|
||||
"id": "ra_clue_6",
|
||||
"name": "密室的入口",
|
||||
"description": "404室的衣柜后面发现了一道暗门。暗门通向一个狭小的隔间,里面有生活痕迹。",
|
||||
"phase": 3
|
||||
},
|
||||
{
|
||||
"id": "ra_clue_7",
|
||||
"name": "顶楼的真相",
|
||||
"description": "顶楼的房间里,墙上贴满了同一个女人的照片——是404室那个“神秘男子”的妻子。桌上有一本日记,最后一页写着“她回来了”。",
|
||||
"phase": 4
|
||||
}
|
||||
],
|
||||
"truth": "真正恐怖的是:那个“神秘男子”(404室住户)确实是顶楼命案的凶手。他在十年前雨夜杀死了妻子,伪造了自己的死亡,躲在404室的密室里。但三个月前失踪的住户不是他杀的——那个失踪者在调查顶楼秘密时,被包租婆误认为是入室窃贼而失手打死。包租婆把尸体藏在了顶楼的水箱里。所以这栋楼里有两个秘密:一个是十年前的谋杀案,一个是三个月前的误杀案。而敲击声——是水箱里尸体随水流撞击内壁的声音。"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
sqlalchemy==2.0.35
|
||||
aiosqlite==0.20.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
pydantic==2.9.0
|
||||
pydantic-settings==2.5.0
|
||||
python-multipart==0.0.12
|
||||
httpx==0.27.0
|
||||
websockets==13.0
|
||||
@@ -0,0 +1,29 @@
|
||||
import httpx, json, time
|
||||
|
||||
c = httpx.Client()
|
||||
base = 'http://localhost:8000'
|
||||
|
||||
g = c.post(base + '/api/games/create', json={'script_id': 'script_fog', 'human_role_id': 'role_detective'}).json()
|
||||
gid = g['game_id']
|
||||
print('Game:', gid[:8])
|
||||
|
||||
s = c.get(base + f'/api/games/{gid}/state').json()
|
||||
for p in s['players']:
|
||||
print(f' {p["role_name"]} (human={p["is_human"]})')
|
||||
|
||||
c.post(base + f'/api/games/{gid}/start')
|
||||
print('Started')
|
||||
|
||||
ch = c.post(base + f'/api/games/{gid}/chat', json={
|
||||
'sender_role_id': 'role_detective', 'type': 'public', 'content': '各位,案发时你们在哪?'
|
||||
}).json()
|
||||
print('Chat sent:', ch['status'])
|
||||
|
||||
time.sleep(1.5)
|
||||
|
||||
s2 = c.get(base + f'/api/games/{gid}/state').json()
|
||||
print(f'\nMessages ({len(s2["messages"])}):')
|
||||
for m in s2['messages'][-6:]:
|
||||
print(f' [{m["sender_name"]}] {m["content"][:60]}')
|
||||
|
||||
print('\n=== NPC auto-response test passed ===')
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI剧本杀</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "mmgame-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { Home } from './pages/Home'
|
||||
import { Login } from './pages/Login'
|
||||
import { ScriptList } from './pages/ScriptList'
|
||||
import { ScriptDetail } from './pages/ScriptDetail'
|
||||
import { Game } from './pages/Game'
|
||||
import { Replay } from './pages/Replay'
|
||||
import { History } from './pages/History'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app-container">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/scripts" element={<ScriptList />} />
|
||||
<Route path="/scripts/:id" element={<ScriptDetail />} />
|
||||
<Route path="/game/:id" element={<Game />} />
|
||||
<Route path="/replay/:id" element={<Replay />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useStore } from '../store'
|
||||
|
||||
export function Navbar() {
|
||||
const { user, logout } = useStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<nav>
|
||||
<Link to="/" className="logo">AI 剧本杀</Link>
|
||||
<div className="nav-links">
|
||||
<Link to="/scripts">剧本库</Link>
|
||||
{user ? (
|
||||
<>
|
||||
<Link to="/history">历史记录</Link>
|
||||
<span style={{ color: '#aaa', fontSize: 14 }}>{user.nickname}</span>
|
||||
<button className="btn-ghost" onClick={handleLogout}>退出</button>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login">登录</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/global.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { api } from '../store/api'
|
||||
|
||||
interface Player {
|
||||
id: string
|
||||
role_id: string
|
||||
role_name: string
|
||||
is_human: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
sender_role_id: string
|
||||
sender_name: string
|
||||
message_type: string
|
||||
content: string
|
||||
target_role_id?: string
|
||||
clue_id?: string
|
||||
phase_index: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function Game() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [gameState, setGameState] = useState<any>(null)
|
||||
const [players, setPlayers] = useState<Player[]>([])
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [clues, setClues] = useState<{ clue_id: string; status: string }[]>([])
|
||||
const [script, setScript] = useState<any>(null)
|
||||
const [phase, setPhase] = useState('')
|
||||
const [phaseIndex, setPhaseIndex] = useState(0)
|
||||
const [status, setStatus] = useState('')
|
||||
const [inputText, setInputText] = useState('')
|
||||
const [chatMode, setChatMode] = useState<'public' | 'private'>('public')
|
||||
const [privateTarget, setPrivateTarget] = useState<string | null>(null)
|
||||
const [voteTarget, setVoteTarget] = useState('')
|
||||
const [voteReason, setVoteReason] = useState('')
|
||||
const [voted, setVoted] = useState(false)
|
||||
const [votingOpen, setVotingOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [wsConnected, setWsConnected] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const [humanRoleId] = useState(() => sessionStorage.getItem('mm-role-id') || '')
|
||||
|
||||
const addMessages = useCallback((newMsgs: Message[], replaceLocalId?: string, replaceWithRealId?: string) => {
|
||||
setMessages((prev) => {
|
||||
let msgs = prev
|
||||
if (replaceLocalId && replaceWithRealId) {
|
||||
msgs = msgs.map((m) => m.id === replaceLocalId ? { ...m, id: replaceWithRealId } : m)
|
||||
}
|
||||
const existingIds = new Set(msgs.map((m) => m.id))
|
||||
const toAdd: Message[] = []
|
||||
for (const msg of newMsgs) {
|
||||
if (existingIds.has(msg.id)) continue
|
||||
const localIdx = msgs.findIndex(
|
||||
(m) => m.id.startsWith('local-') && m.sender_role_id === msg.sender_role_id && m.content === msg.content
|
||||
)
|
||||
if (localIdx >= 0) {
|
||||
msgs[localIdx] = { ...msg }
|
||||
existingIds.add(msg.id)
|
||||
} else {
|
||||
toAdd.push(msg)
|
||||
existingIds.add(msg.id)
|
||||
}
|
||||
}
|
||||
return toAdd.length > 0 ? [...msgs, ...toAdd] : msgs
|
||||
})
|
||||
}, [])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
const load = async () => {
|
||||
try {
|
||||
const state = await api.games.state(id)
|
||||
setGameState(state)
|
||||
setPlayers(state.players)
|
||||
setMessages(state.messages || [])
|
||||
setClues(state.clues || [])
|
||||
setScript(state.script)
|
||||
setPhase(state.phase)
|
||||
setPhaseIndex(state.phase_index)
|
||||
setStatus(state.status)
|
||||
} catch (err: any) {
|
||||
alert('加载游戏失败: ' + err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
|
||||
const connectWs = () => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/games/ws/${id}?role_id=${humanRoleId}`
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => setWsConnected(true)
|
||||
ws.onclose = () => {
|
||||
setWsConnected(false)
|
||||
setTimeout(connectWs, 3000)
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
handleWsMessage(data)
|
||||
} catch {}
|
||||
}
|
||||
wsRef.current = ws
|
||||
}
|
||||
|
||||
connectWs()
|
||||
return () => {
|
||||
wsRef.current?.close()
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleWsMessage = (data: any) => {
|
||||
switch (data.type) {
|
||||
case 'chat_message':
|
||||
addMessages([data.data as Message])
|
||||
break
|
||||
case 'phase_change':
|
||||
setPhase(data.data.phase)
|
||||
setPhaseIndex(data.data.phase_index)
|
||||
if (data.data.message) {
|
||||
addMessages([data.data.message])
|
||||
}
|
||||
if (data.data.new_clues) {
|
||||
setClues((prev) => [...prev, ...data.data.new_clues.map((c: string) => ({ clue_id: c, status: 'released' }))])
|
||||
}
|
||||
break
|
||||
case 'clue_revealed':
|
||||
setClues((prev) =>
|
||||
prev.map((c) => c.clue_id === data.data.clue_id ? { ...c, status: 'revealed' } : c)
|
||||
)
|
||||
break
|
||||
case 'vote_cast':
|
||||
break
|
||||
case 'vote_result':
|
||||
setStatus('completed')
|
||||
if (data.data.message) {
|
||||
addMessages([data.data.message])
|
||||
}
|
||||
setTimeout(() => navigate(`/replay/${id}`), 3000)
|
||||
break
|
||||
case 'game_started':
|
||||
setStatus('playing')
|
||||
if (data.data?.message) {
|
||||
addMessages([data.data.message])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartGame = async () => {
|
||||
if (!id) return
|
||||
await api.games.start(id)
|
||||
setStatus('playing')
|
||||
}
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputText.trim() || !id || !humanRoleId) return
|
||||
const targetId = chatMode === 'private' && privateTarget ? privateTarget : undefined
|
||||
const localId = 'local-' + Date.now()
|
||||
const newMsg: Message = {
|
||||
id: localId,
|
||||
sender_role_id: humanRoleId,
|
||||
sender_name: players.find((p) => p.role_id === humanRoleId)?.role_name || humanRoleId,
|
||||
message_type: chatMode,
|
||||
content: inputText,
|
||||
target_role_id: targetId,
|
||||
phase_index: phaseIndex,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
addMessages([newMsg])
|
||||
setInputText('')
|
||||
try {
|
||||
const res = await api.games.chat(id, humanRoleId, chatMode, inputText, targetId)
|
||||
addMessages([], localId, res.message_id)
|
||||
if (res.npc_responses) {
|
||||
addMessages(res.npc_responses.map((n: any) => n.data))
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessages((prev) => prev.map((m) => m.id === localId ? { ...m, content: m.content + ' (发送失败)' } : m))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdvancePhase = async () => {
|
||||
if (!id) return
|
||||
await api.games.advancePhase(id)
|
||||
}
|
||||
|
||||
const handleRevealClue = async (clueId: string) => {
|
||||
if (!id) return
|
||||
await api.games.revealClue(id, clueId)
|
||||
}
|
||||
|
||||
const handleVote = async () => {
|
||||
if (!id || !voteTarget) return
|
||||
await api.games.vote(id, humanRoleId, voteTarget, voteReason)
|
||||
setVoted(true)
|
||||
setVotingOpen(false)
|
||||
}
|
||||
|
||||
const handleEndVote = async () => {
|
||||
if (!id) return
|
||||
await api.games.endVote(id)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="page"><p style={{ textAlign: 'center', padding: 40 }}>加载游戏中...</p></div>
|
||||
}
|
||||
|
||||
const npcPlayers = players.filter((p) => !p.is_human)
|
||||
const humanPlayer = players.find((p) => p.is_human)
|
||||
const releasedClues = clues.filter((c) => c.status !== 'unreleased')
|
||||
const revealedClues = clues.filter((c) => c.status === 'revealed')
|
||||
|
||||
return (
|
||||
<div className="game-layout">
|
||||
{/* Left: Role Panel */}
|
||||
<div className="role-panel">
|
||||
<h3>角色列表</h3>
|
||||
{players.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="role-item"
|
||||
onClick={() => {
|
||||
if (chatMode === 'private' && privateTarget === p.role_id) {
|
||||
setChatMode('public')
|
||||
setPrivateTarget(null)
|
||||
} else {
|
||||
setChatMode('private')
|
||||
setPrivateTarget(p.role_id)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: chatMode === 'private' && privateTarget === p.role_id ? '#0f3460' : undefined,
|
||||
border: p.is_human ? '1px solid #e94560' : 'none',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<div className="avatar">{p.role_name[0]}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14 }}>{p.role_name}</div>
|
||||
<div style={{ fontSize: 11, color: '#888' }}>
|
||||
{p.is_human ? '你' : 'AI'}
|
||||
{chatMode === 'private' && privateTarget === p.role_id ? ' (私聊中)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`status-dot ${p.is_human ? 'active' : 'thinking'}`} />
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button className="btn-ghost" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => { setChatMode('public'); setPrivateTarget(null) }}>
|
||||
公聊模式 {chatMode === 'public' ? '✓' : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Chat Area */}
|
||||
<div className="chat-area">
|
||||
<div className="chat-header">
|
||||
<div>
|
||||
<span className="phase-name">{phase}</span>
|
||||
<span style={{ marginLeft: 16, fontSize: 13, color: '#888' }}>
|
||||
{status === 'waiting' ? '等待开始' : status === 'playing' ? '进行中' : '已结束'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: wsConnected ? '#4caf50' : '#ff9800', display: 'inline-block',
|
||||
}} />
|
||||
{status === 'waiting' && (
|
||||
<button className="btn-primary" onClick={handleStartGame}>开始游戏</button>
|
||||
)}
|
||||
{status === 'playing' && (
|
||||
<>
|
||||
<button className="btn-ghost" onClick={handleAdvancePhase}>下一幕</button>
|
||||
<button className="btn-secondary" onClick={() => setVotingOpen(true)}>投票</button>
|
||||
</>
|
||||
)}
|
||||
{status === 'playing' && voted && (
|
||||
<button className="btn-primary" onClick={handleEndVote}>结束投票</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className={`message ${m.message_type === 'system' ? 'system' : m.sender_role_id === 'dm' ? 'dm' : m.sender_role_id === humanPlayer?.role_id ? 'player' : 'npc'}`}>
|
||||
{m.message_type !== 'system' && (
|
||||
<div className="sender">
|
||||
{m.sender_name}
|
||||
{m.message_type === 'private' && m.target_role_id && (
|
||||
<span style={{ color: '#e94560' }}> → 私聊</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>{m.content}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="chat-input">
|
||||
{chatMode === 'private' && (
|
||||
<span style={{
|
||||
background: '#0f3460', padding: '6px 12px', borderRadius: 4,
|
||||
fontSize: 12, whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 4,
|
||||
}}>
|
||||
私聊 {players.find((p) => p.role_id === privateTarget)?.role_name}
|
||||
<button
|
||||
onClick={() => { setChatMode('public'); setPrivateTarget(null) }}
|
||||
style={{ background: 'none', border: 'none', color: '#e94560', cursor: 'pointer', padding: 0, fontSize: 14 }}
|
||||
>×</button>
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
placeholder={chatMode === 'public' ? '公聊发言...' : '私聊发言...'}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button className="btn-primary" onClick={handleSendMessage}>发送</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Clue Panel */}
|
||||
<div className="right-panel">
|
||||
<h3>线索牌堆</h3>
|
||||
{releasedClues.length === 0 && (
|
||||
<p style={{ fontSize: 13, color: '#888' }}>暂无线索</p>
|
||||
)}
|
||||
{releasedClues.map((c) => {
|
||||
const clueInfo = script?.clues?.find((cl: any) => cl.id === c.clue_id)
|
||||
const isRevealed = c.status === 'revealed'
|
||||
return (
|
||||
<div
|
||||
key={c.clue_id}
|
||||
className="clue-card"
|
||||
onClick={() => !isRevealed && handleRevealClue(c.clue_id)}
|
||||
style={{ opacity: isRevealed ? 1 : 0.7, cursor: isRevealed ? 'default' : 'pointer' }}
|
||||
>
|
||||
<div className="clue-name">
|
||||
{clueInfo?.name || c.clue_id}
|
||||
{isRevealed ? ' 🔍' : ' 📌'}
|
||||
</div>
|
||||
<div className="clue-desc">
|
||||
{isRevealed ? clueInfo?.description || '' : '点击查看'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{revealedClues.length > 0 && (
|
||||
<>
|
||||
<h3 style={{ marginTop: 20 }}>已揭示的线索</h3>
|
||||
{revealedClues.map((c) => {
|
||||
const clueInfo = script?.clues?.find((cl: any) => cl.id === c.clue_id)
|
||||
return (
|
||||
<div key={c.clue_id} className="clue-card" style={{ borderColor: '#e94560' }}>
|
||||
<div className="clue-name">{clueInfo?.name || c.clue_id}</div>
|
||||
<div className="clue-desc">{clueInfo?.description || ''}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vote Modal */}
|
||||
{votingOpen && (
|
||||
<div className="modal-overlay" onClick={() => setVotingOpen(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>投票指认</h2>
|
||||
{players.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`vote-option ${voteTarget === p.role_id ? 'selected' : ''}`}
|
||||
onClick={() => setVoteTarget(p.role_id)}
|
||||
>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: '50%', background: '#0f3460',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{p.role_name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<div>{p.role_name}</div>
|
||||
<div style={{ fontSize: 12, color: '#888' }}>{p.is_human ? '你' : 'AI NPC'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<textarea
|
||||
placeholder="理由(可选)"
|
||||
value={voteReason}
|
||||
onChange={(e) => setVoteReason(e.target.value)}
|
||||
style={{ marginTop: 12, minHeight: 60, resize: 'vertical' }}
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button className="btn-ghost" onClick={() => setVotingOpen(false)}>取消</button>
|
||||
<button className="btn-primary" onClick={handleVote} disabled={!voteTarget}>提交投票</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Navbar } from '../components/Navbar'
|
||||
import { api } from '../store/api'
|
||||
import { useStore } from '../store'
|
||||
|
||||
export function History() {
|
||||
const [games, setGames] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const user = useStore((s) => s.user)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
api.auth.history()
|
||||
.then(setGames)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [user])
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page">
|
||||
<p style={{ textAlign: 'center', padding: 40 }}>请先登录</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page">
|
||||
<h1 style={{ color: '#e94560', marginBottom: 24 }}>历史记录</h1>
|
||||
{loading ? (
|
||||
<p style={{ textAlign: 'center', padding: 40 }}>加载中...</p>
|
||||
) : games.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', padding: 40, color: '#888' }}>暂无游戏记录</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{games.map((g) => (
|
||||
<div key={g.id} className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
|
||||
{new Date(g.created_at).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{g.script_id}</strong>
|
||||
<span style={{
|
||||
marginLeft: 12, fontSize: 12, padding: '2px 8px', borderRadius: 4,
|
||||
background: g.status === 'completed' ? '#4caf50' : '#ff9800', color: '#fff',
|
||||
}}>
|
||||
{g.status === 'completed' ? '已完成' : g.status === 'playing' ? '进行中' : g.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{g.status === 'completed' && (
|
||||
<Link to={`/replay/${g.id}`}><button className="btn-ghost">复盘</button></Link>
|
||||
)}
|
||||
{g.status === 'playing' && (
|
||||
<Link to={`/game/${g.id}`}><button className="btn-primary">继续</button></Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Navbar } from '../components/Navbar'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../store/api'
|
||||
|
||||
export function Home() {
|
||||
const navigate = useNavigate()
|
||||
const [scripts, setScripts] = useState<any[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
api.scripts.list().then(setScripts).catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="hero">
|
||||
<h1>AI 剧本杀</h1>
|
||||
<p>随时开局,人人都是主角。1 人即可开局,AI 队友补位。</p>
|
||||
<div className="actions">
|
||||
<button className="btn-primary" onClick={() => navigate('/scripts')}>
|
||||
开始游戏
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={() => navigate('/login')}>
|
||||
登录 / 注册
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page">
|
||||
<h2 style={{ marginBottom: 16 }}>推荐剧本</h2>
|
||||
<div className="script-grid">
|
||||
{scripts.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="card script-card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/scripts/${s.id}`)}
|
||||
>
|
||||
<h3>{s.title}</h3>
|
||||
<div className="tags">
|
||||
<span className="tag">{s.type}</span>
|
||||
<span className="tag difficulty">{'⭐'.repeat(s.difficulty)}</span>
|
||||
<span className="tag">{s.duration}</span>
|
||||
<span className="tag">{s.min_players}-{s.max_players}人</span>
|
||||
</div>
|
||||
<p style={{ color: '#aaa', fontSize: 14, lineHeight: 1.6 }}>{s.background}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../store/api'
|
||||
import { useStore } from '../store'
|
||||
|
||||
export function Login() {
|
||||
const [tab, setTab] = useState<'login' | 'register'>('login')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [nickname, setNickname] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const setUser = useStore((s) => s.setUser)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const fn = tab === 'login' ? api.auth.login : api.auth.register
|
||||
const data = tab === 'login'
|
||||
? await fn({ email, password } as any)
|
||||
: await fn({ email, password, nickname })
|
||||
setUser(data)
|
||||
navigate('/scripts')
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGuest = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.auth.guest()
|
||||
setUser(data)
|
||||
navigate('/scripts')
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-box">
|
||||
<h1>AI 剧本杀</h1>
|
||||
<div className="tab-bar">
|
||||
<button className={tab === 'login' ? 'active' : ''} onClick={() => setTab('login')}>登录</button>
|
||||
<button className={tab === 'register' ? 'active' : ''} onClick={() => setTab('register')}>注册</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{tab === 'register' && (
|
||||
<input placeholder="昵称" value={nickname} onChange={(e) => setNickname(e.target.value)} required />
|
||||
)}
|
||||
<input type="email" placeholder="邮箱" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<input type="password" placeholder="密码" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
{error && <p style={{ color: '#e94560', fontSize: 14 }}>{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? '处理中...' : tab === 'login' ? '登录' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="guest-btn">
|
||||
<button onClick={handleGuest} disabled={loading}>游客模式体验</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Navbar } from '../components/Navbar'
|
||||
import { api } from '../store/api'
|
||||
|
||||
export function Replay() {
|
||||
const { id } = useParams()
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
api.games.replay(id)
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
if (loading) return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page"><p style={{ textAlign: 'center', padding: 40 }}>加载复盘数据...</p></div>
|
||||
</div>
|
||||
)
|
||||
if (!data) return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page"><p style={{ textAlign: 'center', padding: 40 }}>复盘数据不存在</p></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="replay-page">
|
||||
<Link to="/scripts" style={{ color: '#0f3460', fontSize: 14 }}>← 返回剧本库</Link>
|
||||
<h1>{data.script_title} - 复盘报告</h1>
|
||||
|
||||
<div className="replay-truth">
|
||||
<h2>案件真相</h2>
|
||||
<p style={{ lineHeight: 1.8 }}>{data.truth}</p>
|
||||
</div>
|
||||
|
||||
<h2 style={{ color: '#e94560', marginBottom: 16 }}>角色秘密全公开</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16, marginBottom: 24 }}>
|
||||
{data.roles?.map((role: any, i: number) => (
|
||||
<div key={i} className="card">
|
||||
<h3 style={{ color: '#e94560', marginBottom: 8 }}>{role.name}</h3>
|
||||
<p style={{ fontSize: 13, color: '#aaa', marginBottom: 4 }}>公开身份:{role.publicProfile}</p>
|
||||
<p style={{ fontSize: 13, color: '#ff9800', marginBottom: 4 }}>秘密:{role.secretProfile}</p>
|
||||
<p style={{ fontSize: 13, color: '#888' }}>目标:{role.goal}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 style={{ color: '#e94560', marginBottom: 16 }}>投票记录</h2>
|
||||
{data.votes?.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 24 }}>
|
||||
{data.votes.map((v: any, i: number) => (
|
||||
<div key={i} className="card" style={{ padding: 12, fontSize: 14 }}>
|
||||
<strong>{v.voter_name}</strong> 投票给 <strong>{v.target_name}</strong>
|
||||
{v.reason && <span style={{ color: '#aaa' }}> — {v.reason}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: '#888', marginBottom: 24 }}>暂无投票记录</p>
|
||||
)}
|
||||
|
||||
<h2 style={{ color: '#e94560', marginBottom: 16 }}>对话回放</h2>
|
||||
<div className="replay-messages">
|
||||
{data.messages?.map((m: any) => (
|
||||
<div key={m.id} className="replay-msg">
|
||||
<strong style={{ color: m.type === 'system' ? '#ff9800' : m.sender_name === 'DM' ? '#e94560' : '#0f3460' }}>
|
||||
{m.sender_name}
|
||||
</strong>
|
||||
:{m.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Navbar } from '../components/Navbar'
|
||||
import { api } from '../store/api'
|
||||
|
||||
export function ScriptDetail() {
|
||||
const { id } = useParams()
|
||||
const [script, setScript] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
api.scripts.detail(id)
|
||||
.then(setScript)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!script || !selectedRoleId) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const { game_id } = await api.games.create(script.id, selectedRoleId)
|
||||
sessionStorage.setItem('mm-role-id', selectedRoleId)
|
||||
navigate(`/game/${game_id}`)
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page"><p style={{ textAlign: 'center', padding: 40 }}>加载中...</p></div>
|
||||
</div>
|
||||
)
|
||||
if (!script) return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page"><p style={{ textAlign: 'center', padding: 40 }}>剧本不存在</p></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page">
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ color: '#e94560', marginBottom: 12 }}>{script.title}</h1>
|
||||
<div className="tags" style={{ marginBottom: 16 }}>
|
||||
<span className="tag">{script.type}</span>
|
||||
<span className="tag difficulty">{'⭐'.repeat(script.difficulty)}</span>
|
||||
<span className="tag">{script.duration}</span>
|
||||
<span className="tag">{script.playerCount.min}-{script.playerCount.max}人</span>
|
||||
</div>
|
||||
<p style={{ lineHeight: 1.8, marginBottom: 16 }}>{script.background}</p>
|
||||
</div>
|
||||
|
||||
<h2 style={{ color: '#e94560', marginBottom: 16 }}>选择你的角色</h2>
|
||||
<p style={{ color: '#888', marginBottom: 16, fontSize: 14 }}>点击选择一个角色,其他角色将由 AI 扮演</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16, marginBottom: 24 }}>
|
||||
{script.roles?.map((role: any) => (
|
||||
<div
|
||||
key={role.id}
|
||||
className="card"
|
||||
onClick={() => setSelectedRoleId(role.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: selectedRoleId === role.id ? '#e94560' : '#0f3460',
|
||||
borderWidth: selectedRoleId === role.id ? 2 : 1,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: '50%', background: '#0f3460',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, fontWeight: 'bold',
|
||||
color: selectedRoleId === role.id ? '#e94560' : '#e0e0e0',
|
||||
}}>
|
||||
{role.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: 4 }}>
|
||||
{role.name}
|
||||
{selectedRoleId === role.id && <span style={{ color: '#e94560', fontSize: 12, marginLeft: 8 }}>✓ 已选</span>}
|
||||
</h3>
|
||||
<p style={{ fontSize: 13, color: '#aaa' }}>{role.publicProfile}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>目标:{role.goal}</p>
|
||||
<p style={{ fontSize: 13, color: '#888' }}>性格:{role.personality}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleStart}
|
||||
disabled={creating || !selectedRoleId}
|
||||
style={{ padding: '14px 48px', fontSize: 18 }}
|
||||
>
|
||||
{creating ? '创建中...' : selectedRoleId ? '开始游戏' : '请先选择一个角色'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Navbar } from '../components/Navbar'
|
||||
import { api } from '../store/api'
|
||||
|
||||
export function ScriptList() {
|
||||
const [scripts, setScripts] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
api.scripts.list()
|
||||
.then(setScripts)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<div className="page">
|
||||
<h1 style={{ color: '#e94560', marginBottom: 8 }}>剧本库</h1>
|
||||
<p style={{ color: '#aaa', marginBottom: 24 }}>选择一个剧本开始你的推理之旅</p>
|
||||
{loading ? (
|
||||
<p style={{ textAlign: 'center', padding: 40 }}>加载中...</p>
|
||||
) : (
|
||||
<div className="script-grid">
|
||||
{scripts.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="card script-card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/scripts/${s.id}`)}
|
||||
>
|
||||
<h3>{s.title}</h3>
|
||||
<div className="tags">
|
||||
<span className="tag">{s.type}</span>
|
||||
<span className="tag difficulty">{'⭐'.repeat(s.difficulty)}</span>
|
||||
<span className="tag">{s.duration}</span>
|
||||
<span className="tag">{s.min_players}-{s.max_players}人</span>
|
||||
</div>
|
||||
<p style={{ color: '#aaa', fontSize: 14, lineHeight: 1.6 }}>{s.background}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
const API_BASE = '/api'
|
||||
|
||||
async function request(path: string, options: RequestInit = {}) {
|
||||
const stored = JSON.parse(localStorage.getItem('mmgame-storage') || '{}')?.state?.user
|
||||
const token = stored?.token
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(err.detail || '请求失败')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
register: (data: { email: string; password: string; nickname: string }) =>
|
||||
request('/auth/register', { method: 'POST', body: JSON.stringify(data) }),
|
||||
login: (data: { email: string; password: string }) =>
|
||||
request('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
|
||||
guest: (nickname?: string) =>
|
||||
request('/auth/guest', { method: 'POST', body: JSON.stringify({ nickname: nickname || '游客' }) }),
|
||||
logout: () => request('/auth/logout', { method: 'POST' }),
|
||||
profile: () => request('/auth/profile'),
|
||||
history: () => request('/auth/history'),
|
||||
},
|
||||
scripts: {
|
||||
list: () => request('/scripts'),
|
||||
detail: (id: string) => request(`/scripts/${id}`),
|
||||
},
|
||||
games: {
|
||||
create: (script_id: string, human_role_id?: string) =>
|
||||
request('/games/create', { method: 'POST', body: JSON.stringify({ script_id, human_role_id: human_role_id || '' }) }),
|
||||
join: (game_id: string, role_id: string) =>
|
||||
request(`/games/${game_id}/join`, { method: 'POST', body: JSON.stringify({ role_id }) }),
|
||||
start: (game_id: string) =>
|
||||
request(`/games/${game_id}/start`, { method: 'POST' }),
|
||||
state: (game_id: string) => request(`/games/${game_id}/state`),
|
||||
advancePhase: (game_id: string) =>
|
||||
request(`/games/${game_id}/phase/advance`, { method: 'POST' }),
|
||||
revealClue: (game_id: string, clue_id: string) =>
|
||||
request(`/games/${game_id}/clue/${clue_id}/reveal`, { method: 'POST' }),
|
||||
vote: (game_id: string, voter_role_id: string, target_role_id: string, reason?: string) =>
|
||||
request(`/games/${game_id}/vote`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ voter_role_id, target_role_id, reason: reason || '' }),
|
||||
}),
|
||||
endVote: (game_id: string) =>
|
||||
request(`/games/${game_id}/vote/end`, { method: 'POST' }),
|
||||
chat: (game_id: string, sender_role_id: string, type: string, content: string, target_role_id?: string) =>
|
||||
request(`/games/${game_id}/chat`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sender_role_id, type, content, target_role_id }),
|
||||
}),
|
||||
replay: (game_id: string) => request(`/games/${game_id}/replay`),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface UserInfo {
|
||||
token: string
|
||||
user_id: string
|
||||
nickname: string
|
||||
is_guest: boolean
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
sender_role_id: string
|
||||
sender_name: string
|
||||
message_type: string
|
||||
content: string
|
||||
target_role_id?: string
|
||||
clue_id?: string
|
||||
phase_index: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface PlayerInfo {
|
||||
id: string
|
||||
role_id: string
|
||||
role_name: string
|
||||
is_human: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
interface ClueInfo {
|
||||
clue_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
user: UserInfo | null
|
||||
setUser: (user: UserInfo) => void
|
||||
logout: () => void
|
||||
|
||||
currentGame: string | null
|
||||
gamePhase: string
|
||||
gamePhaseIndex: number
|
||||
players: PlayerInfo[]
|
||||
clues: ClueInfo[]
|
||||
messages: Message[]
|
||||
script: any
|
||||
ws: WebSocket | null
|
||||
|
||||
setGame: (id: string) => void
|
||||
setGameState: (state: Partial<{
|
||||
phase: string
|
||||
phase_index: number
|
||||
players: PlayerInfo[]
|
||||
clues: ClueInfo[]
|
||||
messages: Message[]
|
||||
script: any
|
||||
}>) => void
|
||||
addMessage: (msg: Message) => void
|
||||
setWs: (ws: WebSocket | null) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useStore = create<GameState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
setUser: (user) => set({ user }),
|
||||
logout: () => {
|
||||
get().ws?.close()
|
||||
set({ user: null, currentGame: null, gamePhase: '', gamePhaseIndex: 0, players: [], clues: [], messages: [], script: null, ws: null })
|
||||
},
|
||||
|
||||
currentGame: null,
|
||||
gamePhase: '',
|
||||
gamePhaseIndex: 0,
|
||||
players: [],
|
||||
clues: [],
|
||||
messages: [],
|
||||
script: null,
|
||||
ws: null,
|
||||
|
||||
setGame: (id) => set({ currentGame: id }),
|
||||
setGameState: (state) => set({
|
||||
gamePhase: state.phase ?? get().gamePhase,
|
||||
gamePhaseIndex: state.phase_index ?? get().gamePhaseIndex,
|
||||
players: state.players ?? get().players,
|
||||
clues: state.clues ?? get().clues,
|
||||
messages: state.messages ?? get().messages,
|
||||
script: state.script ?? get().script,
|
||||
}),
|
||||
addMessage: (msg) => set({ messages: [...get().messages, msg] }),
|
||||
setWs: (ws) => set({ ws }),
|
||||
reset: () => set({
|
||||
currentGame: null, gamePhase: '', gamePhaseIndex: 0,
|
||||
players: [], clues: [], messages: [], script: null, ws: null,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'mmgame-storage',
|
||||
partialize: (state) => ({ user: state.user }),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.app-container { min-height: 100vh; }
|
||||
a { color: #0f3460; text-decoration: none; }
|
||||
button {
|
||||
cursor: pointer; border: none; border-radius: 6px; padding: 10px 24px;
|
||||
font-size: 15px; font-family: inherit;
|
||||
}
|
||||
input, textarea {
|
||||
background: #16213e; border: 1px solid #0f3460; border-radius: 6px;
|
||||
padding: 10px 14px; color: #e0e0e0; font-size: 15px; font-family: inherit;
|
||||
outline: none; width: 100%;
|
||||
}
|
||||
input:focus, textarea:focus { border-color: #e94560; }
|
||||
|
||||
.btn-primary { background: #e94560; color: #fff; }
|
||||
.btn-primary:hover { background: #d63851; }
|
||||
.btn-secondary { background: #0f3460; color: #fff; }
|
||||
.btn-secondary:hover { background: #1a4a8a; }
|
||||
.btn-ghost { background: transparent; color: #e0e0e0; border: 1px solid #0f3460; }
|
||||
.btn-ghost:hover { background: #16213e; }
|
||||
|
||||
.card {
|
||||
background: #16213e; border-radius: 10px; padding: 24px;
|
||||
border: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.page { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
|
||||
/* Login */
|
||||
.login-page {
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
min-height: 100vh; flex-direction: column; gap: 24px;
|
||||
}
|
||||
.login-box {
|
||||
background: #16213e; border-radius: 12px; padding: 40px;
|
||||
width: 400px; border: 1px solid #0f3460;
|
||||
}
|
||||
.login-box h1 { text-align: center; margin-bottom: 32px; color: #e94560; }
|
||||
.login-box form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.login-box .tab-bar { display: flex; margin-bottom: 20px; }
|
||||
.login-box .tab-bar button {
|
||||
flex: 1; background: transparent; color: #888; padding: 12px;
|
||||
border-bottom: 2px solid transparent; border-radius: 0;
|
||||
}
|
||||
.login-box .tab-bar button.active { color: #e94560; border-bottom-color: #e94560; }
|
||||
.guest-btn { margin-top: 16px; text-align: center; }
|
||||
.guest-btn button { color: #0f3460; background: transparent; text-decoration: underline; }
|
||||
|
||||
/* Home */
|
||||
.hero {
|
||||
text-align: center; padding: 80px 24px;
|
||||
background: linear-gradient(135deg, #1a1a2e, #0f3460);
|
||||
}
|
||||
.hero h1 { font-size: 48px; color: #e94560; margin-bottom: 16px; }
|
||||
.hero p { font-size: 18px; color: #aaa; margin-bottom: 32px; }
|
||||
.hero .actions { display: flex; gap: 16px; justify-content: center; }
|
||||
|
||||
/* Script List */
|
||||
.script-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; margin-top: 24px; }
|
||||
.script-card:hover { border-color: #e94560; transform: translateY(-2px); transition: all 0.2s; }
|
||||
.script-card h3 { font-size: 18px; margin-bottom: 8px; }
|
||||
.script-card .tags { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.tag { padding: 4px 10px; border-radius: 4px; font-size: 12px; background: #0f3460; }
|
||||
.tag.difficulty { background: #e94560; }
|
||||
|
||||
/* Game Layout */
|
||||
.game-layout { display: grid; grid-template-columns: 200px 1fr 260px; height: 100vh; overflow: hidden; }
|
||||
.role-panel { background: #16213e; border-right: 1px solid #0f3460; padding: 16px; overflow-y: auto; }
|
||||
.role-panel h3 { color: #e94560; margin-bottom: 16px; font-size: 14px; }
|
||||
.role-item {
|
||||
padding: 10px; border-radius: 6px; margin-bottom: 8px; cursor: pointer;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.role-item:hover { background: #0f3460; }
|
||||
.role-item .avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%; background: #0f3460;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 16px;
|
||||
}
|
||||
.role-item .status-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.role-item .status-dot.active { background: #4caf50; }
|
||||
.role-item .status-dot.thinking { background: #ff9800; }
|
||||
|
||||
.chat-area { display: flex; flex-direction: column; height: 100vh; }
|
||||
.chat-header {
|
||||
padding: 12px 20px; background: #16213e; border-bottom: 1px solid #0f3460;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.chat-header .phase-name { color: #e94560; font-weight: bold; }
|
||||
.chat-messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.message {
|
||||
max-width: 80%; padding: 12px 16px; border-radius: 10px;
|
||||
line-height: 1.5; font-size: 14px;
|
||||
}
|
||||
.message.system { background: #0f3460; color: #ff9800; align-self: center; max-width: 90%; text-align: center; }
|
||||
.message.dm { background: #1a1a2e; border: 1px solid #e94560; align-self: flex-start; }
|
||||
.message.npc { background: #16213e; border: 1px solid #0f3460; align-self: flex-start; }
|
||||
.message.player { background: #e94560; color: #fff; align-self: flex-end; }
|
||||
.message .sender { font-size: 12px; opacity: 0.7; margin-bottom: 4px; }
|
||||
.chat-input {
|
||||
padding: 16px 20px; background: #16213e; border-top: 1px solid #0f3460;
|
||||
display: flex; gap: 12px;
|
||||
}
|
||||
.chat-input input { flex: 1; }
|
||||
|
||||
.right-panel { background: #16213e; border-left: 1px solid #0f3460; padding: 16px; overflow-y: auto; }
|
||||
.right-panel h3 { color: #e94560; margin-bottom: 16px; font-size: 14px; }
|
||||
.clue-card {
|
||||
background: #1a1a2e; border: 1px solid #0f3460; border-radius: 8px;
|
||||
padding: 12px; margin-bottom: 10px; cursor: pointer;
|
||||
}
|
||||
.clue-card:hover { border-color: #e94560; }
|
||||
.clue-card .clue-name { font-weight: bold; margin-bottom: 4px; }
|
||||
.clue-card .clue-desc { font-size: 13px; color: #aaa; }
|
||||
|
||||
/* Vote Modal */
|
||||
.modal-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
.modal { background: #16213e; border-radius: 12px; padding: 32px; width: 480px; border: 1px solid #0f3460; }
|
||||
.modal h2 { margin-bottom: 20px; color: #e94560; }
|
||||
.vote-option {
|
||||
padding: 10px 16px; border: 1px solid #0f3460; border-radius: 8px;
|
||||
margin-bottom: 8px; cursor: pointer; display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.vote-option:hover, .vote-option.selected { border-color: #e94560; background: #1a1a2e; }
|
||||
.modal-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; }
|
||||
|
||||
/* Replay */
|
||||
.replay-page { max-width: 800px; margin: 0 auto; padding: 24px; }
|
||||
.replay-page h1 { color: #e94560; margin-bottom: 24px; }
|
||||
.replay-truth { background: #16213e; border-radius: 10px; padding: 24px; margin-bottom: 24px; border: 1px solid #e94560; }
|
||||
.replay-truth h2 { color: #e94560; margin-bottom: 12px; }
|
||||
.replay-messages { display: flex; flex-direction: column; gap: 8px; }
|
||||
.replay-msg { padding: 8px 12px; border-radius: 6px; background: #16213e; font-size: 14px; }
|
||||
|
||||
nav {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 24px; background: #16213e; border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
nav .logo { font-size: 20px; font-weight: bold; color: #e94560; }
|
||||
nav .nav-links { display: flex; gap: 20px; align-items: center; }
|
||||
nav .nav-links a, nav .nav-links button { color: #e0e0e0; background: none; padding: 8px 12px; }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #1a1a2e; }
|
||||
::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title AI 剧本杀 - 仅启动后端
|
||||
|
||||
echo [AI 剧本杀] 启动后端服务 (端口 8000)...
|
||||
cd /d "%~dp0backend"
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
pause
|
||||
@@ -0,0 +1,29 @@
|
||||
@echo off
|
||||
title AI Murder Mystery MVP
|
||||
|
||||
echo ========================================
|
||||
echo AI Murder Mystery MVP - Launcher
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
set "ROOT=%~dp0"
|
||||
|
||||
echo [1/2] Starting backend (port 8000)...
|
||||
start "MM-Backend" /D "%ROOT%backend" uvicorn app.main:app --reload --port 8000
|
||||
|
||||
ping 127.0.0.1 -n 3 >nul
|
||||
|
||||
echo [2/2] Starting frontend (port 5173)...
|
||||
start "MM-Frontend" /D "%ROOT%frontend" npx vite --host
|
||||
|
||||
ping 127.0.0.1 -n 3 >nul
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Ready!
|
||||
echo Backend: http://localhost:8000
|
||||
echo Frontend: http://localhost:5173
|
||||
echo ========================================
|
||||
|
||||
start http://localhost:5173
|
||||
pause
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "E:\Temp\Anby\MMGame\backend"
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 > backend.log 2>&1
|
||||
Reference in New Issue
Block a user