feat: add MMGame backend and frontend source code
This commit is contained in:
@@ -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室的密室里。但三个月前失踪的住户不是他杀的——那个失踪者在调查顶楼秘密时,被包租婆误认为是入室窃贼而失手打死。包租婆把尸体藏在了顶楼的水箱里。所以这栋楼里有两个秘密:一个是十年前的谋杀案,一个是三个月前的误杀案。而敲击声——是水箱里尸体随水流撞击内壁的声音。"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user