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 管理面板
-
-
-
+
+
Embalming Girl
Server Admin Panel
+
+
+
+
`
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