diff --git a/.gitignore b/.gitignore index d7596c5..1c8777a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ cmake-build-debug server.exe +embalming_girl_server.exe +embalming_girl_server_linux + diff --git a/admin/admin.go b/admin/admin.go index 77fd4d8..ed3a683 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -2,35 +2,76 @@ package admin import ( "Embalming_Girl_Server/db" + "crypto/rand" + "encoding/hex" "encoding/json" - "fmt" "net/http" ) -const AdminPassword = "admin123" +const AdminPassword = "Qwer123456789!" + +var adminSession string + +func genSession() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +func isAuth(r *http.Request) bool { + c, err := r.Cookie("admin_session") + return err == nil && c.Value != "" && c.Value == adminSession +} + +func authWrap(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !isAuth(r) { + w.WriteHeader(401) + json.NewEncoder(w).Encode(map[string]interface{}{"error": "unauthorized"}) + return + } + h(w, r) + } +} func Register(mux *http.ServeMux, getStats func() map[string]interface{}) { mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, adminHTML) + w.Write([]byte(adminHTML)) }) mux.HandleFunc("/api/admin/login", func(w http.ResponseWriter, r *http.Request) { var req struct { Password string `json:"password"` } json.NewDecoder(r.Body).Decode(&req) + w.Header().Set("Content-Type", "application/json") if req.Password != AdminPassword { json.NewEncoder(w).Encode(map[string]interface{}{"ok": false}) return } + adminSession = genSession() + http.SetCookie(w, &http.Cookie{ + Name: "admin_session", Value: adminSession, + Path: "/", MaxAge: 86400, HttpOnly: true, + }) json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) }) - mux.HandleFunc("/api/admin/stats", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/admin/logout", func(w http.ResponseWriter, r *http.Request) { + adminSession = "" + http.SetCookie(w, &http.Cookie{Name: "admin_session", Value: "", Path: "/", MaxAge: -1}) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) + }) + mux.HandleFunc("/api/admin/check_session", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": isAuth(r)}) + }) + mux.HandleFunc("/api/admin/stats", authWrap(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(getStats()) - }) - mux.HandleFunc("/api/admin/users", func(w http.ResponseWriter, r *http.Request) { - rows, err := db.DB.Query("SELECT id, username, nickname, bio, avatar, created_at, last_login FROM users ORDER BY id DESC") + })) + mux.HandleFunc("/api/admin/users", authWrap(func(w http.ResponseWriter, r *http.Request) { + rows, err := db.DB.Query("SELECT id,username,nickname,bio,avatar,created_at,last_login FROM users ORDER BY id DESC") if err != nil { json.NewEncoder(w).Encode([]int{}) return @@ -49,70 +90,129 @@ func Register(mux *http.ServeMux, getStats func() map[string]interface{}) { } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(users) - }) - mux.HandleFunc("/api/admin/delete_user", func(w http.ResponseWriter, r *http.Request) { + })) + mux.HandleFunc("/api/admin/delete_user", authWrap(func(w http.ResponseWriter, r *http.Request) { var req struct { ID int64 `json:"id"` } json.NewDecoder(r.Body).Decode(&req) db.DB.Exec("DELETE FROM sessions WHERE user_id=?", req.ID) db.DB.Exec("DELETE FROM users WHERE id=?", req.ID) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) - }) + })) } -const adminHTML = `管理面板 +const adminHTML = `Embalming Girl - Admin -

管理面板

-
-

Embalming Girl 管理面板

-

服务器状态

-

注册用户

ID用户名昵称头像简介注册时间操作
-
+
+

Embalming Girl

Server Admin Panel

+ + +
+
+ +
+
+

仪表盘

服务器运行状态总览

+
+

在线用户

+
连接ID用户名昵称版本状态
+
+

用户管理

管理已注册的用户账号

+

用户列表

+
ID用户名昵称头像注册时间操作
+
+

更新管理

管理客户端 OTA 更新

+

当前已发布版本

+

发布新版本

