refactor: rewrite backend in Go, replacing Python FastAPI
Port the FastAPI backend to Go 1.26, preserving all API functionality: - auth (register/login/guest/logout/profile/history) with JWT + bcrypt - scripts (list/detail) backed by embedded sample_data.json - games (create/join/start/chat/phase/clue/vote/state/replay) + WebSocket - rule-based NPC agent plus DeepSeek/Ollama AI backends - SQLite persistence via modernc.org/sqlite (pure Go, no cgo) Frontend and documents are unchanged.
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"mmgame/internal/config"
|
||||
"mmgame/internal/scripts"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type AIBackend struct {
|
||||
backend string
|
||||
}
|
||||
|
||||
var ai = &AIBackend{backend: config.Cfg.AIBackend}
|
||||
|
||||
func (a *AIBackend) chat(messages []ChatMessage, temperature float64, maxTokens int) string {
|
||||
switch a.backend {
|
||||
case "ollama":
|
||||
return a.ollamaChat(messages, temperature, maxTokens)
|
||||
case "deepseek":
|
||||
return a.deepseekChat(messages, temperature, maxTokens)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AIBackend) ollamaChat(messages []ChatMessage, temperature float64, maxTokens int) string {
|
||||
body := map[string]interface{}{
|
||||
"model": config.Cfg.OllamaModel,
|
||||
"messages": messages,
|
||||
"stream": false,
|
||||
"options": map[string]interface{}{"temperature": temperature, "num_predict": maxTokens},
|
||||
}
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
resp, err := http.Post(config.Cfg.OllamaBaseURL+"/api/chat", "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return ""
|
||||
}
|
||||
return result.Message.Content
|
||||
}
|
||||
|
||||
func (a *AIBackend) deepseekChat(messages []ChatMessage, temperature float64, maxTokens int) string {
|
||||
body := map[string]interface{}{
|
||||
"model": config.Cfg.DeepSeekModel,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": maxTokens,
|
||||
}
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req, err := http.NewRequest("POST", strings.TrimSuffix(config.Cfg.DeepSeekBaseURL, "/")+"/v1/chat/completions", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+config.Cfg.DeepSeekAPIKey)
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(result.Choices) > 0 {
|
||||
return result.Choices[0].Message.Content
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var dmIntros = map[string]string{
|
||||
"hardcore": "欢迎来到这场推理盛宴。真相就藏在你面前的线索之中,仔细观察,大胆推理。",
|
||||
"emotional": "这是一个关于人心的故事。有时候,真相并不重要,重要的是你在这个过程中感受到了什么。",
|
||||
"欢乐": "欢迎来到这场欢乐的聚会!记住,每个人都在演戏,但真相只有一个!",
|
||||
"恐怖": "黑暗中有什么在注视着你……准备好了吗?",
|
||||
}
|
||||
|
||||
func cleanJSON(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "```json")
|
||||
s = strings.TrimSuffix(s, "```")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func DMGenerate(script *scripts.Script, phaseIndex int, releasedClues []string, context string) map[string]interface{} {
|
||||
phases := script.Phases
|
||||
var phase *scripts.Phase
|
||||
if phaseIndex < len(phases) {
|
||||
phase = &phases[phaseIndex]
|
||||
} else {
|
||||
phase = &phases[len(phases)-1]
|
||||
}
|
||||
title := script.Title
|
||||
scriptType := script.Type
|
||||
typeIntro := dmIntros[scriptType]
|
||||
cluesStr := "暂无"
|
||||
if len(releasedClues) > 0 {
|
||||
cluesStr = strings.Join(releasedClues, ", ")
|
||||
}
|
||||
prompt := "你是一个剧本杀主持人(DM),请以主持人的身份发言。\n" +
|
||||
"当前剧本: " + title + "\n" +
|
||||
"当前阶段: " + phase.Name + "\n" +
|
||||
"阶段描述: " + phase.Description + "\n" +
|
||||
"已发放线索: " + cluesStr + "\n" +
|
||||
"上下文: " + context + "\n\n" +
|
||||
"请简短发言(50字以内),推动剧情发展。"
|
||||
|
||||
result := ai.chat([]ChatMessage{{Role: "user", Content: prompt}}, 0.7, 512)
|
||||
if result != "" {
|
||||
cleaned := cleanJSON(result)
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(cleaned), &m); err == nil {
|
||||
return m
|
||||
}
|
||||
return map[string]interface{}{"action": "narrate", "content": result}
|
||||
}
|
||||
|
||||
voice := phase.Description
|
||||
if len(voice) > 80 {
|
||||
voice = voice[:80]
|
||||
}
|
||||
if phaseIndex == 0 && context == "opening" {
|
||||
bg := script.Background
|
||||
if len(bg) > 100 {
|
||||
bg = bg[:100]
|
||||
}
|
||||
voice = typeIntro + "\n\n欢迎来到【" + title + "】。" + bg
|
||||
}
|
||||
return map[string]interface{}{"action": "narrate", "content": "【" + phase.Name + "】" + voice}
|
||||
}
|
||||
|
||||
func NPCGenerate(role *scripts.Role, background, phaseName string, recentMessages []string, playerMessage string, isPrivate bool, knownClues []string, phaseGoal string, allRoles []*scripts.Role) map[string]interface{} {
|
||||
personality := role.Personality
|
||||
if personality == "" {
|
||||
personality = role.Name
|
||||
}
|
||||
cluesStr := "暂无线索"
|
||||
if len(knownClues) > 0 {
|
||||
var last []string
|
||||
if len(knownClues) > 5 {
|
||||
last = knownClues[len(knownClues)-5:]
|
||||
} else {
|
||||
last = knownClues
|
||||
}
|
||||
cluesStr = strings.Join(last, " | ")
|
||||
}
|
||||
rolesStr := ""
|
||||
if len(allRoles) > 0 {
|
||||
var names []string
|
||||
for _, r := range allRoles {
|
||||
if r.ID != role.ID {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
rolesStr = "其他玩家: " + strings.Join(names, ", ")
|
||||
}
|
||||
}
|
||||
bg := background
|
||||
if len(bg) > 80 {
|
||||
bg = bg[:80] + "..."
|
||||
}
|
||||
recent := ""
|
||||
if len(recentMessages) > 0 {
|
||||
var last []string
|
||||
if len(recentMessages) > 4 {
|
||||
last = recentMessages[len(recentMessages)-4:]
|
||||
} else {
|
||||
last = recentMessages
|
||||
}
|
||||
recent = strings.Join(last, " | ")
|
||||
}
|
||||
goalLine := ""
|
||||
if phaseGoal != "" {
|
||||
goalLine = "阶段目标: " + phaseGoal
|
||||
}
|
||||
privLine := ""
|
||||
if isPrivate && playerMessage != "" {
|
||||
privLine = "【私聊】" + playerMessage
|
||||
} else if playerMessage != "" {
|
||||
privLine = "【有人对你说】" + playerMessage
|
||||
}
|
||||
|
||||
prompt := "你正在扮演一个剧本杀角色。请严格按照角色设定发言。\n\n" +
|
||||
"角色信息:\n" +
|
||||
"- 姓名: " + role.Name + "\n" +
|
||||
"- 公开身份: " + role.PublicProfile + "\n" +
|
||||
"- 性格: " + personality + "\n" +
|
||||
"- 你的目标: " + role.Goal + "\n" +
|
||||
"- 你的秘密: " + role.SecretProfile + "\n\n" +
|
||||
"当前背景: " + bg + "\n" +
|
||||
"当前阶段: " + phaseName + "\n" +
|
||||
goalLine + "\n" +
|
||||
rolesStr + "\n\n" +
|
||||
"你已知道的线索:\n" + cluesStr + "\n\n" +
|
||||
"最近发言:\n" + recent + "\n\n" +
|
||||
privLine + "\n\n" +
|
||||
"【发言规则】\n" +
|
||||
"1. 以角色身份说人话,简短自然(30字以内)\n" +
|
||||
"2. 你的秘密绝对不能主动说出来\n" +
|
||||
"3. 被问到时可以撒谎、回避、转移话题\n" +
|
||||
"4. 结合已公开的线索来推理和回应\n" +
|
||||
"5. 如果你知道某些线索的真相(如你就是凶手),可以故意误导他人\n\n" +
|
||||
"直接输出你的角色发言内容,不要JSON、不要解释。"
|
||||
|
||||
result := ai.chat([]ChatMessage{{Role: "user", Content: prompt}}, 0.7, 512)
|
||||
if result != "" {
|
||||
cleaned := cleanJSON(result)
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(cleaned), &m); err == nil {
|
||||
return m
|
||||
}
|
||||
return map[string]interface{}{"action": "speak", "content": result}
|
||||
}
|
||||
return map[string]interface{}{"action": "speak", "content": ruleNPCReply(role, playerMessage, isPrivate, knownClues)}
|
||||
}
|
||||
|
||||
func ruleNPCReply(role *scripts.Role, playerMessage string, isPrivate bool, knownClues []string) string {
|
||||
name := role.Name
|
||||
clues := knownClues
|
||||
|
||||
if playerMessage == "" {
|
||||
if len(clues) > 0 {
|
||||
replies := []string{
|
||||
name + "沉思道:\"这些线索……我觉得需要重新梳理一下。\"",
|
||||
name + "说:\"我注意到了一些细节,但现在还不方便说。\"",
|
||||
name + "看了看法医报告:\"时间线和线索对不上,肯定有人撒谎。\"",
|
||||
}
|
||||
return replies[randInt(len(replies))]
|
||||
}
|
||||
replies := []string{
|
||||
name + "环顾四周,若有所思。",
|
||||
name + "清了清嗓子:\"各位,我觉得我们应该整理一下思路。\"",
|
||||
name + "沉默地看着大家。",
|
||||
name + "低声说:\"这件事没有那么简单……\"",
|
||||
}
|
||||
return replies[randInt(len(replies))]
|
||||
}
|
||||
|
||||
if strings.Contains(playerMessage, "凶手") || strings.Contains(playerMessage, "杀人") || strings.Contains(playerMessage, "你杀") {
|
||||
denials := []string{
|
||||
name + "脸色一变:\"你凭什么这么说?证据呢?\"",
|
||||
name + "冷笑一声:\"如果我是凶手,我还会坐在这里?\"",
|
||||
name + "摇头:\"我没有理由杀他。\"",
|
||||
}
|
||||
return denials[randInt(len(denials))]
|
||||
}
|
||||
|
||||
if strings.Contains(playerMessage, "时间") || strings.Contains(playerMessage, "在哪") || strings.Contains(playerMessage, "案发") {
|
||||
times := []string{
|
||||
name + "回忆道:\"我当时在……让我想想。\"",
|
||||
name + "说:\"那段时间我一个人在房间里。\"",
|
||||
name + "皱眉:\"我不太确定具体时间,但我确实听到了什么声音。\"",
|
||||
}
|
||||
return times[randInt(len(times))]
|
||||
}
|
||||
|
||||
if strings.Contains(playerMessage, "线索") || strings.Contains(playerMessage, "证据") || strings.Contains(playerMessage, "发现") {
|
||||
clueReplies := []string{
|
||||
name + "点头:\"这个线索确实值得注意。\"",
|
||||
name + "沉思:\"但这个线索也可能是在误导我们。\"",
|
||||
name + "说:\"我也有一个发现,但还不确定是否相关。\"",
|
||||
}
|
||||
return clueReplies[randInt(len(clueReplies))]
|
||||
}
|
||||
|
||||
if strings.Contains(playerMessage, "知道") || strings.Contains(playerMessage, "秘密") {
|
||||
secrets := []string{
|
||||
name + "回避了你的目光:\"我什么也不知道。\"",
|
||||
name + "沉默了一会儿:\"每个人都有不想说的秘密,不是吗?\"",
|
||||
name + "说:\"我只能告诉你,事情不是你看到的那样。\"",
|
||||
}
|
||||
return secrets[randInt(len(secrets))]
|
||||
}
|
||||
|
||||
if strings.Contains(playerMessage, "动机") || strings.Contains(playerMessage, "为什么") || strings.Contains(playerMessage, "目的") {
|
||||
motives := []string{
|
||||
name + "说:\"每个人都有自己的理由,但有些理由……\"",
|
||||
name + "耸耸肩:\"动机?也许我们需要先搞清楚发生了什么。\"",
|
||||
name + "看着你:\"你确定你想知道真正的动机?\"",
|
||||
}
|
||||
return motives[randInt(len(motives))]
|
||||
}
|
||||
|
||||
generic := []string{
|
||||
name + "思考了一下:\"这个嘛,我说不好。\"",
|
||||
name + "回答:\"我不太确定,但我觉得我们应该继续调查。\"",
|
||||
name + "说:\"你说得有一定道理,但可能还有其他可能性。\"",
|
||||
name + "点头:\"有意思,继续说。\"",
|
||||
}
|
||||
return generic[randInt(len(generic))]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"mmgame/internal/scripts"
|
||||
)
|
||||
|
||||
type DMAgent struct {
|
||||
Script *scripts.Script
|
||||
currentPhaseIndex int
|
||||
releasedClues []string
|
||||
}
|
||||
|
||||
func NewDMAgent(script *scripts.Script) *DMAgent {
|
||||
return &DMAgent{Script: script}
|
||||
}
|
||||
|
||||
func (a *DMAgent) GetOpening() string {
|
||||
return fmt.Sprintf("欢迎来到【%s】。%s", a.Script.Title, a.Script.Background)
|
||||
}
|
||||
|
||||
func (a *DMAgent) Act(phaseIndex int, playerProgress, context string) map[string]interface{} {
|
||||
result := DMGenerate(a.Script, phaseIndex, a.releasedClues, context)
|
||||
if len(result) > 0 {
|
||||
return result
|
||||
}
|
||||
phases := a.Script.Phases
|
||||
phase := &phases[len(phases)-1]
|
||||
if phaseIndex < len(phases) {
|
||||
phase = &phases[phaseIndex]
|
||||
}
|
||||
return map[string]interface{}{"action": "narrate", "content": "【" + phase.Name + "】" + phase.Description}
|
||||
}
|
||||
|
||||
func (a *DMAgent) GetReplaySummary() string {
|
||||
return fmt.Sprintf("剧本:%s\n真相:%s", a.Script.Title, a.Script.Truth)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"mmgame/internal/scripts"
|
||||
)
|
||||
|
||||
type NPCAgent struct {
|
||||
Role *scripts.Role
|
||||
Background string
|
||||
PublicInfo string
|
||||
memory []ChatMessage
|
||||
}
|
||||
|
||||
func NewNPCAgent(role *scripts.Role, background, publicInfo string) *NPCAgent {
|
||||
return &NPCAgent{Role: role, Background: background, PublicInfo: publicInfo}
|
||||
}
|
||||
|
||||
func (a *NPCAgent) Respond(phaseName string, recentMessages []map[string]string, playerMessage string, isPrivate bool, knownClues []string, phaseGoal string, allRoles []*scripts.Role) map[string]interface{} {
|
||||
if len(recentMessages) > 5 {
|
||||
recentMessages = recentMessages[len(recentMessages)-5:]
|
||||
}
|
||||
var recent []string
|
||||
for _, m := range recentMessages {
|
||||
sender := m["sender_name"]
|
||||
if sender == "" {
|
||||
sender = m["sender"]
|
||||
}
|
||||
recent = append(recent, fmt.Sprintf("%s: %s", sender, m["content"]))
|
||||
}
|
||||
result := NPCGenerate(a.Role, a.Background, phaseName, recent, playerMessage, isPrivate, knownClues, phaseGoal, allRoles)
|
||||
if content, ok := result["content"].(string); ok && content != "" {
|
||||
a.memory = append(a.memory, ChatMessage{Role: "assistant", Content: content})
|
||||
return result
|
||||
}
|
||||
return map[string]interface{}{"content": a.Role.Name + "陷入了沉思……", "action": "speak"}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package agents
|
||||
|
||||
import "math/rand"
|
||||
|
||||
func randInt(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return rand.Intn(n)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
SecretKey string
|
||||
Algorithm string
|
||||
AccessTokenExpireMinutes int
|
||||
|
||||
AIBackend string
|
||||
DeepSeekAPIKey string
|
||||
DeepSeekBaseURL string
|
||||
DeepSeekModel string
|
||||
OllamaBaseURL string
|
||||
OllamaModel string
|
||||
}
|
||||
|
||||
var Cfg = load()
|
||||
|
||||
func loadDotEnv() {
|
||||
f, err := os.Open(".env")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, "=")
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
val := strings.Trim(strings.TrimSpace(line[idx+1:]), "\"'")
|
||||
if _, ok := os.LookupEnv(key); !ok {
|
||||
os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getenvInt(key string, def int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func load() *Config {
|
||||
loadDotEnv()
|
||||
return &Config{
|
||||
DatabaseURL: getenv("DATABASE_URL", "sqlite+aiosqlite:///./mmgame.db"),
|
||||
SecretKey: getenv("SECRET_KEY", "mmgame-dev-secret-key-change-in-production"),
|
||||
Algorithm: getenv("ALGORITHM", "HS256"),
|
||||
AccessTokenExpireMinutes: getenvInt("ACCESS_TOKEN_EXPIRE_MINUTES", 60*24*7),
|
||||
AIBackend: getenv("AI_BACKEND", "rule"),
|
||||
DeepSeekAPIKey: getenv("DEEPSEEK_API_KEY", ""),
|
||||
DeepSeekBaseURL: getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
|
||||
DeepSeekModel: getenv("DEEPSEEK_MODEL", "deepseek-chat"),
|
||||
OllamaBaseURL: getenv("OLLAMA_BASE_URL", "http://localhost:11434"),
|
||||
OllamaModel: getenv("OLLAMA_MODEL", "qwen2.5:7b"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"mmgame/internal/config"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
func normalizeDSN(url string) string {
|
||||
if url == "" {
|
||||
return "mmgame.db"
|
||||
}
|
||||
for _, prefix := range []string{"sqlite+aiosqlite:///", "sqlite:///"} {
|
||||
if strings.HasPrefix(url, prefix) {
|
||||
path := strings.TrimPrefix(url, prefix)
|
||||
if path == ":memory:" {
|
||||
return "file::memory:?cache=shared"
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func Init() error {
|
||||
dsn := normalizeDSN(config.Cfg.DatabaseURL)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := db.Ping(); err != nil {
|
||||
return err
|
||||
}
|
||||
DB = db
|
||||
return createTables()
|
||||
}
|
||||
|
||||
func createTables() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE,
|
||||
nickname VARCHAR(50),
|
||||
avatar_url TEXT,
|
||||
password_hash VARCHAR(255),
|
||||
role VARCHAR(20) DEFAULT 'player',
|
||||
is_guest BOOLEAN DEFAULT 0,
|
||||
game_count INTEGER DEFAULT 0,
|
||||
token_version INTEGER DEFAULT 0,
|
||||
created_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS game_sessions (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
script_id VARCHAR(36),
|
||||
status VARCHAR(20) DEFAULT 'waiting',
|
||||
phase VARCHAR(100) DEFAULT '',
|
||||
phase_index INTEGER DEFAULT 0,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
config TEXT,
|
||||
created_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS session_players (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
session_id VARCHAR(36),
|
||||
user_id VARCHAR(36),
|
||||
role_id VARCHAR(100),
|
||||
role_name VARCHAR(100) DEFAULT '',
|
||||
is_human BOOLEAN DEFAULT 1,
|
||||
is_ready BOOLEAN DEFAULT 0,
|
||||
avatar_url TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
joined_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
session_id VARCHAR(36),
|
||||
sender_role_id VARCHAR(100),
|
||||
sender_name VARCHAR(100) DEFAULT '',
|
||||
message_type VARCHAR(20) DEFAULT 'public',
|
||||
content TEXT,
|
||||
target_role_id VARCHAR(100),
|
||||
clue_id VARCHAR(100),
|
||||
phase_index INTEGER DEFAULT 0,
|
||||
created_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS clues_state (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
session_id VARCHAR(36),
|
||||
clue_id VARCHAR(100),
|
||||
status VARCHAR(20) DEFAULT 'unreleased',
|
||||
revealed_by VARCHAR(100),
|
||||
released_at TEXT,
|
||||
created_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS votes (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
session_id VARCHAR(36),
|
||||
round INTEGER DEFAULT 1,
|
||||
voter_id VARCHAR(36),
|
||||
target_id VARCHAR(36),
|
||||
reason TEXT,
|
||||
created_at TEXT
|
||||
)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := DB.Exec(s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"mmgame/internal/config"
|
||||
"mmgame/internal/database"
|
||||
"mmgame/internal/models"
|
||||
"mmgame/internal/util"
|
||||
)
|
||||
|
||||
var errUnauthorized = errors.New("unauthorized")
|
||||
|
||||
func createToken(userID string) (string, error) {
|
||||
exp := time.Now().Add(time.Duration(config.Cfg.AccessTokenExpireMinutes) * time.Minute)
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"exp": exp.Unix(),
|
||||
"v": 0,
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(config.Cfg.SecretKey))
|
||||
}
|
||||
|
||||
func currentUser(r *http.Request) (*models.User, error) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
tokenStr := strings.TrimPrefix(auth, "Bearer ")
|
||||
claims := jwt.MapClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.Cfg.SecretKey), nil
|
||||
}, jwt.WithValidMethods([]string{config.Cfg.Algorithm}))
|
||||
if err != nil || !token.Valid {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
userID, _ := claims["sub"].(string)
|
||||
if userID == "" {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
row := database.DB.QueryRow(
|
||||
`SELECT id, email, nickname, avatar_url, password_hash, role, is_guest, game_count, token_version, created_at
|
||||
FROM users WHERE id = ?`, userID)
|
||||
var u models.User
|
||||
var email, nickname, avatarURL, passwordHash, createdAt sql.NullString
|
||||
if err := row.Scan(&u.ID, &email, &nickname, &avatarURL, &passwordHash, &u.Role,
|
||||
&u.IsGuest, &u.GameCount, &u.TokenVersion, &createdAt); err != nil {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
if email.Valid {
|
||||
u.Email = &email.String
|
||||
}
|
||||
if nickname.Valid {
|
||||
u.Nickname = &nickname.String
|
||||
}
|
||||
if avatarURL.Valid {
|
||||
u.AvatarURL = &avatarURL.String
|
||||
}
|
||||
if passwordHash.Valid {
|
||||
u.PasswordHash = &passwordHash.String
|
||||
}
|
||||
u.CreatedAt = util.ParseTime(createdAt.String)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, 422, "请求格式错误")
|
||||
return
|
||||
}
|
||||
var existingID string
|
||||
err := database.DB.QueryRow(`SELECT id FROM users WHERE email = ?`, req.Email).Scan(&existingID)
|
||||
if err == nil {
|
||||
writeError(w, 400, "邮箱已注册")
|
||||
return
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
userID := newID()
|
||||
_, err = database.DB.Exec(
|
||||
`INSERT INTO users (id, email, nickname, password_hash, role, is_guest, game_count, token_version, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 0, 0, ?)`,
|
||||
userID, req.Email, req.Nickname, string(hash), models.RolePlayer, util.NowStr())
|
||||
if err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
token, _ := createToken(userID)
|
||||
writeJSON(w, 200, map[string]interface{}{
|
||||
"token": token,
|
||||
"user_id": userID,
|
||||
"nickname": req.Nickname,
|
||||
"is_guest": false,
|
||||
})
|
||||
}
|
||||
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, 422, "请求格式错误")
|
||||
return
|
||||
}
|
||||
var id, email, nickname, passwordHash string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT id, email, nickname, password_hash FROM users WHERE email = ?`, req.Email).
|
||||
Scan(&id, &email, &nickname, &passwordHash)
|
||||
if err == sql.ErrNoRows || passwordHash == "" {
|
||||
writeError(w, 401, "邮箱或密码错误")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)) != nil {
|
||||
writeError(w, 401, "邮箱或密码错误")
|
||||
return
|
||||
}
|
||||
token, _ := createToken(id)
|
||||
writeJSON(w, 200, map[string]interface{}{
|
||||
"token": token,
|
||||
"user_id": id,
|
||||
"nickname": nickname,
|
||||
"is_guest": false,
|
||||
})
|
||||
}
|
||||
|
||||
func GuestLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Nickname == "" {
|
||||
req.Nickname = "游客"
|
||||
}
|
||||
userID := newID()
|
||||
_, err := database.DB.Exec(
|
||||
`INSERT INTO users (id, nickname, role, is_guest, game_count, token_version, created_at)
|
||||
VALUES (?, ?, ?, 1, 0, 0, ?)`,
|
||||
userID, req.Nickname, models.RolePlayer, util.NowStr())
|
||||
if err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
token, _ := createToken(userID)
|
||||
writeJSON(w, 200, map[string]interface{}{
|
||||
"token": token,
|
||||
"user_id": userID,
|
||||
"nickname": req.Nickname,
|
||||
"is_guest": true,
|
||||
})
|
||||
}
|
||||
|
||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := currentUser(r)
|
||||
if err != nil {
|
||||
writeError(w, 401, "未登录")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]interface{}{
|
||||
"user_id": user.ID,
|
||||
"email": nullStr(user.Email),
|
||||
"nickname": nullStr(user.Nickname),
|
||||
"avatar_url": nullStr(user.AvatarURL),
|
||||
"is_guest": user.IsGuest,
|
||||
"role": user.Role,
|
||||
"game_count": user.GameCount,
|
||||
"created_at": util.TimeStr(user.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := currentUser(r)
|
||||
if err != nil {
|
||||
writeError(w, 401, "未登录")
|
||||
return
|
||||
}
|
||||
var data map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&data)
|
||||
|
||||
var nickname, avatarURL *string
|
||||
if v, ok := data["nickname"].(string); ok {
|
||||
nickname = &v
|
||||
}
|
||||
if v, ok := data["avatar_url"].(string); ok {
|
||||
avatarURL = &v
|
||||
}
|
||||
if nickname != nil {
|
||||
if _, err := database.DB.Exec(`UPDATE users SET nickname = ? WHERE id = ?`, *nickname, user.ID); err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
}
|
||||
if avatarURL != nil {
|
||||
if _, err := database.DB.Exec(`UPDATE users SET avatar_url = ? WHERE id = ?`, *avatarURL, user.ID); err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func GetHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := currentUser(r); err != nil {
|
||||
writeError(w, 401, "未登录")
|
||||
return
|
||||
}
|
||||
rows, err := database.DB.Query(
|
||||
`SELECT id, script_id, status, phase, phase_index, started_at, completed_at, created_at
|
||||
FROM game_sessions ORDER BY created_at DESC LIMIT 50`)
|
||||
if err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, scriptID, status, phase, startedAt, completedAt, createdAt sql.NullString
|
||||
var phaseIndex int
|
||||
if err := rows.Scan(&id, &scriptID, &status, &phase, &phaseIndex, &startedAt, &completedAt, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, map[string]interface{}{
|
||||
"id": id.String,
|
||||
"script_id": scriptID.String,
|
||||
"status": status.String,
|
||||
"phase": phase.String,
|
||||
"phase_index": phaseIndex,
|
||||
"started_at": startedAt.String,
|
||||
"completed_at": completedAt.String,
|
||||
"created_at": createdAt.String,
|
||||
})
|
||||
}
|
||||
writeJSON(w, 200, items)
|
||||
}
|
||||
@@ -0,0 +1,866 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"mmgame/internal/agents"
|
||||
"mmgame/internal/database"
|
||||
"mmgame/internal/models"
|
||||
"mmgame/internal/scripts"
|
||||
"mmgame/internal/util"
|
||||
)
|
||||
|
||||
func CreateGame(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ScriptID string `json:"script_id"`
|
||||
HumanRoleID string `json:"human_role_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, 422, "请求格式错误")
|
||||
return
|
||||
}
|
||||
script := scripts.GetByID(req.ScriptID)
|
||||
if script == nil {
|
||||
writeError(w, 404, "剧本不存在")
|
||||
return
|
||||
}
|
||||
gameID := newID()
|
||||
phase := ""
|
||||
if len(script.Phases) > 0 {
|
||||
phase = script.Phases[0].Name
|
||||
}
|
||||
configJSON, _ := json.Marshal(map[string]interface{}{"script": script})
|
||||
_, err := database.DB.Exec(
|
||||
`INSERT INTO game_sessions (id, script_id, status, phase, phase_index, config, created_at)
|
||||
VALUES (?, ?, ?, ?, 0, ?, ?)`,
|
||||
gameID, req.ScriptID, models.GameStatusWaiting, phase, string(configJSON), util.NowStr())
|
||||
if err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
|
||||
for _, role := range script.Roles {
|
||||
isHuman := role.ID == req.HumanRoleID
|
||||
if _, err := database.DB.Exec(
|
||||
`INSERT INTO session_players (id, session_id, role_id, role_name, is_human, is_ready, avatar_url, status, joined_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, '', ?, ?)`,
|
||||
newID(), gameID, role.ID, role.Name, isHuman, models.PlayerStatusActive, util.NowStr()); err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(script.Phases) > 0 {
|
||||
for _, clueID := range script.Phases[0].Clues {
|
||||
if _, err := database.DB.Exec(
|
||||
`INSERT INTO clues_state (id, session_id, clue_id, status, created_at)
|
||||
VALUES (?, ?, ?, 'released', ?)`,
|
||||
newID(), gameID, clueID, util.NowStr()); err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, 200, map[string]string{"game_id": gameID})
|
||||
}
|
||||
|
||||
func JoinGame(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
var req struct {
|
||||
RoleID string `json:"role_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, 422, "请求格式错误")
|
||||
return
|
||||
}
|
||||
var exists int
|
||||
err := database.DB.QueryRow(`SELECT 1 FROM game_sessions WHERE id = ?`, gameID).Scan(&exists)
|
||||
if err != nil {
|
||||
writeError(w, 404, "游戏不存在")
|
||||
return
|
||||
}
|
||||
|
||||
playerID := newID()
|
||||
if _, err := database.DB.Exec(
|
||||
`INSERT INTO session_players (id, session_id, role_id, is_human, is_ready, status, joined_at)
|
||||
VALUES (?, ?, ?, 1, 0, ?, ?)`,
|
||||
playerID, gameID, req.RoleID, models.PlayerStatusActive, util.NowStr()); err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
|
||||
var scriptID string
|
||||
_ = database.DB.QueryRow(`SELECT script_id FROM game_sessions WHERE id = ?`, gameID).Scan(&scriptID)
|
||||
script := scripts.GetByID(scriptID)
|
||||
if script != nil {
|
||||
for _, role := range script.Roles {
|
||||
if role.ID == req.RoleID {
|
||||
writeJSON(w, 200, map[string]interface{}{
|
||||
"player_id": playerID,
|
||||
"role_id": req.RoleID,
|
||||
"role_name": role.Name,
|
||||
"public_profile": role.PublicProfile,
|
||||
"secret_profile": role.SecretProfile,
|
||||
"secret": role.Secret,
|
||||
"goal": role.Goal,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]interface{}{"player_id": playerID, "role_id": req.RoleID})
|
||||
}
|
||||
|
||||
func StartGame(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
game, err := getGame(gameID)
|
||||
if err != nil {
|
||||
writeError(w, 404, "游戏不存在")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = database.DB.Exec(
|
||||
`UPDATE game_sessions SET status = ?, started_at = ? WHERE id = ?`,
|
||||
models.GameStatusPlaying, util.NowStr(), gameID)
|
||||
if err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
|
||||
script := scriptFromConfig(game.Config)
|
||||
background := ""
|
||||
title := ""
|
||||
if script != nil {
|
||||
background = script.Background
|
||||
title = script.Title
|
||||
}
|
||||
content := fmt.Sprintf("欢迎来到【%s】。\n%s\n\n请各位玩家阅读角色信息,做好准备后,在公屏中发送「准备好了」开始游戏。", title, background)
|
||||
msgID := newID()
|
||||
now := util.NowStr()
|
||||
if _, err := database.DB.Exec(
|
||||
`INSERT INTO chat_messages (id, session_id, sender_role_id, sender_name, message_type, content, phase_index, created_at)
|
||||
VALUES (?, ?, 'dm', 'DM', ?, ?, ?, ?)`,
|
||||
msgID, gameID, models.MessageTypeSystem, content, game.PhaseIndex, now); err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
|
||||
broadcast(gameID, map[string]interface{}{
|
||||
"type": "game_started",
|
||||
"data": map[string]interface{}{
|
||||
"phase": game.Phase,
|
||||
"phase_index": game.PhaseIndex,
|
||||
"message": map[string]interface{}{
|
||||
"id": msgID,
|
||||
"sender_name": "DM",
|
||||
"sender_role_id": "dm",
|
||||
"message_type": models.MessageTypeSystem,
|
||||
"content": content,
|
||||
"created_at": now,
|
||||
},
|
||||
},
|
||||
})
|
||||
writeJSON(w, 200, map[string]interface{}{"status": "started", "phase": game.Phase})
|
||||
}
|
||||
|
||||
type npcResponse struct {
|
||||
msg *models.ChatMessage
|
||||
broadcast map[string]interface{}
|
||||
}
|
||||
|
||||
func generateNPCResponses(gameID string, senderRoleID, content, msgType string, targetRoleID *string, script *scripts.Script, phaseName string, phaseIndex int) []npcResponse {
|
||||
var responses []npcResponse
|
||||
if script == nil {
|
||||
return responses
|
||||
}
|
||||
|
||||
var npcRoles []*scripts.Role
|
||||
for i := range script.Roles {
|
||||
if script.Roles[i].ID != senderRoleID {
|
||||
npcRoles = append(npcRoles, &script.Roles[i])
|
||||
}
|
||||
}
|
||||
rand.Shuffle(len(npcRoles), func(i, j int) { npcRoles[i], npcRoles[j] = npcRoles[j], npcRoles[i] })
|
||||
if len(npcRoles) == 0 {
|
||||
return responses
|
||||
}
|
||||
|
||||
recentMsgs := queryMessages(gameID)
|
||||
recentList := []map[string]string{}
|
||||
if len(recentMsgs) > 10 {
|
||||
recentMsgs = recentMsgs[len(recentMsgs)-10:]
|
||||
}
|
||||
for _, m := range recentMsgs {
|
||||
recentList = append(recentList, map[string]string{"sender_name": m.SenderName, "content": m.Content})
|
||||
}
|
||||
|
||||
knownClues := []string{}
|
||||
for _, c := range queryClues(gameID) {
|
||||
if c.Status == "released" && script != nil {
|
||||
for _, sc := range script.Clues {
|
||||
if sc.ID == c.ClueID {
|
||||
knownClues = append(knownClues, sc.Description)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var phaseGoal string
|
||||
if phaseIndex < len(script.Phases) {
|
||||
phaseGoal = script.Phases[phaseIndex].Description
|
||||
}
|
||||
|
||||
allRoles := []*scripts.Role{}
|
||||
for i := range script.Roles {
|
||||
allRoles = append(allRoles, &script.Roles[i])
|
||||
}
|
||||
|
||||
count := 2
|
||||
if len(npcRoles) < count {
|
||||
count = len(npcRoles)
|
||||
}
|
||||
for _, npcRole := range npcRoles[:count] {
|
||||
agent := agents.NewNPCAgent(npcRole, script.Background, "")
|
||||
isPrivate := msgType == "private" && targetRoleID != nil && *targetRoleID == npcRole.ID
|
||||
response := agent.Respond(phaseName, recentList, content, isPrivate, knownClues, phaseGoal, allRoles)
|
||||
reply, _ := response["content"].(string)
|
||||
action, _ := response["action"].(string)
|
||||
|
||||
if reply == "" || action == "remain_silent" {
|
||||
continue
|
||||
}
|
||||
|
||||
var npcTarget *string
|
||||
if isPrivate {
|
||||
npcTarget = &senderRoleID
|
||||
}
|
||||
npcMsgType := models.MessageTypePublic
|
||||
if isPrivate {
|
||||
npcMsgType = models.MessageTypePrivate
|
||||
}
|
||||
now := util.NowStr()
|
||||
npcMsg := &models.ChatMessage{
|
||||
ID: newID(),
|
||||
SessionID: gameID,
|
||||
SenderRoleID: npcRole.ID,
|
||||
SenderName: npcRole.Name,
|
||||
MessageType: npcMsgType,
|
||||
Content: reply,
|
||||
TargetRoleID: npcTarget,
|
||||
PhaseIndex: phaseIndex,
|
||||
CreatedAt: util.ParseTime(now),
|
||||
}
|
||||
responses = append(responses, npcResponse{
|
||||
msg: npcMsg,
|
||||
broadcast: map[string]interface{}{
|
||||
"type": "chat_message",
|
||||
"data": map[string]interface{}{
|
||||
"id": npcMsg.ID,
|
||||
"sender_role_id": npcRole.ID,
|
||||
"sender_name": npcRole.Name,
|
||||
"message_type": npcMsgType,
|
||||
"content": reply,
|
||||
"target_role_id": npcTarget,
|
||||
"phase_index": phaseIndex,
|
||||
"created_at": now,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
func SendChat(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
var req struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
TargetRoleID *string `json:"target_role_id"`
|
||||
SenderRoleID string `json:"sender_role_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, 422, "请求格式错误")
|
||||
return
|
||||
}
|
||||
game, err := getGame(gameID)
|
||||
if err != nil {
|
||||
writeError(w, 404, "游戏不存在")
|
||||
return
|
||||
}
|
||||
|
||||
senderName := req.SenderRoleID
|
||||
script := scriptFromConfig(game.Config)
|
||||
if script != nil {
|
||||
for _, role := range script.Roles {
|
||||
if role.ID == req.SenderRoleID {
|
||||
senderName = role.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
msgType := req.Type
|
||||
if msgType == "" {
|
||||
msgType = models.MessageTypePublic
|
||||
}
|
||||
msgID := newID()
|
||||
now := util.NowStr()
|
||||
if _, err := database.DB.Exec(
|
||||
`INSERT INTO chat_messages (id, session_id, sender_role_id, sender_name, message_type, content, target_role_id, phase_index, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
msgID, gameID, req.SenderRoleID, senderName, msgType, req.Content, req.TargetRoleID, game.PhaseIndex, now); err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
|
||||
npcResponses := generateNPCResponses(gameID, req.SenderRoleID, req.Content, msgType, req.TargetRoleID, script, game.Phase, game.PhaseIndex)
|
||||
for _, n := range npcResponses {
|
||||
if n.msg != nil {
|
||||
if _, err := database.DB.Exec(
|
||||
`INSERT INTO chat_messages (id, session_id, sender_role_id, sender_name, message_type, content, target_role_id, phase_index, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.msg.ID, n.msg.SessionID, n.msg.SenderRoleID, n.msg.SenderName, n.msg.MessageType,
|
||||
n.msg.Content, n.msg.TargetRoleID, n.msg.PhaseIndex, util.TimeStr(n.msg.CreatedAt)); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
msgData := map[string]interface{}{
|
||||
"type": "chat_message",
|
||||
"data": map[string]interface{}{
|
||||
"id": msgID,
|
||||
"sender_role_id": req.SenderRoleID,
|
||||
"sender_name": senderName,
|
||||
"message_type": msgType,
|
||||
"content": req.Content,
|
||||
"target_role_id": req.TargetRoleID,
|
||||
"phase_index": game.PhaseIndex,
|
||||
"created_at": now,
|
||||
},
|
||||
}
|
||||
|
||||
if msgType == "private" && req.TargetRoleID != nil {
|
||||
broadcastToRole(gameID, req.SenderRoleID, msgData)
|
||||
broadcastToRole(gameID, *req.TargetRoleID, msgData)
|
||||
} else {
|
||||
broadcast(gameID, msgData)
|
||||
}
|
||||
|
||||
for _, n := range npcResponses {
|
||||
b := n.broadcast
|
||||
data, _ := b["data"].(map[string]interface{})
|
||||
if data != nil {
|
||||
if data["message_type"] == "private" {
|
||||
target, _ := data["target_role_id"].(string)
|
||||
if target != "" {
|
||||
broadcastToRole(gameID, req.SenderRoleID, b)
|
||||
broadcastToRole(gameID, target, b)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
broadcast(gameID, b)
|
||||
}
|
||||
|
||||
npcList := []map[string]interface{}{}
|
||||
for _, n := range npcResponses {
|
||||
npcList = append(npcList, n.broadcast)
|
||||
}
|
||||
writeJSON(w, 200, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"message_id": msgID,
|
||||
"npc_responses": npcList,
|
||||
})
|
||||
}
|
||||
|
||||
func AdvancePhase(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
game, err := getGame(gameID)
|
||||
if err != nil {
|
||||
writeError(w, 404, "游戏不存在")
|
||||
return
|
||||
}
|
||||
|
||||
script := scriptFromConfig(game.Config)
|
||||
var phases []scripts.Phase
|
||||
if script != nil {
|
||||
phases = script.Phases
|
||||
}
|
||||
nextIdx := game.PhaseIndex + 1
|
||||
if nextIdx >= len(phases) {
|
||||
writeJSON(w, 200, map[string]string{"status": "already_at_end"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = database.DB.Exec(
|
||||
`UPDATE game_sessions SET phase_index = ?, phase = ? WHERE id = ?`,
|
||||
nextIdx, phases[nextIdx].Name, gameID)
|
||||
if err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
|
||||
phase := &phases[nextIdx]
|
||||
content := fmt.Sprintf("【%s】\n%s\n\n%s", phase.Name, phase.Description, phase.PublicInfo)
|
||||
msgID := newID()
|
||||
now := util.NowStr()
|
||||
if _, err := database.DB.Exec(
|
||||
`INSERT INTO chat_messages (id, session_id, sender_role_id, sender_name, message_type, content, phase_index, created_at)
|
||||
VALUES (?, ?, 'dm', 'DM', ?, ?, ?, ?)`,
|
||||
msgID, gameID, models.MessageTypeSystem, content, nextIdx, now); err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
|
||||
cluesToRelease := phase.Clues
|
||||
for _, clueID := range cluesToRelease {
|
||||
var existingID string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT id FROM clues_state WHERE session_id = ? AND clue_id = ?`, gameID, clueID).Scan(&existingID)
|
||||
if err == sql.ErrNoRows {
|
||||
if _, err := database.DB.Exec(
|
||||
`INSERT INTO clues_state (id, session_id, clue_id, status, created_at)
|
||||
VALUES (?, ?, ?, 'released', ?)`,
|
||||
newID(), gameID, clueID, util.NowStr()); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(gameID, map[string]interface{}{
|
||||
"type": "phase_change",
|
||||
"data": map[string]interface{}{
|
||||
"phase": phase.Name,
|
||||
"phase_index": nextIdx,
|
||||
"message": map[string]interface{}{
|
||||
"id": msgID,
|
||||
"sender_name": "DM",
|
||||
"sender_role_id": "dm",
|
||||
"message_type": models.MessageTypeSystem,
|
||||
"content": content,
|
||||
"created_at": now,
|
||||
},
|
||||
"new_clues": cluesToRelease,
|
||||
},
|
||||
})
|
||||
writeJSON(w, 200, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"phase": phase.Name,
|
||||
"phase_index": nextIdx,
|
||||
})
|
||||
}
|
||||
|
||||
func RevealClue(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
clueID := chi.URLParam(r, "clue_id")
|
||||
|
||||
var stateID string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT id FROM clues_state WHERE session_id = ? AND clue_id = ?`, gameID, clueID).Scan(&stateID)
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, 404, "线索不存在或未发放")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
if _, err := database.DB.Exec(`UPDATE clues_state SET status = 'revealed' WHERE id = ?`, stateID); err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
|
||||
game, _ := getGame(gameID)
|
||||
script := scriptFromConfig(game.Config)
|
||||
var clueInfo interface{}
|
||||
if script != nil {
|
||||
for _, c := range script.Clues {
|
||||
if c.ID == clueID {
|
||||
clueInfo = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(gameID, map[string]interface{}{
|
||||
"type": "clue_revealed",
|
||||
"data": map[string]interface{}{"clue_id": clueID, "clue": clueInfo},
|
||||
})
|
||||
writeJSON(w, 200, map[string]interface{}{"status": "revealed", "clue": clueInfo})
|
||||
}
|
||||
|
||||
func SubmitVote(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
var req struct {
|
||||
TargetRoleID string `json:"target_role_id"`
|
||||
Reason string `json:"reason"`
|
||||
VoterRoleID string `json:"voter_role_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, 422, "请求格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
var voterID, voterName string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT id, role_name FROM session_players WHERE session_id = ? AND role_id = ?`,
|
||||
gameID, req.VoterRoleID).Scan(&voterID, &voterName)
|
||||
if err != nil {
|
||||
writeError(w, 404, "投票者不存在")
|
||||
return
|
||||
}
|
||||
|
||||
var targetID string
|
||||
err = database.DB.QueryRow(
|
||||
`SELECT id FROM session_players WHERE session_id = ? AND role_id = ?`,
|
||||
gameID, req.TargetRoleID).Scan(&targetID)
|
||||
if err != nil {
|
||||
writeError(w, 404, "投票目标不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := database.DB.Exec(
|
||||
`INSERT INTO votes (id, session_id, round, voter_id, target_id, reason, created_at)
|
||||
VALUES (?, ?, 1, ?, ?, ?, ?)`,
|
||||
newID(), gameID, voterID, targetID, req.Reason, util.NowStr()); err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
|
||||
broadcast(gameID, map[string]interface{}{
|
||||
"type": "vote_cast",
|
||||
"data": map[string]interface{}{
|
||||
"voter_role_id": req.VoterRoleID,
|
||||
"target_role_id": req.TargetRoleID,
|
||||
"voter_name": voterName,
|
||||
},
|
||||
})
|
||||
writeJSON(w, 200, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func EndVote(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
game, err := getGame(gameID)
|
||||
if err != nil {
|
||||
writeError(w, 404, "游戏不存在")
|
||||
return
|
||||
}
|
||||
|
||||
script := scriptFromConfig(game.Config)
|
||||
var allClues map[string]scripts.Clue
|
||||
var roleByID map[string]*scripts.Role
|
||||
if script != nil {
|
||||
allClues = map[string]scripts.Clue{}
|
||||
for _, c := range script.Clues {
|
||||
allClues[c.ID] = c
|
||||
}
|
||||
roleByID = map[string]*scripts.Role{}
|
||||
for i := range script.Roles {
|
||||
roleByID[script.Roles[i].ID] = &script.Roles[i]
|
||||
}
|
||||
}
|
||||
|
||||
existingVotes := queryVotes(gameID)
|
||||
existingVoterIDs := map[string]bool{}
|
||||
for _, v := range existingVotes {
|
||||
existingVoterIDs[v.VoterID] = true
|
||||
}
|
||||
|
||||
playersList := queryPlayers(gameID)
|
||||
playersByID := map[string]*models.SessionPlayer{}
|
||||
for _, p := range playersList {
|
||||
playersByID[p.ID] = p
|
||||
}
|
||||
|
||||
releasedClueIDs := map[string]bool{}
|
||||
for _, c := range queryClues(gameID) {
|
||||
if c.Status == "released" {
|
||||
releasedClueIDs[c.ClueID] = true
|
||||
}
|
||||
}
|
||||
knownClues := []string{}
|
||||
if allClues != nil {
|
||||
for cid := range releasedClueIDs {
|
||||
if c, ok := allClues[cid]; ok {
|
||||
knownClues = append(knownClues, c.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var npcPlayers []*models.SessionPlayer
|
||||
for _, p := range playersList {
|
||||
if !p.IsHuman && !existingVoterIDs[p.ID] {
|
||||
npcPlayers = append(npcPlayers, p)
|
||||
}
|
||||
}
|
||||
|
||||
if script != nil {
|
||||
for _, npc := range npcPlayers {
|
||||
var targets []*models.SessionPlayer
|
||||
for _, p := range playersList {
|
||||
if p.ID != npc.ID {
|
||||
targets = append(targets, p)
|
||||
}
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var targetID string
|
||||
if npcRole, ok := roleByID[npc.RoleID]; ok {
|
||||
agent := agents.NewNPCAgent(npcRole, script.Background, "")
|
||||
voteReason := agent.Respond("投票阶段", []map[string]string{}, "请投票选出你最怀疑的人,并说明理由", false, knownClues, "", nil)
|
||||
voteContent, _ := voteReason["content"].(string)
|
||||
targetID = targets[rand.Intn(len(targets))].ID
|
||||
for _, t := range targets {
|
||||
if t.RoleName != "" && strings.Contains(voteContent, t.RoleName) {
|
||||
targetID = t.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
targetID = targets[rand.Intn(len(targets))].ID
|
||||
}
|
||||
|
||||
if _, err := database.DB.Exec(
|
||||
`INSERT INTO votes (id, session_id, round, voter_id, target_id, reason, created_at)
|
||||
VALUES (?, ?, 1, ?, ?, '', ?)`,
|
||||
newID(), gameID, npc.ID, targetID, util.NowStr()); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
targetPlayer := playersByID[targetID]
|
||||
if targetPlayer != nil {
|
||||
broadcast(gameID, map[string]interface{}{
|
||||
"type": "vote_cast",
|
||||
"data": map[string]interface{}{
|
||||
"voter_role_id": npc.RoleID,
|
||||
"voter_name": npc.RoleName,
|
||||
"target_role_id": targetPlayer.RoleID,
|
||||
"target_name": targetPlayer.RoleName,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
votes := queryVotes(gameID)
|
||||
tally := map[string][]string{}
|
||||
for _, v := range votes {
|
||||
targetRole := "unknown"
|
||||
if p, ok := playersByID[v.TargetID]; ok {
|
||||
targetRole = p.RoleID
|
||||
}
|
||||
voterRole := "unknown"
|
||||
if p, ok := playersByID[v.VoterID]; ok {
|
||||
voterRole = p.RoleID
|
||||
}
|
||||
tally[targetRole] = append(tally[targetRole], voterRole)
|
||||
}
|
||||
|
||||
maxVotes := 0
|
||||
accusedRoleID := ""
|
||||
for roleID, voters := range tally {
|
||||
if len(voters) > maxVotes {
|
||||
maxVotes = len(voters)
|
||||
accusedRoleID = roleID
|
||||
}
|
||||
}
|
||||
|
||||
accusedName := accusedRoleID
|
||||
if script != nil {
|
||||
for _, role := range script.Roles {
|
||||
if role.ID == accusedRoleID {
|
||||
accusedName = role.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
truth := ""
|
||||
if script != nil {
|
||||
truth = script.Truth
|
||||
}
|
||||
|
||||
if _, err := database.DB.Exec(
|
||||
`UPDATE game_sessions SET status = ?, completed_at = ? WHERE id = ?`,
|
||||
models.GameStatusCompleted, util.NowStr(), gameID); err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
|
||||
content := fmt.Sprintf("投票结束!最多票指向:%s(%d票)\n\n真相:%s", accusedName, maxVotes, truth)
|
||||
msgID := newID()
|
||||
now := util.NowStr()
|
||||
if _, err := database.DB.Exec(
|
||||
`INSERT INTO chat_messages (id, session_id, sender_role_id, sender_name, message_type, content, phase_index, created_at)
|
||||
VALUES (?, ?, 'dm', 'DM', ?, ?, ?, ?)`,
|
||||
msgID, gameID, models.MessageTypeSystem, content, game.PhaseIndex, now); err != nil {
|
||||
writeError(w, 500, "服务器错误")
|
||||
return
|
||||
}
|
||||
|
||||
broadcast(gameID, map[string]interface{}{
|
||||
"type": "vote_result",
|
||||
"data": map[string]interface{}{
|
||||
"tally": tally,
|
||||
"accused_role_id": accusedRoleID,
|
||||
"accused_name": accusedName,
|
||||
"truth": truth,
|
||||
"game_id": gameID,
|
||||
"message": map[string]interface{}{
|
||||
"id": msgID,
|
||||
"sender_name": "DM",
|
||||
"sender_role_id": "dm",
|
||||
"message_type": models.MessageTypeSystem,
|
||||
"content": content,
|
||||
"created_at": now,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
removeGameConns(gameID)
|
||||
|
||||
writeJSON(w, 200, map[string]interface{}{
|
||||
"status": "completed",
|
||||
"accused": accusedName,
|
||||
"truth": truth,
|
||||
})
|
||||
}
|
||||
|
||||
func GetGameState(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
game, err := getGame(gameID)
|
||||
if err != nil {
|
||||
writeError(w, 404, "游戏不存在")
|
||||
return
|
||||
}
|
||||
|
||||
players := queryPlayers(gameID)
|
||||
clues := queryClues(gameID)
|
||||
msgs := queryMessages(gameID)
|
||||
script := scriptFromConfig(game.Config)
|
||||
|
||||
playersOut := []map[string]interface{}{}
|
||||
for _, p := range players {
|
||||
playersOut = append(playersOut, map[string]interface{}{
|
||||
"id": p.ID,
|
||||
"role_id": p.RoleID,
|
||||
"role_name": p.RoleName,
|
||||
"is_human": p.IsHuman,
|
||||
"status": p.Status,
|
||||
})
|
||||
}
|
||||
cluesOut := []map[string]interface{}{}
|
||||
for _, c := range clues {
|
||||
cluesOut = append(cluesOut, map[string]interface{}{"clue_id": c.ClueID, "status": c.Status})
|
||||
}
|
||||
msgsOut := []map[string]interface{}{}
|
||||
for _, m := range msgs {
|
||||
msgsOut = append(msgsOut, messageDict(m))
|
||||
}
|
||||
|
||||
var scriptOut interface{}
|
||||
if script != nil {
|
||||
scriptOut = script
|
||||
} else {
|
||||
scriptOut = map[string]interface{}{}
|
||||
}
|
||||
|
||||
writeJSON(w, 200, map[string]interface{}{
|
||||
"id": game.ID,
|
||||
"script_id": game.ScriptID,
|
||||
"status": game.Status,
|
||||
"phase": game.Phase,
|
||||
"phase_index": game.PhaseIndex,
|
||||
"script": scriptOut,
|
||||
"players": playersOut,
|
||||
"clues": cluesOut,
|
||||
"messages": msgsOut,
|
||||
})
|
||||
}
|
||||
|
||||
func GetReplay(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
game, err := getGame(gameID)
|
||||
if err != nil {
|
||||
writeError(w, 404, "游戏不存在")
|
||||
return
|
||||
}
|
||||
|
||||
msgs := queryMessages(gameID)
|
||||
votes := queryVotes(gameID)
|
||||
players := map[string]*models.SessionPlayer{}
|
||||
for _, p := range queryPlayers(gameID) {
|
||||
players[p.ID] = p
|
||||
}
|
||||
script := scriptFromConfig(game.Config)
|
||||
|
||||
scriptTitle := game.ScriptID
|
||||
truth := ""
|
||||
var rolesOut []map[string]interface{}
|
||||
if script != nil {
|
||||
scriptTitle = script.Title
|
||||
truth = script.Truth
|
||||
for _, role := range script.Roles {
|
||||
rolesOut = append(rolesOut, map[string]interface{}{
|
||||
"id": role.ID,
|
||||
"name": role.Name,
|
||||
"publicProfile": role.PublicProfile,
|
||||
"secretProfile": role.SecretProfile,
|
||||
"goal": role.Goal,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
msgsOut := []map[string]interface{}{}
|
||||
for _, m := range msgs {
|
||||
msgsOut = append(msgsOut, map[string]interface{}{
|
||||
"id": m.ID,
|
||||
"sender_name": m.SenderName,
|
||||
"content": m.Content,
|
||||
"type": m.MessageType,
|
||||
"time": util.TimeStr(m.CreatedAt),
|
||||
})
|
||||
}
|
||||
voteDetails := []map[string]interface{}{}
|
||||
for _, v := range votes {
|
||||
voter := players[v.VoterID]
|
||||
target := players[v.TargetID]
|
||||
voterName := "unknown"
|
||||
if voter != nil {
|
||||
voterName = voter.RoleName
|
||||
}
|
||||
targetName := "unknown"
|
||||
if target != nil {
|
||||
targetName = target.RoleName
|
||||
}
|
||||
reason := ""
|
||||
if v.Reason != nil {
|
||||
reason = *v.Reason
|
||||
}
|
||||
voteDetails = append(voteDetails, map[string]interface{}{
|
||||
"voter_name": voterName,
|
||||
"target_name": targetName,
|
||||
"reason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, 200, map[string]interface{}{
|
||||
"game_id": gameID,
|
||||
"script_title": scriptTitle,
|
||||
"truth": truth,
|
||||
"roles": rolesOut,
|
||||
"messages": msgsOut,
|
||||
"votes": voteDetails,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"mmgame/internal/database"
|
||||
"mmgame/internal/models"
|
||||
"mmgame/internal/scripts"
|
||||
"mmgame/internal/util"
|
||||
)
|
||||
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, detail string) {
|
||||
writeJSON(w, status, map[string]string{"detail": detail})
|
||||
}
|
||||
|
||||
func nullStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func Health(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func parseConfig(configStr string) map[string]interface{} {
|
||||
if configStr == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(configStr), &m); err != nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func scriptFromConfig(configStr string) *scripts.Script {
|
||||
cfg := parseConfig(configStr)
|
||||
raw, ok := cfg["script"]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var s scripts.Script
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func getGame(gameID string) (*models.GameSession, error) {
|
||||
row := database.DB.QueryRow(
|
||||
`SELECT id, script_id, status, phase, phase_index, started_at, completed_at, config, created_at
|
||||
FROM game_sessions WHERE id = ?`, gameID)
|
||||
var g models.GameSession
|
||||
var startedAt, completedAt, configStr, createdAt sql.NullString
|
||||
err := row.Scan(&g.ID, &g.ScriptID, &g.Status, &g.Phase, &g.PhaseIndex,
|
||||
&startedAt, &completedAt, &configStr, &createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if startedAt.Valid {
|
||||
t := util.ParseTime(startedAt.String)
|
||||
g.StartedAt = &t
|
||||
}
|
||||
if completedAt.Valid {
|
||||
t := util.ParseTime(completedAt.String)
|
||||
g.CompletedAt = &t
|
||||
}
|
||||
g.Config = configStr.String
|
||||
g.CreatedAt = util.ParseTime(createdAt.String)
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func queryPlayers(gameID string) []*models.SessionPlayer {
|
||||
rows, err := database.DB.Query(
|
||||
`SELECT id, session_id, user_id, role_id, role_name, is_human, is_ready, avatar_url, status, joined_at
|
||||
FROM session_players WHERE session_id = ?`, gameID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []*models.SessionPlayer
|
||||
for rows.Next() {
|
||||
p := &models.SessionPlayer{}
|
||||
var userID, avatarURL, joinedAt sql.NullString
|
||||
if err := rows.Scan(&p.ID, &p.SessionID, &userID, &p.RoleID, &p.RoleName,
|
||||
&p.IsHuman, &p.IsReady, &avatarURL, &p.Status, &joinedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if userID.Valid {
|
||||
p.UserID = &userID.String
|
||||
}
|
||||
if avatarURL.Valid {
|
||||
p.AvatarURL = &avatarURL.String
|
||||
}
|
||||
p.JoinedAt = util.ParseTime(joinedAt.String)
|
||||
list = append(list, p)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func queryMessages(gameID string) []*models.ChatMessage {
|
||||
rows, err := database.DB.Query(
|
||||
`SELECT id, session_id, sender_role_id, sender_name, message_type, content, target_role_id, clue_id, phase_index, created_at
|
||||
FROM chat_messages WHERE session_id = ? ORDER BY created_at`, gameID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []*models.ChatMessage
|
||||
for rows.Next() {
|
||||
m := &models.ChatMessage{}
|
||||
var targetRoleID, clueID, createdAt sql.NullString
|
||||
if err := rows.Scan(&m.ID, &m.SessionID, &m.SenderRoleID, &m.SenderName, &m.MessageType,
|
||||
&m.Content, &targetRoleID, &clueID, &m.PhaseIndex, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if targetRoleID.Valid {
|
||||
m.TargetRoleID = &targetRoleID.String
|
||||
}
|
||||
if clueID.Valid {
|
||||
m.ClueID = &clueID.String
|
||||
}
|
||||
m.CreatedAt = util.ParseTime(createdAt.String)
|
||||
list = append(list, m)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func queryClues(gameID string) []*models.ClueState {
|
||||
rows, err := database.DB.Query(
|
||||
`SELECT id, session_id, clue_id, status, revealed_by, released_at, created_at
|
||||
FROM clues_state WHERE session_id = ?`, gameID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []*models.ClueState
|
||||
for rows.Next() {
|
||||
c := &models.ClueState{}
|
||||
var revealedBy, releasedAt, createdAt sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.SessionID, &c.ClueID, &c.Status, &revealedBy, &releasedAt, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if revealedBy.Valid {
|
||||
c.RevealedBy = &revealedBy.String
|
||||
}
|
||||
if releasedAt.Valid {
|
||||
t := util.ParseTime(releasedAt.String)
|
||||
c.ReleasedAt = &t
|
||||
}
|
||||
c.CreatedAt = util.ParseTime(createdAt.String)
|
||||
list = append(list, c)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func queryVotes(gameID string) []*models.Vote {
|
||||
rows, err := database.DB.Query(
|
||||
`SELECT id, session_id, round, voter_id, target_id, reason, created_at
|
||||
FROM votes WHERE session_id = ?`, gameID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []*models.Vote
|
||||
for rows.Next() {
|
||||
v := &models.Vote{}
|
||||
var reason, createdAt sql.NullString
|
||||
if err := rows.Scan(&v.ID, &v.SessionID, &v.Round, &v.VoterID, &v.TargetID, &reason, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if reason.Valid {
|
||||
v.Reason = &reason.String
|
||||
}
|
||||
v.CreatedAt = util.ParseTime(createdAt.String)
|
||||
list = append(list, v)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func messageDict(m *models.ChatMessage) map[string]interface{} {
|
||||
d := map[string]interface{}{
|
||||
"id": m.ID,
|
||||
"sender_role_id": m.SenderRoleID,
|
||||
"sender_name": m.SenderName,
|
||||
"message_type": m.MessageType,
|
||||
"content": m.Content,
|
||||
"target_role_id": m.TargetRoleID,
|
||||
"clue_id": m.ClueID,
|
||||
"phase_index": m.PhaseIndex,
|
||||
"created_at": util.TimeStr(m.CreatedAt),
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"mmgame/internal/scripts"
|
||||
)
|
||||
|
||||
func ListScripts(w http.ResponseWriter, r *http.Request) {
|
||||
items := []map[string]interface{}{}
|
||||
for _, s := range scripts.SCRIPTS {
|
||||
items = append(items, map[string]interface{}{
|
||||
"id": s.ID,
|
||||
"title": s.Title,
|
||||
"type": s.Type,
|
||||
"difficulty": s.Difficulty,
|
||||
"min_players": s.PlayerCount.Min,
|
||||
"max_players": s.PlayerCount.Max,
|
||||
"duration": s.Duration,
|
||||
"background": s.Background,
|
||||
})
|
||||
}
|
||||
writeJSON(w, 200, items)
|
||||
}
|
||||
|
||||
func GetScript(w http.ResponseWriter, r *http.Request) {
|
||||
scriptID := chi.URLParam(r, "script_id")
|
||||
s := scripts.GetByID(scriptID)
|
||||
if s == nil {
|
||||
writeError(w, 404, "剧本不存在")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, s)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Conn *websocket.Conn
|
||||
RoleID string
|
||||
}
|
||||
|
||||
var (
|
||||
connsMu sync.RWMutex
|
||||
activeConns = map[string][]*Client{}
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
func removeConn(gameID string, c *Client) {
|
||||
connsMu.Lock()
|
||||
defer connsMu.Unlock()
|
||||
clients := activeConns[gameID]
|
||||
for i, x := range clients {
|
||||
if x == c {
|
||||
activeConns[gameID] = append(clients[:i], clients[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(activeConns[gameID]) == 0 {
|
||||
delete(activeConns, gameID)
|
||||
}
|
||||
}
|
||||
|
||||
func removeGameConns(gameID string) {
|
||||
connsMu.Lock()
|
||||
defer connsMu.Unlock()
|
||||
if clients, ok := activeConns[gameID]; ok {
|
||||
for _, c := range clients {
|
||||
_ = c.Conn.Close()
|
||||
}
|
||||
}
|
||||
delete(activeConns, gameID)
|
||||
}
|
||||
|
||||
func broadcast(gameID string, message interface{}) {
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
connsMu.RLock()
|
||||
clients := append([]*Client{}, activeConns[gameID]...)
|
||||
connsMu.RUnlock()
|
||||
for _, c := range clients {
|
||||
if err := c.Conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
removeConn(gameID, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastToRole(gameID, targetRoleID string, message interface{}) {
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
connsMu.RLock()
|
||||
clients := append([]*Client{}, activeConns[gameID]...)
|
||||
connsMu.RUnlock()
|
||||
for _, c := range clients {
|
||||
if c.RoleID != "" && c.RoleID != targetRoleID {
|
||||
continue
|
||||
}
|
||||
if err := c.Conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
removeConn(gameID, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GameWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
roleID := r.URL.Query().Get("role_id")
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
client := &Client{Conn: conn, RoleID: roleID}
|
||||
|
||||
connsMu.Lock()
|
||||
activeConns[gameID] = append(activeConns[gameID], client)
|
||||
connsMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
removeConn(gameID, client)
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
if msg.Type == "ping" {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"pong"}`)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
RolePlayer = "player"
|
||||
RoleCreator = "creator"
|
||||
RoleAdmin = "admin"
|
||||
|
||||
ScriptSourceBuiltin = "builtin"
|
||||
ScriptSourceAIGenerated = "ai_generated"
|
||||
ScriptSourceUploaded = "uploaded"
|
||||
ScriptSourceCreator = "creator"
|
||||
|
||||
ScriptStatusDraft = "draft"
|
||||
ScriptStatusPublished = "published"
|
||||
ScriptStatusDisabled = "disabled"
|
||||
|
||||
GameStatusWaiting = "waiting"
|
||||
GameStatusPlaying = "playing"
|
||||
GameStatusPaused = "paused"
|
||||
GameStatusCompleted = "completed"
|
||||
|
||||
PlayerStatusActive = "active"
|
||||
PlayerStatusLeft = "left"
|
||||
PlayerStatusEliminated = "eliminated"
|
||||
|
||||
MessageTypePublic = "public"
|
||||
MessageTypePrivate = "private"
|
||||
MessageTypeSystem = "system"
|
||||
MessageTypeClue = "clue"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email *string
|
||||
Nickname *string
|
||||
AvatarURL *string
|
||||
PasswordHash *string
|
||||
Role string
|
||||
IsGuest bool
|
||||
GameCount int
|
||||
TokenVersion int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type GameSession struct {
|
||||
ID string
|
||||
ScriptID string
|
||||
Status string
|
||||
Phase string
|
||||
PhaseIndex int
|
||||
StartedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
Config string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type SessionPlayer struct {
|
||||
ID string
|
||||
SessionID string
|
||||
UserID *string
|
||||
RoleID string
|
||||
RoleName string
|
||||
IsHuman bool
|
||||
IsReady bool
|
||||
AvatarURL *string
|
||||
Status string
|
||||
JoinedAt time.Time
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
ID string
|
||||
SessionID string
|
||||
SenderRoleID string
|
||||
SenderName string
|
||||
MessageType string
|
||||
Content string
|
||||
TargetRoleID *string
|
||||
ClueID *string
|
||||
PhaseIndex int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ClueState struct {
|
||||
ID string
|
||||
SessionID string
|
||||
ClueID string
|
||||
Status string
|
||||
RevealedBy *string
|
||||
ReleasedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Vote struct {
|
||||
ID string
|
||||
SessionID string
|
||||
Round int
|
||||
VoterID string
|
||||
TargetID string
|
||||
Reason *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
[
|
||||
{
|
||||
"id": "script_fog",
|
||||
"title": "雾港庄园谋杀案",
|
||||
"type": "hardcore",
|
||||
"difficulty": 2,
|
||||
"playerCount": {
|
||||
"min": 1,
|
||||
"max": 5
|
||||
},
|
||||
"duration": "60-90min",
|
||||
"background": "1940年深秋,雾港庄园的主人——富商爱德华·格雷伯爵在一场暴雨夜中被发现死于书房。房门反锁,窗户紧闭,这是一间密室。在座的每一位客人都有嫌疑,每一个微笑背后都藏着秘密。",
|
||||
"phases": [
|
||||
{
|
||||
"name": "第一幕:暴雨夜宴",
|
||||
"description": "暴雨之夜,格雷伯爵邀请诸位宾客参加晚宴。席间伯爵面色凝重,似乎在隐藏什么。午夜钟声响起,仆人发现伯爵死于书房。",
|
||||
"publicInfo": "所有人都在客厅等待,仆人老张去书房叫伯爵时发现尸体。",
|
||||
"clues": [
|
||||
"clue_1",
|
||||
"clue_2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第二幕:迷雾重重",
|
||||
"description": "众人震惊之余,开始回忆当晚的细节。每个人似乎都在隐瞒什么。",
|
||||
"publicInfo": "法医初步判断死亡时间为晚上10点到11点之间,死因是中毒。",
|
||||
"clues": [
|
||||
"clue_3",
|
||||
"clue_4",
|
||||
"clue_5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第三幕:真相浮现",
|
||||
"description": "随着调查深入,更多线索浮出水面。密室的真相、每个人的动机,一切即将揭晓。",
|
||||
"publicInfo": "书房的门锁是老旧式样的机械锁,从内部反锁需要转动钥匙。",
|
||||
"clues": [
|
||||
"clue_6",
|
||||
"clue_7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "最终幕:投票与结局",
|
||||
"description": "所有线索已呈现,是时候指认真凶了。",
|
||||
"publicInfo": "请各位做出最终判断。",
|
||||
"clues": []
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"id": "role_detective",
|
||||
"name": "侦探·林默",
|
||||
"publicProfile": "一位来自伦敦的私家侦探,受伯爵邀请前来参加晚宴。",
|
||||
"secretProfile": "你是爱德华的旧友,半年前曾帮他处理过一起勒索案。你怀疑爱德华最近又被人威胁了。",
|
||||
"secret": "你其实一直在暗中调查爱德华,怀疑他与一桩军火走私案有关。",
|
||||
"goal": "查明真凶,揭露真相。",
|
||||
"winCondition": "正确指认凶手",
|
||||
"personality": "冷静理智、善于观察"
|
||||
},
|
||||
{
|
||||
"id": "role_white",
|
||||
"name": "白若溪",
|
||||
"publicProfile": "伯爵的私人秘书,跟随伯爵五年,深得信任。",
|
||||
"secretProfile": "你深爱着爱德华,但他却对你不冷不热。最近你发现他准备解雇你,因为你知道的太多了。",
|
||||
"secret": "你偷了伯爵保险柜里的一封密信,信中揭露了军火交易的细节。",
|
||||
"goal": "保护自己,不要让任何人发现你偷了信。",
|
||||
"winCondition": "不被指认为凶手",
|
||||
"personality": "温柔优雅但内心焦虑"
|
||||
},
|
||||
{
|
||||
"id": "role_black",
|
||||
"name": "黑蛇",
|
||||
"publicProfile": "一位神秘的东方商人,当晚第一次拜访庄园。",
|
||||
"secretProfile": "你是爱德华的生意伙伴,但最近一批货出了问题,爱德华拒绝赔偿你的损失。",
|
||||
"secret": "你今晚在伯爵的酒杯里下了安眠药,本想等他睡着后去书房偷一份合约,但没想到他死了。",
|
||||
"goal": "洗脱嫌疑,不要让任何人知道你下药的事。",
|
||||
"winCondition": "不被指认为凶手",
|
||||
"personality": "深沉狡诈、善于言辞"
|
||||
},
|
||||
{
|
||||
"id": "role_red",
|
||||
"name": "红玫瑰",
|
||||
"publicProfile": "一位过气的歌剧演员,自称是伯爵的远房表妹。",
|
||||
"secretProfile": "你根本不是伯爵的表妹。你是一个骗子,想利用伯爵的社交圈东山再起。爱德华已经发现了你的真实身份。",
|
||||
"secret": "你曾在10点左右去书房找伯爵求情,发现他正在和一个神秘人激烈争吵。你躲在门外偷听,听到了“军火”和“灭口”等词。",
|
||||
"goal": "维持你的假身份,找出伯爵之死的真相趁机牟利。",
|
||||
"winCondition": "不被指认为凶手",
|
||||
"personality": "戏剧化、爱出风头"
|
||||
},
|
||||
{
|
||||
"id": "role_green",
|
||||
"name": "翡翠",
|
||||
"publicProfile": "庄园的女仆,已经在庄园工作了三年。",
|
||||
"secretProfile": "你的弟弟曾经是爱德华工厂的工人,因工伤去世,爱德华只赔了一笔小钱。你来到庄园做女仆,是为了收集爱德华的罪证。",
|
||||
"secret": "你在晚宴开始前清理书房时,在书架后面发现了一把手枪。你偷偷藏了起来。",
|
||||
"goal": "为弟弟讨回公道,找出爱德华的犯罪证据。",
|
||||
"winCondition": "找出爱德华的全部罪证",
|
||||
"personality": "沉默寡言、内心坚毅"
|
||||
},
|
||||
{
|
||||
"id": "role_old_zhang",
|
||||
"name": "老张",
|
||||
"publicProfile": "庄园的老管家,服务了格雷家族三十年。",
|
||||
"secretProfile": "你看着爱德华长大,知道他所有的秘密。你一直帮他掩盖那些见不得光的事,但最近你良心不安。",
|
||||
"secret": "是你帮爱德华处理了那封勒索信的送信人。你把这个秘密埋在心底。",
|
||||
"goal": "保护庄园的名誉,但也要守住自己的秘密。",
|
||||
"winCondition": "真相不被完全揭开",
|
||||
"personality": "忠诚但矛盾、老谋深算"
|
||||
}
|
||||
],
|
||||
"clues": [
|
||||
{
|
||||
"id": "clue_1",
|
||||
"name": "破碎的酒杯",
|
||||
"description": "书房地毯上有一个破碎的高脚杯,检测出残留的安眠药成分。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "clue_2",
|
||||
"name": "反锁的门",
|
||||
"description": "书房的门从内部反锁,钥匙还插在锁孔里。但窗户有一道缝隙,勉强可以穿过一根细线。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "clue_3",
|
||||
"name": "神秘信件",
|
||||
"description": "书桌上有一封没有寄出的信,收件人是“伦敦警察厅”,内容提到“军火走私”和“灭口”。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "clue_4",
|
||||
"name": "壁炉里的灰烬",
|
||||
"description": "壁炉里有烧毁的文件残片,依稀可辨“合约”和“5000英镑”字样。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "clue_5",
|
||||
"name": "时间线记录",
|
||||
"description": "根据众人的证词整理的时间线:8点晚宴开始,9点伯爵离席去书房,10点半红玫瑰去书房,11点黑蛇去书房,11点半老张发现尸体。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "clue_6",
|
||||
"name": "书架后的手枪",
|
||||
"description": "书架后面发现一把手枪,已经擦去了指纹。经检测,这把枪近期发射过。",
|
||||
"phase": 3
|
||||
},
|
||||
{
|
||||
"id": "clue_7",
|
||||
"name": "密室的真相",
|
||||
"description": "仔细观察门锁结构,发现可以用鱼线从门缝下方套住钥匙,从外部反锁房门。",
|
||||
"phase": 3
|
||||
}
|
||||
],
|
||||
"truth": "真凶是老张。他利用鱼线从门缝制造了密室,在伯爵的酒杯里下毒。动机:伯爵发现了老张三十年前的一桩旧案,以此要挟他。老张为了保护自己,先下手为强。"
|
||||
},
|
||||
{
|
||||
"id": "script_zero",
|
||||
"title": "零号舱的悖论",
|
||||
"type": "hardcore",
|
||||
"difficulty": 4,
|
||||
"playerCount": {
|
||||
"min": 1,
|
||||
"max": 5
|
||||
},
|
||||
"duration": "90-120min",
|
||||
"background": "公元2157年,人类第一艘超光速飞船“零号舱”在试飞中消失。72小时后,飞船突然出现在木星轨道上,船体完好但全员失忆。更诡异的是,船员们的记忆碎片拼接后发现——他们中有一个人,根本不属于这个时空。",
|
||||
"phases": [
|
||||
{
|
||||
"name": "第一幕:失忆的船员",
|
||||
"description": "你在飞船上醒来,发现自己失去了部分记忆。面前是5个同样茫然的同伴。飞船AI报告:有人篡改了航行日志。",
|
||||
"publicInfo": "飞船AI显示航行日志被篡改,最后一个条目写着“他不是我们中的一员”。",
|
||||
"clues": [
|
||||
"zc_clue_1",
|
||||
"zc_clue_2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第二幕:记忆碎片",
|
||||
"description": "随着交流深入,每个人的记忆开始恢复片段。但奇怪的是,有些人的记忆互相矛盾。",
|
||||
"publicInfo": "船上有5人:船长、工程师、医疗官、生物学家、通讯员。其中一人的记忆与其他四人不在同一条时间线。",
|
||||
"clues": [
|
||||
"zc_clue_3",
|
||||
"zc_clue_4",
|
||||
"zc_clue_5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第三幕:悖论的核心",
|
||||
"description": "计算显示,超光速飞行导致了时间裂缝。两个平行宇宙的人在那一刻被融合到了一起。",
|
||||
"publicInfo": "飞船AI复原了部分航行数据,证明发生过时空异常。",
|
||||
"clues": [
|
||||
"zc_clue_6",
|
||||
"zc_clue_7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "最终幕:投票与结局",
|
||||
"description": "必须找出那个不属于这个时空的人,否则飞船的能量核心将在15分钟后过载。",
|
||||
"publicInfo": "请做出最终判断。",
|
||||
"clues": []
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"id": "zc_captain",
|
||||
"name": "艾伦船长",
|
||||
"publicProfile": "“零号舱”的船长,深空探索计划的王牌飞行员。",
|
||||
"secretProfile": "你记得起飞前收到过一封密令:这次飞行有秘密任务——测试一种量子纠缠通讯装置。但你在飞行日志里找不到任何记录。",
|
||||
"secret": "你其实是另一个平行宇宙的艾伦船长。在那个宇宙,飞行失败了,全员遇难。你不知道为什么你还活着。",
|
||||
"goal": "找出时空异常者,保护飞船。",
|
||||
"winCondition": "正确投出异常者",
|
||||
"personality": "果断坚毅、有领导力"
|
||||
},
|
||||
{
|
||||
"id": "zc_engineer",
|
||||
"name": "伊娃工程师",
|
||||
"publicProfile": "天才工程师,负责飞船动力系统。",
|
||||
"secretProfile": "你在飞行前偷偷在飞船主控系统里植入了一个后门程序——为了监视船长的秘密任务。",
|
||||
"secret": "你发现飞船的能量读数异常:实际消耗比记录多了300%。有人在偷偷使用额外能量。",
|
||||
"goal": "弄清楚秘密任务的真相。",
|
||||
"winCondition": "查明全部真相",
|
||||
"personality": "聪明谨慎、略带神经质"
|
||||
},
|
||||
{
|
||||
"id": "zc_medic",
|
||||
"name": "王医疗官",
|
||||
"publicProfile": "飞船医疗官,也是心理学专家。",
|
||||
"secretProfile": "你在飞行前注意到所有船员都有轻微的记忆偏差,但你以为只是压力导致的。",
|
||||
"secret": "你给自己做了记忆扫描,发现自己有一段被植入的虚假记忆——关于某个船员的背景。",
|
||||
"goal": "保护船员心理健康,找出真相。",
|
||||
"winCondition": "正确投出异常者",
|
||||
"personality": "温和理性、善于倾听"
|
||||
},
|
||||
{
|
||||
"id": "zc_bio",
|
||||
"name": "萨沙生物学家",
|
||||
"publicProfile": "研究外星微生物的科学家,本次飞行是为了采集木卫二冰层下的样本。",
|
||||
"secretProfile": "你其实不是真正的萨沙。真正的萨沙在起飞前三天失踪了,你是被秘密机构派来顶替的。",
|
||||
"secret": "你知道零号舱的真正任务:寻找外星生命样本——但不能让其他船员知道。",
|
||||
"goal": "完成样本采集任务,不暴露身份。",
|
||||
"winCondition": "不被发现真实身份",
|
||||
"personality": "神秘疏离、知识渊博"
|
||||
},
|
||||
{
|
||||
"id": "zc_comms",
|
||||
"name": "李通讯官",
|
||||
"publicProfile": "飞船通讯官,负责与地球保持联系。",
|
||||
"secretProfile": "你发现通讯系统被人为干扰了。地球发来的信号中有重复的摩斯密码,内容是“小心”。",
|
||||
"secret": "你在失忆前最后一刻发送了一条求救信号。但现在通讯系统显示:信号从未发出过。",
|
||||
"goal": "恢复通讯,向地球报告情况。",
|
||||
"winCondition": "让飞船安全返航",
|
||||
"personality": "活泼外向、容易紧张"
|
||||
}
|
||||
],
|
||||
"clues": [
|
||||
{
|
||||
"id": "zc_clue_1",
|
||||
"name": "矛盾的照片",
|
||||
"description": "船员合影中,有一个人出现在了不该出现的位置——合影拍摄时他应该在飞船另一侧。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "zc_clue_2",
|
||||
"name": "篡改的日志",
|
||||
"description": "航行日志被修改了37次,修改者ID显示为“所有人”。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "zc_clue_3",
|
||||
"name": "能量异常",
|
||||
"description": "飞船能量消耗记录显示,有额外的300%能量被消耗,但找不到去向。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "zc_clue_4",
|
||||
"name": "双重记忆",
|
||||
"description": "两个船员对同一事件的记忆完全不同——一个记得船长在驾驶舱,另一个记得船长在餐厅。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "zc_clue_5",
|
||||
"name": "量子纠缠信号",
|
||||
"description": "飞船接收到一组量子纠缠信号,编码方式不属于已知的任何人类通讯协议。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "zc_clue_6",
|
||||
"name": "平行宇宙理论",
|
||||
"description": "飞船AI数据库中有被加密访问的记录:关于平行宇宙融合的物理学论文,访问时间正是起飞前。",
|
||||
"phase": 3
|
||||
},
|
||||
{
|
||||
"id": "zc_clue_7",
|
||||
"name": "最后的录像",
|
||||
"description": "在飞船黑匣子里发现一段录像:一个船员对着镜头说“我是唯一真实的人”,随后录像被删除。",
|
||||
"phase": 3
|
||||
}
|
||||
],
|
||||
"truth": "真正的异常者是艾伦船长。他来自另一个平行宇宙——在那个宇宙中零号舱爆炸了,他的意识在爆炸瞬间穿越到了这个宇宙的艾伦身上。两个艾伦的意识在同一具身体里共存,导致记忆混乱。"
|
||||
},
|
||||
{
|
||||
"id": "script_photo",
|
||||
"title": "遗忘照相馆",
|
||||
"type": "emotional",
|
||||
"difficulty": 1,
|
||||
"playerCount": {
|
||||
"min": 1,
|
||||
"max": 4
|
||||
},
|
||||
"duration": "45-60min",
|
||||
"background": "在城市的老街拐角,有一家不起眼的照相馆。店主是一位老人,据说他拍的照片能让人看到最想念的人。每个走进这家照相馆的人,都带着一段未了的故事。",
|
||||
"phases": [
|
||||
{
|
||||
"name": "第一幕:走进照相馆",
|
||||
"description": "你推开那扇吱呀作响的木门,风铃响起。店内陈列着许多黑白照片,每一张都像在诉说一个故事。",
|
||||
"publicInfo": "照相馆内光线昏暗,墙上挂满了照片。柜台后坐着一位白发老人。",
|
||||
"clues": [
|
||||
"ph_clue_1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第二幕:照片中的秘密",
|
||||
"description": "老人让你挑选一张照片。你伸手触碰的瞬间,照片中的人影开始晃动——你看到了自己最深处的记忆。",
|
||||
"publicInfo": "每一张照片都对应着一个人内心最深的牵挂。",
|
||||
"clues": [
|
||||
"ph_clue_2",
|
||||
"ph_clue_3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第三幕:未说出口的话",
|
||||
"description": "照片中的人开始说话,那是你一直想见却再也见不到的人。你有机会说出当年没说完的话。",
|
||||
"publicInfo": "照片中的影像只能持续十分钟。",
|
||||
"clues": [
|
||||
"ph_clue_4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "最终幕:告别",
|
||||
"description": "十分钟到了,照片中的人影渐渐淡去。但你知道,有些话终于说出了口。",
|
||||
"publicInfo": "老人微笑着看着你,眼角有泪光。",
|
||||
"clues": []
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"id": "ph_old_man",
|
||||
"name": "照相馆老人",
|
||||
"publicProfile": "照相馆的老板,一位白发苍苍的老人,眼神深邃。",
|
||||
"secretProfile": "你其实已经去世十年了。这家照相馆是你留在人间的执念——帮助那些有遗憾的人完成告别。",
|
||||
"secret": "每一张照片都是你用自己的记忆编织的幻象。每帮一个人,你的存在就会消散一分。",
|
||||
"goal": "在彻底消失之前,帮助尽可能多的人。",
|
||||
"winCondition": "帮助客人完成心愿",
|
||||
"personality": "慈祥智慧、略带忧伤"
|
||||
},
|
||||
{
|
||||
"id": "ph_guest_1",
|
||||
"name": "寻女的母亲",
|
||||
"publicProfile": "一位中年妇女,神色憔悴,手中紧握一张泛黄的全家福。",
|
||||
"secretProfile": "你的女儿在五年前的一场事故中去世了。你一直无法接受这个事实,每年她的生日你都会来这家照相馆。",
|
||||
"secret": "你今天带来了女儿最喜欢的那条红围巾。你想再给她围上一次。",
|
||||
"goal": "再见女儿一面,告诉她妈妈永远爱她。",
|
||||
"winCondition": "完成与女儿的最后告别",
|
||||
"personality": "温柔坚强、隐忍"
|
||||
},
|
||||
{
|
||||
"id": "ph_guest_2",
|
||||
"name": "愧疚的丈夫",
|
||||
"publicProfile": "一位三十多岁的男人,西装革履但满脸疲惫。",
|
||||
"secretProfile": "你的妻子在生宝宝时难产去世了。这几年你一直忙于工作,用忙碌麻痹自己。",
|
||||
"secret": "你从来没敢给宝宝看妈妈的照片。你怕自己先哭出来。",
|
||||
"goal": "告诉妻子你有多想她,告诉她宝宝长得像她。",
|
||||
"winCondition": "说出藏在心里的话",
|
||||
"personality": "表面坚强、内心脆弱"
|
||||
},
|
||||
{
|
||||
"id": "ph_guest_3",
|
||||
"name": "寻兄的少年",
|
||||
"publicProfile": "一个十几岁的少年,背着书包,眼神倔强。",
|
||||
"secretProfile": "你的哥哥是消防员,去年在一次火灾中牺牲了。你们最后一次吵架是因为一台游戏机。",
|
||||
"secret": "你一直偷偷留着哥哥那台旧游戏机,但你再也没打开过。",
|
||||
"goal": "跟哥哥说对不起,告诉他你是他的骄傲。",
|
||||
"winCondition": "与哥哥和解",
|
||||
"personality": "倔强叛逆、内心柔软"
|
||||
}
|
||||
],
|
||||
"clues": [
|
||||
{
|
||||
"id": "ph_clue_1",
|
||||
"name": "泛黄的相册",
|
||||
"description": "柜台上放着一本手工相册,扉页上写着“送给每一个需要告别的人”。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "ph_clue_2",
|
||||
"name": "会动的照片",
|
||||
"description": "当你触碰照片时,画面中的人像开始活动,就像一段被封存的记忆被重新播放。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "ph_clue_3",
|
||||
"name": "老人的倒影",
|
||||
"description": "墙上的镜子里,老人没有倒影。但你不敢细想。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "ph_clue_4",
|
||||
"name": "消散的边缘",
|
||||
"description": "老人的身影在灯光下渐渐变得透明,像一张正在褪色的照片。",
|
||||
"phase": 3
|
||||
}
|
||||
],
|
||||
"truth": "照相馆老人是一个未能完成心愿的亡灵。他生前是这家照相馆的老板,去世前最遗憾的是没能和女儿好好告别。于是他化作照相馆的一部分,用最后的能力帮助每一个带着遗憾而来的人。当最后一位客人完成告别时,老人的身影化作星光消散——他终于可以安息了。"
|
||||
},
|
||||
{
|
||||
"id": "script_jade",
|
||||
"title": "翡翠城的交易",
|
||||
"type": "欢乐",
|
||||
"difficulty": 2,
|
||||
"playerCount": {
|
||||
"min": 1,
|
||||
"max": 6
|
||||
},
|
||||
"duration": "60-90min",
|
||||
"background": "翡翠城是沙漠中唯一的不夜城。今晚,城中最有权势的商人金大牙将在他的赌场举办一场盛大的拍卖会。传闻一件价值连城的宝物“翡翠之心”将在今晚易主。但在拍卖开始前,金大牙的保险柜被洗劫一空。",
|
||||
"phases": [
|
||||
{
|
||||
"name": "第一幕:拍卖之夜",
|
||||
"description": "赌场内灯火辉煌,宾客云集。金大牙站在舞台上宣布拍卖开始,但当他想展示“翡翠之心”时,保险柜已经空了。",
|
||||
"publicInfo": "保险柜完好无损,密码锁没有被破坏的痕迹——这意味着是知道密码的人干的。",
|
||||
"clues": [
|
||||
"jd_clue_1",
|
||||
"jd_clue_2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第二幕:各怀鬼胎",
|
||||
"description": "金大牙封锁了赌场,所有人都有嫌疑。每个宾客似乎都有不可告人的目的。",
|
||||
"publicInfo": "金大牙宣布:在找到“翡翠之心”之前,谁也别想离开。",
|
||||
"clues": [
|
||||
"jd_clue_3",
|
||||
"jd_clue_4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第三幕:谁在说谎",
|
||||
"description": "通过搜证和盘问,宾客们发现每个人都在打“翡翠之心”的主意,但真正的窃贼只有一个。",
|
||||
"publicInfo": "有人匿名送来了一张纸条:“翡翠之心已经在拍卖会前就被调包了。”",
|
||||
"clues": [
|
||||
"jd_clue_5",
|
||||
"jd_clue_6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "最终幕:指认窃贼",
|
||||
"description": "所有线索都指向了真相。谁才是真正的窃贼?",
|
||||
"publicInfo": "金大牙给出了最后的机会:主动交出翡翠之心,既往不咎。",
|
||||
"clues": []
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"id": "jd_gold",
|
||||
"name": "金大牙",
|
||||
"publicProfile": "赌场老板,翡翠城首富,以精明的商业头脑和黄金大牙闻名。",
|
||||
"secretProfile": "你根本没有“翡翠之心”。你在上个月就已经把它卖掉了,今晚的拍卖会只是一个幌子——为了骗保险。",
|
||||
"secret": "保险柜是你自己派人偷偷清空的,目的是获取巨额保险金。但你没想到,真的有人在你行动之前就把东西拿走了。",
|
||||
"goal": "查出是谁拿走了本应被你拿走的东西。",
|
||||
"winCondition": "找回失物且不被发现骗保",
|
||||
"personality": "精明圆滑、嚣张跋扈"
|
||||
},
|
||||
{
|
||||
"id": "jd_blind",
|
||||
"name": "盲眼算师",
|
||||
"publicProfile": "赌场的首席算牌师,虽然双目失明但听力超群。",
|
||||
"secretProfile": "你的眼睛其实没有问题。你装瞎是为了收集赌场的内幕信息——你是竞争对手派来的商业间谍。",
|
||||
"secret": "你昨晚目睹了一个人鬼鬼祟祟地接近金大牙的办公室。但因为你是“盲人”,你不能说出来。",
|
||||
"goal": "偷取金大牙的客户名单。",
|
||||
"winCondition": "获取名单且不暴露身份",
|
||||
"personality": "神秘莫测、话中有话"
|
||||
},
|
||||
{
|
||||
"id": "jd_dancer",
|
||||
"name": "红舞娘",
|
||||
"publicProfile": "赌场的驻场舞者,以曼妙的舞姿倾倒无数客人。",
|
||||
"secretProfile": "你曾经是金大牙的情人,但他抛弃了你。你来参加今晚的拍卖会是为了报复。",
|
||||
"secret": "你在金大牙的酒里下了泻药。本想让他在拍卖会上出丑,没想到发生了失窃案。",
|
||||
"goal": "让金大牙身败名裂。",
|
||||
"winCondition": "揭露金大牙的丑闻",
|
||||
"personality": "风情万种、敢爱敢恨"
|
||||
},
|
||||
{
|
||||
"id": "jd_traveler",
|
||||
"name": "沙漠旅人",
|
||||
"publicProfile": "一个路过的旅行商人,自称是第一次来翡翠城。",
|
||||
"secretProfile": "你是一名职业盗贼,专偷富商。你已经在翡翠城踩点了一个月。",
|
||||
"secret": "你确实打算偷“翡翠之心”,但当你潜入保险柜时,发现里面已经空了!你被人抢先一步。",
|
||||
"goal": "洗脱嫌疑,找到真正的窃贼。",
|
||||
"winCondition": "不被当成替罪羊",
|
||||
"personality": "洒脱不羁、玩世不恭"
|
||||
},
|
||||
{
|
||||
"id": "jd_waiter",
|
||||
"name": "阿福",
|
||||
"publicProfile": "赌场的侍者,在赌场工作了十年,老实本分。",
|
||||
"secretProfile": "你是金大牙的私生子,但你妈从来没让你认他。你来赌场工作只是为了离这个父亲近一点。",
|
||||
"secret": "你有金大牙办公室的备用钥匙。你经常在深夜偷偷进去,只是想在父亲待过的地方坐一坐。",
|
||||
"goal": "保护金大牙,虽然他不知道自己是谁。",
|
||||
"winCondition": "不暴露自己的真实身份",
|
||||
"personality": "老实憨厚、内心敏感"
|
||||
},
|
||||
{
|
||||
"id": "jd_inspector",
|
||||
"name": "陈探长",
|
||||
"publicProfile": "翡翠城的警长,金大牙的老朋友。今晚以宾客身份出席。",
|
||||
"secretProfile": "你一直在调查金大牙的走私活动。今晚的拍卖会是你搜集证据的好机会。",
|
||||
"secret": "你其实已经拿到了金大牙的走私证据,只差人赃并获。失窃案打乱了你的计划。",
|
||||
"goal": "查出失窃案真相,同时搜集金大牙的罪证。",
|
||||
"winCondition": "将罪犯绳之以法",
|
||||
"personality": "正义凛然、老谋深算"
|
||||
}
|
||||
],
|
||||
"clues": [
|
||||
{
|
||||
"id": "jd_clue_1",
|
||||
"name": "完好的保险柜",
|
||||
"description": "保险柜没有被撬的痕迹,密码锁显示最后一次输入密码是在昨晚11点。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "jd_clue_2",
|
||||
"name": "监控录像",
|
||||
"description": "监控录像显示昨晚11点到12点之间有一段画面被覆盖了。覆盖的人对监控系统非常熟悉。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "jd_clue_3",
|
||||
"name": "酒里的泻药",
|
||||
"description": "金大牙的酒杯里检测出泻药成分。有人想让他出丑。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "jd_clue_4",
|
||||
"name": "指纹对比",
|
||||
"description": "保险柜上提取到多组指纹,其中一组不属于任何已知的赌场员工。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "jd_clue_5",
|
||||
"name": "空盒子",
|
||||
"description": "在金大牙办公室的暗格里发现了一个盒子,标签写着“翡翠之心”,但里面是空的。",
|
||||
"phase": 3
|
||||
},
|
||||
{
|
||||
"id": "jd_clue_6",
|
||||
"name": "匿名信",
|
||||
"description": "一封没有署名的信:“翡翠之心早就不在保险柜里了。查查金大牙最近的银行流水。”",
|
||||
"phase": 3
|
||||
}
|
||||
],
|
||||
"truth": "真正的窃贼是金大牙自己。他早就把翡翠之心卖掉了,安排了一场假拍卖来骗保。但沙漠旅人的确试图行窃,发现保险柜空了——这就是为什么会有陌生指纹。盲眼算师目睹了沙漠旅人接近办公室,但以为是金大牙的人。整个事件是一场连环套:金大牙骗保、旅人偷窃未遂、算师商业间谍,三件事撞在了一起。"
|
||||
},
|
||||
{
|
||||
"id": "script_rain",
|
||||
"title": "阴雨公寓",
|
||||
"type": "恐怖",
|
||||
"difficulty": 3,
|
||||
"playerCount": {
|
||||
"min": 1,
|
||||
"max": 5
|
||||
},
|
||||
"duration": "60-90min",
|
||||
"background": "城中村有一栋老旧的公寓楼,每逢雨天,顶楼就会传来诡异的敲击声。三个月前,一个住户在雨夜失踪了。今晚又是暴雨,新搬来的住户们发现:这栋楼里的住户,似乎比他们以为的要多。",
|
||||
"phases": [
|
||||
{
|
||||
"name": "第一幕:雨夜搬入",
|
||||
"description": "暴雨如注,你搬进了这栋老旧的公寓。楼道里的灯忽明忽暗,电梯似乎永远停在顶楼。前台的老太太用奇怪的眼神看着你。",
|
||||
"publicInfo": "公寓共7层,你住在3楼。据前台说,整栋楼只有5户有人住。",
|
||||
"clues": [
|
||||
"ra_clue_1",
|
||||
"ra_clue_2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第二幕:邻居们",
|
||||
"description": "你开始认识楼里的其他住户。每个人似乎都有秘密。深夜,顶楼再次传来了敲击声。",
|
||||
"publicInfo": "敲击声有规律:三长两短,像是某种信号。",
|
||||
"clues": [
|
||||
"ra_clue_3",
|
||||
"ra_clue_4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "第三幕:消失的住户",
|
||||
"description": "你发现失踪的住户并没有搬走——他的房间里一切如常,只是人不见了。他的手机最后一条信息是“我在顶楼”。",
|
||||
"publicInfo": "顶楼的门锁着,需要钥匙才能上去。前台说顶楼已经十年没人住了。",
|
||||
"clues": [
|
||||
"ra_clue_5",
|
||||
"ra_clue_6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "最终幕:顶楼的秘密",
|
||||
"description": "你终于拿到了顶楼的钥匙。敲击声就在门后。当你打开门的瞬间,你看到了……",
|
||||
"publicInfo": "真相就在这扇门后面。",
|
||||
"clues": [
|
||||
"ra_clue_7"
|
||||
]
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"id": "ra_reporter",
|
||||
"name": "记者·方晴",
|
||||
"publicProfile": "调查记者,为了调查三个月前的失踪案搬进公寓。",
|
||||
"secretProfile": "你怀疑这栋公寓和近三年来的五起失踪案有关。你伪装成普通住户来调查。",
|
||||
"secret": "你查到了失踪者的共同点:他们都曾在失踪前提到过“顶楼的敲击声”。",
|
||||
"goal": "查明失踪案的真相,写出独家报道。",
|
||||
"winCondition": "揭开全部秘密",
|
||||
"personality": "勇敢执着、敏锐"
|
||||
},
|
||||
{
|
||||
"id": "ra_teacher",
|
||||
"name": "张老师",
|
||||
"publicProfile": "退休教师,在公寓住了二十年。和蔼可亲。",
|
||||
"secretProfile": "你知道顶楼的秘密——十年前,顶楼住着一对夫妇,妻子在雨夜被杀,丈夫失踪了。从那以后,每逢雨夜就有敲击声。",
|
||||
"secret": "你其实知道顶楼的钥匙在哪。但你不敢说,因为你怕那个“丈夫”还在楼里。",
|
||||
"goal": "保护自己和邻居的安全。",
|
||||
"winCondition": "真相不危害到活着的人",
|
||||
"personality": "慈祥温和、欲言又止"
|
||||
},
|
||||
{
|
||||
"id": "ra_doctor",
|
||||
"name": "林医生",
|
||||
"publicProfile": "年轻的心理医生,刚搬来不久。",
|
||||
"secretProfile": "你根本不是心理医生。你是失踪者的弟弟,来寻找哥哥的下落。",
|
||||
"secret": "你哥哥失踪前给你打过电话,说“这栋楼里有一个不存在的人”。你以为是他的精神出了问题。",
|
||||
"goal": "找到哥哥,无论死活。",
|
||||
"winCondition": "查明哥哥的下落",
|
||||
"personality": "冷静理智、内心焦虑"
|
||||
},
|
||||
{
|
||||
"id": "ra_landlord",
|
||||
"name": "包租婆",
|
||||
"publicProfile": "公寓的管理员兼前台,看似刻薄但热心肠。",
|
||||
"secretProfile": "你当年目睹了顶楼的谋杀案。你一直保持沉默因为凶手威胁要伤害你的家人。",
|
||||
"secret": "是你把顶楼锁起来的。每月的15号你会在顶楼门口放一碗米饭——那是你赎罪的方式。",
|
||||
"goal": "守住这个秘密,保护楼里的住户。",
|
||||
"winCondition": "秘密不被发现",
|
||||
"personality": "刀子嘴豆腐心、担惊受怕"
|
||||
},
|
||||
{
|
||||
"id": "ra_stranger",
|
||||
"name": "神秘男子",
|
||||
"publicProfile": "一个沉默寡言的年轻人,住在404室。几乎不出门。",
|
||||
"secretProfile": "你就是顶楼那个“死了”的丈夫。你当年失手杀了妻子,然后伪造了自己的死亡。这些年你一直躲在404室的密室里。",
|
||||
"secret": "敲击声是你发出的信号——你在提醒自己“她还在这里”。你已经开始产生幻觉,觉得妻子的鬼魂在跟着你。",
|
||||
"goal": "不暴露身份,继续躲藏。",
|
||||
"winCondition": "不被发现真实身份",
|
||||
"personality": "阴郁沉默、精神恍惚"
|
||||
}
|
||||
],
|
||||
"clues": [
|
||||
{
|
||||
"id": "ra_clue_1",
|
||||
"name": "住户登记表",
|
||||
"description": "前台登记表显示,这栋楼登记住户有6人,但前台说只有5户有人住。多出来的一个是谁?",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "ra_clue_2",
|
||||
"name": "电梯的异常",
|
||||
"description": "电梯的楼层按钮中,“4”和“顶楼”的按钮是新的,其他按钮都已褪色。",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "ra_clue_3",
|
||||
"name": "墙上的划痕",
|
||||
"description": "404室的墙角有指甲划出的痕迹,看起来像是有人被拖行过。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "ra_clue_4",
|
||||
"name": "顶楼的灯光",
|
||||
"description": "每晚12点,从对面楼可以看到顶楼的窗户亮灯一分钟,然后熄灭。",
|
||||
"phase": 2
|
||||
},
|
||||
{
|
||||
"id": "ra_clue_5",
|
||||
"name": "失踪者的房间",
|
||||
"description": "失踪者的房间里,日历停在了三个月前的那一天。桌上有一张照片,照片中有6个人,但你只认识5个。",
|
||||
"phase": 3
|
||||
},
|
||||
{
|
||||
"id": "ra_clue_6",
|
||||
"name": "密室的入口",
|
||||
"description": "404室的衣柜后面发现了一道暗门。暗门通向一个狭小的隔间,里面有生活痕迹。",
|
||||
"phase": 3
|
||||
},
|
||||
{
|
||||
"id": "ra_clue_7",
|
||||
"name": "顶楼的真相",
|
||||
"description": "顶楼的房间里,墙上贴满了同一个女人的照片——是404室那个“神秘男子”的妻子。桌上有一本日记,最后一页写着“她回来了”。",
|
||||
"phase": 4
|
||||
}
|
||||
],
|
||||
"truth": "真正恐怖的是:那个“神秘男子”(404室住户)确实是顶楼命案的凶手。他在十年前雨夜杀死了妻子,伪造了自己的死亡,躲在404室的密室里。但三个月前失踪的住户不是他杀的——那个失踪者在调查顶楼秘密时,被包租婆误认为是入室窃贼而失手打死。包租婆把尸体藏在了顶楼的水箱里。所以这栋楼里有两个秘密:一个是十年前的谋杀案,一个是三个月前的误杀案。而敲击声——是水箱里尸体随水流撞击内壁的声音。"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
package scripts
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"log"
|
||||
)
|
||||
|
||||
//go:embed sample_data.json
|
||||
var dataJSON []byte
|
||||
|
||||
var SCRIPTS []Script
|
||||
|
||||
func init() {
|
||||
if err := json.Unmarshal(dataJSON, &SCRIPTS); err != nil {
|
||||
log.Fatalf("failed to parse sample_data.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type Script struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
PlayerCount PlayerCount `json:"playerCount"`
|
||||
Duration string `json:"duration"`
|
||||
Background string `json:"background"`
|
||||
Phases []Phase `json:"phases"`
|
||||
Roles []Role `json:"roles"`
|
||||
Clues []Clue `json:"clues"`
|
||||
Truth string `json:"truth"`
|
||||
}
|
||||
|
||||
type PlayerCount struct {
|
||||
Min int `json:"min"`
|
||||
Max int `json:"max"`
|
||||
}
|
||||
|
||||
type Phase struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
PublicInfo string `json:"publicInfo"`
|
||||
Clues []string `json:"clues"`
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PublicProfile string `json:"publicProfile"`
|
||||
SecretProfile string `json:"secretProfile"`
|
||||
Secret string `json:"secret"`
|
||||
Goal string `json:"goal"`
|
||||
WinCondition string `json:"winCondition"`
|
||||
Personality string `json:"personality"`
|
||||
}
|
||||
|
||||
type Clue struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Phase int `json:"phase"`
|
||||
}
|
||||
|
||||
func GetByID(id string) *Script {
|
||||
for i := range SCRIPTS {
|
||||
if SCRIPTS[i].ID == id {
|
||||
return &SCRIPTS[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetByTitle(title string) *Script {
|
||||
for i := range SCRIPTS {
|
||||
if SCRIPTS[i].Title == title {
|
||||
return &SCRIPTS[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package util
|
||||
|
||||
import "time"
|
||||
|
||||
const PyTimeLayout = "2006-01-02T15:04:05.999999"
|
||||
|
||||
func NowStr() string {
|
||||
return time.Now().UTC().Format(PyTimeLayout)
|
||||
}
|
||||
|
||||
func TimeStr(t time.Time) string {
|
||||
return t.UTC().Format(PyTimeLayout)
|
||||
}
|
||||
|
||||
func ParseTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
for _, layout := range []string{PyTimeLayout, "2006-01-02T15:04:05", "2006-01-02 15:04:05", time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
Reference in New Issue
Block a user