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