+ + + +
+ +
+
` diff --git a/admin/update.go b/admin/update.go new file mode 100644 index 0000000..5514e58 --- /dev/null +++ b/admin/update.go @@ -0,0 +1,160 @@ +package admin + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "time" +) + +type UpdateInfo struct { + Version string `json:"version"` + Platform string `json:"platform"` + FileName string `json:"filename"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + ReleaseNotes string `json:"release_notes"` + Mandatory bool `json:"mandatory"` + PublishedAt string `json:"published_at"` +} + +var updatesDir string + +func initUpdatesDir() { + exe, _ := os.Executable() + updatesDir = filepath.Join(filepath.Dir(exe), "data", "updates") + os.MkdirAll(updatesDir, 0755) +} + +func RegisterUpdateRoutes(mux *http.ServeMux) { + initUpdatesDir() + + mux.HandleFunc("/api/update/check", handleUpdateCheck) + mux.Handle("/updates/", http.StripPrefix("/updates/", + http.FileServer(http.Dir(updatesDir)))) + mux.HandleFunc("/api/admin/publish_update", authWrap(handlePublishUpdate)) + mux.HandleFunc("/api/admin/update_info", handleGetUpdateInfo) + mux.HandleFunc("/api/admin/delete_update", authWrap(handleDeleteUpdate)) +} + +func getUpdateInfoPath() string { + return filepath.Join(updatesDir, "latest.json") +} + +func loadUpdateInfo() *UpdateInfo { + data, err := os.ReadFile(getUpdateInfoPath()) + if err != nil { + return nil + } + var info UpdateInfo + if json.Unmarshal(data, &info) != nil { + return nil + } + return &info +} + +func saveUpdateInfo(info *UpdateInfo) error { + data, _ := json.MarshalIndent(info, "", " ") + return os.WriteFile(getUpdateInfoPath(), data, 0644) +} + +func handleUpdateCheck(w http.ResponseWriter, r *http.Request) { + clientVersion := r.URL.Query().Get("version") + info := loadUpdateInfo() + + w.Header().Set("Content-Type", "application/json") + if info == nil || info.Version == clientVersion { + json.NewEncoder(w).Encode(map[string]interface{}{ + "update_available": false, + "current_version": clientVersion, + }) + return + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "update_available": true, + "latest_version": info.Version, + "download_url": "/updates/" + info.FileName, + "sha256": info.SHA256, + "size": info.Size, + "release_notes": info.ReleaseNotes, + "mandatory": info.Mandatory, + }) +} + +func handleGetUpdateInfo(w http.ResponseWriter, r *http.Request) { + info := loadUpdateInfo() + w.Header().Set("Content-Type", "application/json") + if info == nil { + json.NewEncoder(w).Encode(map[string]interface{}{"published": false}) + return + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "published": true, "info": info, + }) +} + +func handleDeleteUpdate(w http.ResponseWriter, r *http.Request) { + info := loadUpdateInfo() + if info != nil { + os.Remove(filepath.Join(updatesDir, info.FileName)) + os.Remove(getUpdateInfoPath()) + } + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) +} + +func handlePublishUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", 405) + return + } + r.ParseMultipartForm(200 << 20) + + version := r.FormValue("version") + notes := r.FormValue("release_notes") + mandatory := r.FormValue("mandatory") == "true" + + file, header, err := r.FormFile("file") + if err != nil { + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "文件上传失败"}) + return + } + defer file.Close() + + dstPath := filepath.Join(updatesDir, header.Filename) + dst, err := os.Create(dstPath) + if err != nil { + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "无法创建文件"}) + return + } + + hasher := sha256.New() + size, _ := io.Copy(io.MultiWriter(dst, hasher), file) + dst.Close() + hash := hex.EncodeToString(hasher.Sum(nil)) + + info := &UpdateInfo{ + Version: version, Platform: "win64", FileName: header.Filename, + SHA256: hash, Size: size, ReleaseNotes: notes, Mandatory: mandatory, + PublishedAt: time.Now().Format("2006-01-02 15:04:05"), + } + saveUpdateInfo(info) + log.Printf("Update published: v%s (%s, %d bytes)", version, header.Filename, size) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "info": info}) +} + +func FormatSize(b int64) string { + if b < 1024 { + return fmt.Sprintf("%d B", b) + } else if b < 1024*1024 { + return fmt.Sprintf("%.1f KB", float64(b)/1024) + } + return fmt.Sprintf("%.1f MB", float64(b)/1024/1024) +} diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..86bbf48 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,56 @@ +#!/usr/bin/env pwsh +# Embalming Girl Server - Build Script +# Usage: +# .\build.ps1 # Build for Windows +# .\build.ps1 linux # Cross-compile for Linux +# .\build.ps1 all # Build for both platforms + +param([string]$Target = "windows") + +$ErrorActionPreference = "Stop" +$ProjectName = "embalming_girl_server" + +Write-Host "=== Embalming Girl Server Build ===" -ForegroundColor Cyan + +function Build-Windows { + Write-Host "[Building] Windows amd64..." -ForegroundColor Yellow + $env:GOOS = "windows" + $env:GOARCH = "amd64" + go build -o "$ProjectName.exe" . + if ($LASTEXITCODE -eq 0) { + Write-Host "[OK] $ProjectName.exe" -ForegroundColor Green + } else { + Write-Host "[FAILED] Windows build" -ForegroundColor Red + exit 1 + } +} + +function Build-Linux { + Write-Host "[Building] Linux amd64..." -ForegroundColor Yellow + $env:GOOS = "linux" + $env:GOARCH = "amd64" + $env:CGO_ENABLED = "0" + go build -o "${ProjectName}_linux" . + if ($LASTEXITCODE -eq 0) { + Write-Host "[OK] ${ProjectName}_linux" -ForegroundColor Green + } else { + Write-Host "[FAILED] Linux build" -ForegroundColor Red + exit 1 + } +} + +switch ($Target.ToLower()) { + "windows" { Build-Windows } + "linux" { Build-Linux } + "all" { Build-Windows; Build-Linux } + default { + Write-Host "Usage: .\build.ps1 [windows|linux|all]" -ForegroundColor Yellow + } +} + +# Clean up environment +Remove-Item Env:GOOS -ErrorAction SilentlyContinue +Remove-Item Env:GOARCH -ErrorAction SilentlyContinue +Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue + +Write-Host "=== Build Complete ===" -ForegroundColor Cyan diff --git a/game/card.go b/game/card.go index a47f581..8d8058d 100644 --- a/game/card.go +++ b/game/card.go @@ -16,10 +16,12 @@ type CardDef struct { } type Card struct { - UID string - TypeID string - Name string - MP int + UID string + TypeID string + Name string + MP int + PlacedBy string + PlacedByNick string } var AllCardDefs = []CardDef{ diff --git a/game/engine.go b/game/engine.go index 0de4c17..f8b5495 100644 --- a/game/engine.go +++ b/game/engine.go @@ -11,6 +11,7 @@ type PlayerState struct { ID string Nickname string IsBot bool + BotControlled bool Hand []*Card SkillZone []*Card ChallengeZone []*Card @@ -31,6 +32,8 @@ type Engine struct { Effect *EffectState ChaosMode bool ActionLogs []string + TurnID int + RoundNum int rng *rand.Rand } @@ -67,6 +70,8 @@ func (e *Engine) Deal() { e.CurrentTurn = e.findStartingPlayer() e.Phase = "playing" e.TurnPhase = "select_card" + e.RoundNum = 1 + e.TurnID = 0 e.startTurn() } @@ -82,6 +87,11 @@ func (e *Engine) findStartingPlayer() int { } func (e *Engine) startTurn() { + e.TurnID++ + cp := e.CurrentPlayer() + if cp != nil && !cp.IsBot && !cp.BotControlled { + e.log(fmt.Sprintf("轮到 %s 的回合 (第%d轮)", cp.Nickname, e.RoundNum)) + } if e.CheckInfectedEffect() { e.TurnPhase = "effect_resolving" } else { @@ -190,6 +200,8 @@ func (e *Engine) doChallenge(p *PlayerState, targetID string) error { } card := e.SelectedCard e.removeFromHand(p, card.UID) + card.PlacedBy = p.ID + card.PlacedByNick = p.Nickname target.ChallengeZone = append(target.ChallengeZone, card) e.log(p.Nickname + " 质疑了 " + target.Nickname) e.SelectedCard = nil @@ -214,10 +226,14 @@ func (e *Engine) finishTurn(p *PlayerState) { func (e *Engine) advanceTurn() { n := len(e.Players) + old := e.CurrentTurn next := (e.CurrentTurn + 1) % n for i := 0; i < n; i++ { if !e.Players[next].IsExited { e.CurrentTurn = next + if next <= old { + e.RoundNum++ + } return } next = (next + 1) % n @@ -256,6 +272,29 @@ func (e *Engine) IsHandTargetable(p *PlayerState) bool { return !p.IsExited } +func (e *Engine) UpdatePlayerID(oldID, newID string) { + for _, p := range e.Players { + if p.ID == oldID { + p.ID = newID + return + } + } +} + +func (e *Engine) SetBotControlled(playerID string, v bool) { + for _, p := range e.Players { + if p.ID == playerID { + p.BotControlled = v + return + } + } +} + +func (e *Engine) IsPlayerBotLike(playerID string) bool { + p := e.GetPlayer(playerID) + return p != nil && (p.IsBot || p.BotControlled) +} + func (e *Engine) EffectNeedsResponseFrom(playerID string) bool { if !e.IsEffectResolving() { return false @@ -271,7 +310,7 @@ func (e *Engine) EffectNeedsResponseFrom(playerID string) bool { func (e *Engine) BotAutoPlay(playerID string) (string, string) { p := e.GetPlayer(playerID) - if p == nil || !p.IsBot || p.IsExited { + if p == nil || (!p.IsBot && !p.BotControlled) || p.IsExited { return "", "" } var playable []*Card diff --git a/game/snapshot.go b/game/snapshot.go index 3cc4273..1991d41 100644 --- a/game/snapshot.go +++ b/game/snapshot.go @@ -13,6 +13,8 @@ func (e *Engine) Snapshot(forPlayerID string) map[string]interface{} { "current_turn": currentTurnID, "your_player_id": forPlayerID, "harmony_target": e.HarmonyTarget, + "round_num": e.RoundNum, + "turn_id": e.TurnID, } var players []map[string]interface{} @@ -47,10 +49,20 @@ func (e *Engine) Snapshot(forPlayerID string) map[string]interface{} { } snap["hand_cards"] = hand + var myChallenge []map[string]interface{} + if me != nil { + for _, c := range me.ChallengeZone { + myChallenge = append(myChallenge, map[string]interface{}{ + "uid": c.UID, "placed_by": c.PlacedByNick, + }) + } + } + snap["my_challenge_zone"] = myChallenge + var harmony []map[string]interface{} - for _, c := range e.HarmonyZone { + for i, c := range e.HarmonyZone { harmony = append(harmony, map[string]interface{}{ - "uid": c.UID, "face_up": false, + "uid": c.UID, "face_up": false, "order": i + 1, }) } snap["harmony_zone"] = harmony diff --git a/handler/ws.go b/handler/ws.go index 3ee9d59..227a608 100644 --- a/handler/ws.go +++ b/handler/ws.go @@ -5,6 +5,7 @@ import ( "Embalming_Girl_Server/game" "Embalming_Girl_Server/room" "encoding/json" + "fmt" "log" "math/rand" "net/http" @@ -25,6 +26,7 @@ type Client struct { Username string Nickname string Token string + Version string Conn *websocket.Conn Send chan []byte mu sync.Mutex @@ -51,6 +53,18 @@ func (h *Hub) OnlineCount() int { return len(h.clients) } +func (h *Hub) OnlineClientsList() []map[string]interface{} { + h.mu.RLock() + defer h.mu.RUnlock() + var list []map[string]interface{} + for _, c := range h.clients { + list = append(list, map[string]interface{}{ + "id": c.ID, "username": c.Username, "nickname": c.Nickname, "room_id": c.RoomID, "version": c.Version, + }) + } + return list +} + func (h *Hub) InitTestRooms() { for _, n := range []int{3, 4, 5, 6} { rid := "TEST" + string(rune('0'+n)) @@ -109,15 +123,59 @@ func (h *Hub) removeClient(c *Client) { h.mu.Lock() delete(h.clients, c.ID) h.mu.Unlock() + if c.RoomID != "" { - h.RoomMgr.LeaveRoom(c.RoomID, c.ID) - rm := h.RoomMgr.GetRoom(c.RoomID) - if rm != nil { - h.broadcastRoomState(rm) + h.mu.RLock() + _, hasGame := h.engines[c.RoomID] + h.mu.RUnlock() + + if hasGame && c.Username != "" { + h.RoomMgr.DisconnectPlayer(c.RoomID, c.ID) + h.broadcastToRoom(c.RoomID, "player_disconnected", map[string]interface{}{ + "nickname": c.Nickname, "message": c.Nickname + " 断线了", + }) + log.Printf("Player %s disconnected, slot preserved, 60s takeover timer started", c.Username) + + go func(roomID, playerID, nick string) { + time.Sleep(60 * time.Second) + if !h.RoomMgr.IsPlayerDisconnected(roomID, playerID) { + return + } + h.RoomMgr.SetPlayerBotControlled(roomID, playerID, true) + + h.mu.RLock() + eng := h.engines[roomID] + h.mu.RUnlock() + if eng != nil { + eng.SetBotControlled(playerID, true) + } + + h.broadcastToRoom(roomID, "player_bot_takeover", map[string]interface{}{ + "nickname": nick, "message": nick + " 已被人机接管", + }) + log.Printf("Player %s taken over by bot in room %s", nick, roomID) + + if eng != nil && eng.Phase == "playing" { + cp := eng.CurrentPlayer() + if cp != nil && cp.ID == playerID { + h.processBotTurns(roomID) + } + } + }(c.RoomID, c.ID, c.Nickname) + } else { + h.RoomMgr.LeaveRoom(c.RoomID, c.ID) + rm := h.RoomMgr.GetRoom(c.RoomID) + if rm != nil { + h.broadcastRoomState(rm) + } else { + h.mu.Lock() + delete(h.engines, c.RoomID) + h.mu.Unlock() + } } } close(c.Send) - log.Printf("Disconnected: %s", c.ID) + log.Printf("Disconnected: %s (%s)", c.ID, c.Username) } func (h *Hub) handleMessage(c *Client, msg map[string]interface{}) { @@ -136,6 +194,8 @@ func (h *Hub) handleMessage(c *Client, msg map[string]interface{}) { h.handleCreateRoom(c, p) case "join_room": h.handleJoinRoom(c, p) + case "rejoin_room": + h.handleRejoin(c, p) case "leave_room": h.handleLeaveRoom(c) case "set_ready": @@ -154,6 +214,8 @@ func (h *Hub) handleMessage(c *Client, msg map[string]interface{}) { h.handleChat(c, p) case "set_chaos": h.handleSetChaos(c, p) + case "create_bot_room": + h.handleCreateBotRoom(c, p) case "heartbeat": h.sendTo(c, "heartbeat_ack", nil) } @@ -203,12 +265,20 @@ func (h *Hub) handleLogin(c *Client, p map[string]interface{}) { c.Username = user.Username c.Nickname = user.Nickname c.Token = token + if v, ok := p["version"].(string); ok { + c.Version = v + } log.Printf("User logged in: %s (%s)", user.Username, c.ID) - h.sendTo(c, "login_result", map[string]interface{}{ + result := map[string]interface{}{ "success": true, "token": token, "username": user.Username, "nickname": user.Nickname, "avatar": user.Avatar, "bio": user.Bio, - }) + } + if roomID, playerID, found := h.RoomMgr.FindRoomByUsername(user.Username); found { + result["active_room"] = roomID + result["active_player_id"] = playerID + } + h.sendTo(c, "login_result", result) } func (h *Hub) handleUpdateProfile(c *Client, p map[string]interface{}) { @@ -239,6 +309,41 @@ func (h *Hub) handleGetProfile(c *Client, p map[string]interface{}) { }) } +func (h *Hub) handleRejoin(c *Client, p map[string]interface{}) { + roomID, _ := p["room_id"].(string) + oldPID, _ := p["player_id"].(string) + if roomID == "" || oldPID == "" { + h.sendTo(c, "error", map[string]interface{}{"message": "缺少参数"}) + return + } + if !h.RoomMgr.ReconnectPlayer(roomID, oldPID, c.ID, c.Send) { + h.sendTo(c, "error", map[string]interface{}{"message": "无法重连到对局"}) + return + } + c.RoomID = roomID + log.Printf("Player %s rejoined room %s", c.Username, roomID) + + h.mu.RLock() + eng := h.engines[roomID] + h.mu.RUnlock() + + if eng != nil { + eng.UpdatePlayerID(oldPID, c.ID) + eng.SetBotControlled(c.ID, false) + h.sendToChannel(c.Send, "game_start", map[string]interface{}{ + "your_player_id": c.ID, "harmony_target": eng.HarmonyTarget, + "player_count": len(eng.Players), "rejoin": true, + }) + snap := eng.Snapshot(c.ID) + h.sendToChannel(c.Send, "state_snapshot", snap) + } + rm := h.RoomMgr.GetRoom(roomID) + if rm != nil { + h.broadcastRoomState(rm) + } + h.sendTo(c, "rejoin_result", map[string]interface{}{"success": true, "room_id": roomID}) +} + func (h *Hub) handleCreateRoom(c *Client, p map[string]interface{}) { nick, _ := p["nickname"].(string) mp := 4 @@ -251,6 +356,7 @@ func (h *Hub) handleCreateRoom(c *Client, p map[string]interface{}) { rm := h.RoomMgr.CreateRoom(c.ID, nick, mp) c.RoomID = rm.ID rm.Players[c.ID].Send = c.Send + rm.Players[c.ID].Username = c.Username h.sendTo(c, "room_created", map[string]interface{}{"room_id": rm.ID}) h.broadcastRoomState(rm) } @@ -268,6 +374,8 @@ func (h *Hub) handleJoinRoom(c *Client, p map[string]interface{}) { } c.RoomID = rm.ID rm.Players[c.ID].Send = c.Send + rm.Players[c.ID].Username = c.Username + h.sendTo(c, "room_joined", map[string]interface{}{"room_id": rm.ID}) h.broadcastRoomState(rm) } @@ -298,6 +406,32 @@ func (h *Hub) handleSetChaos(c *Client, p map[string]interface{}) { h.broadcastRoomState(rm) } +func (h *Hub) handleCreateBotRoom(c *Client, p map[string]interface{}) { + count := 4 + if v, ok := p["player_count"].(float64); ok { + count = int(v) + } + if count < 3 || count > 6 { + h.sendTo(c, "error", map[string]interface{}{"message": "人数必须在3-6之间"}) + return + } + rid := fmt.Sprintf("BOT_%s", generateClientID()[:6]) + h.RoomMgr.CreateTestRoom(rid, count) + + rm, ok := h.RoomMgr.JoinRoom(rid, c.ID, c.Nickname) + if !ok { + h.sendTo(c, "error", map[string]interface{}{"message": "创建失败"}) + return + } + c.RoomID = rm.ID + rm.Players[c.ID].Send = c.Send + rm.Players[c.ID].Username = c.Username + + h.sendTo(c, "room_joined", map[string]interface{}{"room_id": rm.ID}) + h.broadcastRoomState(rm) + log.Printf("Bot room %s created by %s (%d players)", rid, c.Username, count) +} + func (h *Hub) handleChat(c *Client, p map[string]interface{}) { if c.RoomID == "" { return @@ -528,7 +662,7 @@ func (h *Hub) processBotTurns(roomID string) { for eng.Phase == "playing" && !eng.IsEffectResolving() { cp := eng.CurrentPlayer() - if cp == nil || !cp.IsBot { + if cp == nil || (!cp.IsBot && !cp.BotControlled) { break } @@ -556,6 +690,7 @@ func (h *Hub) processBotTurns(roomID string) { return } } + h.startTurnTimer(roomID) } func (h *Hub) handleAllExited(roomID string) { @@ -624,7 +759,65 @@ func (h *Hub) handleAllExited(roomID string) { h.broadcastRoomState(rm) } -// ---- Audio relay ---- +func (h *Hub) startTurnTimer(roomID string) { + h.mu.RLock() + eng := h.engines[roomID] + h.mu.RUnlock() + if eng == nil || eng.Phase != "playing" { + return + } + cp := eng.CurrentPlayer() + if cp == nil || cp.IsBot || cp.BotControlled { + return + } + + turnID := eng.TurnID + go func() { + time.Sleep(30 * time.Second) + h.mu.RLock() + eng2 := h.engines[roomID] + h.mu.RUnlock() + if eng2 == nil || eng2.Phase != "playing" || eng2.TurnID != turnID { + return + } + + cp2 := eng2.CurrentPlayer() + if cp2 == nil { + return + } + + eng2.ActionLogs = append(eng2.ActionLogs, cp2.Nickname+" 超时,自动出牌") + action, target := eng2.BotAutoPlay(cp2.ID) + if action == "" { + return + } + + eng2.ConfirmAction(cp2.ID, action, target) + h.broadcastSnapshots(roomID) + h.flushGameLogs(roomID) + if eng2.Phase == "all_exited" { + h.handleAllExited(roomID) + return + } + h.processBotTurns(roomID) + }() +} + +// ---- Broadcast helpers ---- + +func (h *Hub) broadcastToRoom(roomID, msgType string, data map[string]interface{}) { + rm := h.RoomMgr.GetRoom(roomID) + if rm == nil { + return + } + for _, pid := range rm.PlayerOrder { + p := rm.Players[pid] + if p.IsBot || p.Send == nil { + continue + } + h.sendToChannel(p.Send, msgType, data) + } +} func (h *Hub) flushGameLogs(roomID string) { h.mu.RLock() diff --git a/main.go b/main.go index a6eb2d4..603d0be 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "Embalming_Girl_Server/admin" "Embalming_Girl_Server/db" "Embalming_Girl_Server/handler" + "encoding/json" "log" "net/http" ) @@ -33,6 +34,11 @@ func main() { "online_clients": hub.OnlineCount(), } }) + admin.RegisterUpdateRoutes(mux) + mux.HandleFunc("/api/admin/online", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(hub.OnlineClientsList()) + }) addr := ":8080" log.Printf("Server started on %s", addr) diff --git a/room/manager.go b/room/manager.go index d00eb37..b8fd6f3 100644 --- a/room/manager.go +++ b/room/manager.go @@ -8,12 +8,15 @@ import ( ) type Player struct { - ID string - Nickname string - IsReady bool - IsHost bool - IsBot bool - Send chan []byte + ID string + Username string + Nickname string + IsReady bool + IsHost bool + IsBot bool + BotControlled bool + Disconnected bool + Send chan []byte } type Room struct { @@ -298,6 +301,85 @@ func (r *Room) SendToPlayer(playerID string, data []byte) { } } +func (m *Manager) FindRoomByUsername(username string) (string, string, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + for _, rm := range m.rooms { + rm.mu.RLock() + for _, p := range rm.Players { + if p.Username == username && !p.IsBot { + rm.mu.RUnlock() + return rm.ID, p.ID, true + } + } + rm.mu.RUnlock() + } + return "", "", false +} + +func (m *Manager) DisconnectPlayer(roomID, playerID string) { + rm := m.GetRoom(roomID) + if rm == nil { + return + } + rm.mu.Lock() + defer rm.mu.Unlock() + if p, ok := rm.Players[playerID]; ok { + p.Disconnected = true + p.Send = nil + } +} + +func (m *Manager) IsPlayerDisconnected(roomID, playerID string) bool { + rm := m.GetRoom(roomID) + if rm == nil { + return false + } + rm.mu.RLock() + defer rm.mu.RUnlock() + p, ok := rm.Players[playerID] + return ok && p.Disconnected +} + +func (m *Manager) SetPlayerBotControlled(roomID, playerID string, v bool) { + rm := m.GetRoom(roomID) + if rm == nil { + return + } + rm.mu.Lock() + defer rm.mu.Unlock() + if p, ok := rm.Players[playerID]; ok { + p.BotControlled = v + } +} + +func (m *Manager) ReconnectPlayer(roomID, oldPID, newPID string, send chan []byte) bool { + rm := m.GetRoom(roomID) + if rm == nil { + return false + } + rm.mu.Lock() + defer rm.mu.Unlock() + p, ok := rm.Players[oldPID] + if !ok { + return false + } + + p.ID = newPID + p.Disconnected = false + p.BotControlled = false + p.Send = send + delete(rm.Players, oldPID) + rm.Players[newPID] = p + for i, id := range rm.PlayerOrder { + if id == oldPID { + rm.PlayerOrder[i] = newPID + break + } + } + return true +} + func generateRoomID() string { src := rand.NewSource(time.Now().UnixNano()) r := rand.New(src) diff --git a/server.exe b/server.exe index a475a35..b89f1a3 100644 Binary files a/server.exe and b/server.exe differ