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
+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
}
}
}
}