feat: add MMGame backend and frontend source code

This commit is contained in:
gmh01
2026-08-01 21:30:23 +08:00
parent 26ce0dd600
commit c176c04aab
42 changed files with 3606 additions and 0 deletions
View File
View File
+220
View File
@@ -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)
+24
View File
@@ -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', '')}"
+26
View File
@@ -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"}
+21
View File
@@ -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()
+24
View File
@@ -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)
+31
View File
@@ -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"}
View File
+94
View File
@@ -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)
+39
View File
@@ -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)
+27
View File
@@ -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)
View File
+144
View File
@@ -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
]
+694
View File
@@ -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
+45
View File
@@ -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, "剧本不存在")
View File
+23
View File
@@ -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
+710
View File
@@ -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室的密室里。但三个月前失踪的住户不是他杀的——那个失踪者在调查顶楼秘密时,被包租婆误认为是入室窃贼而失手打死。包租婆把尸体藏在了顶楼的水箱里。所以这栋楼里有两个秘密:一个是十年前的谋杀案,一个是三个月前的误杀案。而敲击声——是水箱里尸体随水流撞击内壁的声音。"
}
]