All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 2m30s
363 lines
11 KiB
Go
363 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io/ioutil"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
|
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
|
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/server"
|
|
)
|
|
|
|
// Globaler Zustand des Servers
|
|
var (
|
|
rooms = make(map[string]*server.Room)
|
|
playerSessions = make(map[string]*server.Room)
|
|
mu sync.RWMutex
|
|
globalWorld *game.World
|
|
serverID string // Eindeutige Server-ID für diesen Pod
|
|
roomRegistry *RoomRegistry // Redis-basierte Room-Registry
|
|
)
|
|
|
|
func main() {
|
|
log.Println("🚀 Escape From Teacher SERVER startet...")
|
|
|
|
// 0. Server-ID generieren (Hostname oder zufällig)
|
|
serverID = os.Getenv("HOSTNAME")
|
|
if serverID == "" {
|
|
serverID = os.Getenv("POD_NAME")
|
|
}
|
|
if serverID == "" {
|
|
serverID = "server-" + time.Now().Format("150405")
|
|
}
|
|
log.Printf("🆔 Server-ID: %s", serverID)
|
|
|
|
// 1. WELT & ASSETS LADEN
|
|
globalWorld = game.NewWorld()
|
|
loadServerAssets(globalWorld)
|
|
|
|
// 1b. Redis-Leaderboard initialisieren
|
|
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
|
|
if err := server.InitLeaderboard(redisAddr); err != nil {
|
|
log.Fatal("❌ Konnte nicht zu Redis verbinden: ", err)
|
|
}
|
|
log.Printf("✅ Verbunden mit Redis (Leaderboard): %s", redisAddr)
|
|
|
|
// 1c. Room-Registry initialisieren
|
|
var err error
|
|
roomRegistry, err = NewRoomRegistry(redisAddr, serverID)
|
|
if err != nil {
|
|
log.Fatal("❌ Konnte Room-Registry nicht initialisieren: ", err)
|
|
}
|
|
|
|
// 2. NATS VERBINDUNG mit Reconnect-Logik
|
|
natsURL := getEnv("NATS_URL", "nats://localhost:4222")
|
|
|
|
nc, err := nats.Connect(
|
|
natsURL,
|
|
nats.MaxReconnects(-1), // Unbegrenzte Reconnects
|
|
nats.ReconnectWait(2*time.Second),
|
|
nats.ReconnectBufSize(5*1024*1024), // 5MB Buffer
|
|
nats.DisconnectErrHandler(func(nc *nats.Conn, err error) {
|
|
if err != nil {
|
|
log.Printf("⚠️ NATS getrennt: %v", err)
|
|
}
|
|
}),
|
|
nats.ReconnectHandler(func(nc *nats.Conn) {
|
|
log.Printf("✅ NATS wiederverbunden: %s", nc.ConnectedUrl())
|
|
}),
|
|
nats.ClosedHandler(func(nc *nats.Conn) {
|
|
log.Println("❌ NATS Verbindung geschlossen")
|
|
}),
|
|
nats.Timeout(10*time.Second),
|
|
nats.PingInterval(20*time.Second),
|
|
nats.MaxPingsOutstanding(5),
|
|
)
|
|
|
|
if err != nil {
|
|
// Retry-Logik für initiales Connect
|
|
maxRetries := 10
|
|
retryDelay := 2 * time.Second
|
|
|
|
for i := 0; i < maxRetries; i++ {
|
|
log.Printf("⚠️ NATS noch nicht erreichbar (Versuch %d/%d): %v", i+1, maxRetries, err)
|
|
log.Printf("⏳ Warte %v vor erneutem Versuch...", retryDelay)
|
|
time.Sleep(retryDelay)
|
|
|
|
nc, err = nats.Connect(
|
|
natsURL,
|
|
nats.MaxReconnects(-1),
|
|
nats.ReconnectWait(2*time.Second),
|
|
nats.Timeout(10*time.Second),
|
|
)
|
|
|
|
if err == nil {
|
|
break
|
|
}
|
|
|
|
retryDelay = retryDelay * 2 // Exponential backoff
|
|
if retryDelay > 30*time.Second {
|
|
retryDelay = 30 * time.Second
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
log.Fatalf("❌ Konnte nicht zu NATS verbinden nach %d Versuchen: %v", maxRetries, err)
|
|
}
|
|
}
|
|
defer nc.Close()
|
|
|
|
ec, err := nats.NewEncodedConn(nc, nats.JSON_ENCODER)
|
|
if err != nil {
|
|
log.Fatal("❌ JSON Encoder Fehler: ", err)
|
|
}
|
|
log.Printf("✅ Verbunden mit NATS: %s", natsURL)
|
|
|
|
// 3. HANDLER: GAME JOIN (Queue Group - nur EIN Pod bekommt den JOIN)
|
|
sub, err := ec.QueueSubscribe("game.join", "room-handlers", func(req *game.JoinRequest) {
|
|
playerID := req.Name
|
|
if playerID == "" {
|
|
playerID = "Unknown"
|
|
}
|
|
|
|
roomID := req.RoomID
|
|
if roomID == "" {
|
|
roomID = "lobby"
|
|
}
|
|
|
|
log.Printf("📥 JOIN empfangen: Name=%s, RoomID=%s (Pod: %s)", req.Name, roomID, serverID)
|
|
|
|
// Prüfe Room-Registry: Welcher Pod soll diesen Raum hosten?
|
|
claimed, assignedPod, err := roomRegistry.ClaimRoom(roomID)
|
|
if err != nil {
|
|
log.Printf("❌ Room-Registry Fehler: %v", err)
|
|
return
|
|
}
|
|
|
|
// Wenn Raum einem anderen Pod zugewiesen ist, ignoriere den JOIN
|
|
if !claimed && assignedPod != serverID {
|
|
log.Printf("⏩ Raum '%s' gehört zu Pod %s, ignoriere JOIN auf Pod %s", roomID, assignedPod, serverID)
|
|
// Republish JOIN an den richtigen Pod (spezifischer Channel)
|
|
ec.Publish("game.join."+assignedPod, req)
|
|
return
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
// Raum finden oder erstellen (nur wenn wir der Owner sind)
|
|
room, exists := rooms[roomID]
|
|
if !exists {
|
|
log.Printf("🆕 Erstelle neuen Raum: '%s' auf Pod %s", roomID, serverID)
|
|
room = server.NewRoom(roomID, nc, globalWorld)
|
|
rooms[roomID] = room
|
|
|
|
// Starte den Game-Loop (Physik)
|
|
go room.RunLoop()
|
|
}
|
|
|
|
// Spieler hinzufügen (ID, Name)
|
|
room.AddPlayer(playerID, req.Name)
|
|
|
|
// Falls Host, speichere PlayerCode
|
|
if req.IsHost && req.PlayerCode != "" {
|
|
room.Mutex.Lock()
|
|
room.HostPlayerCode = req.PlayerCode
|
|
room.Mutex.Unlock()
|
|
log.Printf("🔑 Host PlayerCode gesetzt: %s", req.PlayerCode)
|
|
}
|
|
|
|
// Session speichern
|
|
playerSessions[playerID] = room
|
|
log.Printf("✅ Spieler '%s' ist Raum '%s' beigetreten (Pod: %s).", playerID, roomID, serverID)
|
|
})
|
|
|
|
if err != nil {
|
|
log.Fatal("❌ Fehler beim Subscribe auf game.join:", err)
|
|
}
|
|
log.Printf("👂 Lausche auf 'game.join' in Queue Group 'room-handlers'... (Sub Valid: %v)", sub.IsValid())
|
|
|
|
// 3b. HANDLER: Pod-spezifische JOINs (für Weiterleitung)
|
|
podJoinChannel := "game.join." + serverID
|
|
_, err = ec.Subscribe(podJoinChannel, func(req *game.JoinRequest) {
|
|
playerID := req.Name
|
|
if playerID == "" {
|
|
playerID = "Unknown"
|
|
}
|
|
|
|
roomID := req.RoomID
|
|
if roomID == "" {
|
|
roomID = "lobby"
|
|
}
|
|
|
|
log.Printf("📥 Pod-spezifischer JOIN empfangen: Name=%s, RoomID=%s (Pod: %s)", req.Name, roomID, serverID)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
// Raum muss bereits existieren
|
|
room, exists := rooms[roomID]
|
|
if !exists {
|
|
log.Printf("⚠️ Raum '%s' existiert nicht auf Pod %s", roomID, serverID)
|
|
return
|
|
}
|
|
|
|
// Spieler hinzufügen
|
|
room.AddPlayer(playerID, req.Name)
|
|
|
|
// Session speichern
|
|
playerSessions[playerID] = room
|
|
log.Printf("✅ Spieler '%s' ist Raum '%s' beigetreten (weitergeleitet zu Pod: %s).", playerID, roomID, serverID)
|
|
})
|
|
|
|
if err != nil {
|
|
log.Fatal("❌ Fehler beim Subscribe auf "+podJoinChannel+":", err)
|
|
}
|
|
log.Printf("👂 Lausche auf pod-spezifische JOINs: %s", podJoinChannel)
|
|
|
|
// 4. HANDLER: GAME START (broadcast - alle Pods empfangen, nur der mit dem Raum reagiert)
|
|
_, _ = ec.Subscribe("game.start", func(req *game.StartRequest) {
|
|
log.Printf("▶️ START empfangen: RoomID=%s, PlayerID=%s", req.RoomID, req.PlayerID)
|
|
|
|
mu.RLock()
|
|
room, exists := rooms[req.RoomID]
|
|
mu.RUnlock()
|
|
|
|
if exists {
|
|
room.Mutex.Lock()
|
|
|
|
// Prüfe ob der Spieler der Host ist
|
|
if room.HostID != "" && room.HostID != req.PlayerID {
|
|
log.Printf("⚠️ Spieler '%s' ist nicht Host von Raum '%s' (Host: %s)", req.PlayerID, req.RoomID, room.HostID)
|
|
room.Mutex.Unlock()
|
|
return
|
|
}
|
|
|
|
room.StartCountdown()
|
|
room.Mutex.Unlock()
|
|
log.Printf("🎮 Raum '%s' Countdown gestartet von Host '%s'", req.RoomID, req.PlayerID)
|
|
} else {
|
|
log.Printf("❌ Raum '%s' nicht gefunden", req.RoomID)
|
|
}
|
|
})
|
|
|
|
// 5. HANDLER: INPUT (broadcast - alle Pods empfangen, nur der mit dem Spieler reagiert)
|
|
_, _ = ec.Subscribe("game.input", func(input *game.ClientInput) {
|
|
mu.RLock()
|
|
room, ok := playerSessions[input.PlayerID]
|
|
mu.RUnlock()
|
|
|
|
if ok {
|
|
room.HandleInput(*input)
|
|
}
|
|
})
|
|
|
|
// 6. HANDLER: SCORE SUBMISSION (Queue Group - nur ein Pod speichert)
|
|
_, _ = ec.QueueSubscribe("score.submit", "score-handlers", func(submission *game.ScoreSubmission) {
|
|
// Verwende Team-Name wenn vorhanden (Coop-Mode), sonst Player-Name (Solo-Mode)
|
|
displayName := submission.PlayerName
|
|
if submission.TeamName != "" {
|
|
displayName = submission.TeamName
|
|
}
|
|
|
|
log.Printf("📊 Score-Submission: %s (%s) mit %d Punkten [Mode: %s]", displayName, submission.PlayerCode, submission.Score, submission.Mode)
|
|
added, proofCode := server.GlobalLeaderboard.AddScore(displayName, submission.PlayerCode, submission.Score)
|
|
if added {
|
|
log.Printf("✅ Score akzeptiert für %s (Proof: %s)", displayName, proofCode)
|
|
|
|
// Sende Response zurück über NATS
|
|
response := game.ScoreSubmissionResponse{
|
|
Success: true,
|
|
ProofCode: proofCode,
|
|
Score: submission.Score,
|
|
}
|
|
// Sende an player-spezifischen Channel
|
|
channel := "score.response." + submission.PlayerCode
|
|
ec.Publish(channel, &response)
|
|
log.Printf("📤 Proof-Code gesendet an Channel: %s", channel)
|
|
}
|
|
})
|
|
|
|
// 7. HANDLER: LEADERBOARD REQUEST (alt, für Kompatibilität) - Queue Group
|
|
_, _ = ec.QueueSubscribe("leaderboard.get", "leaderboard-handlers", func(subject, reply string, _ *struct{}) {
|
|
top10 := server.GlobalLeaderboard.GetTop10()
|
|
log.Printf("📊 Leaderboard-Request beantwortet: %d Einträge (Pod: %s)", len(top10), serverID)
|
|
ec.Publish(reply, top10)
|
|
})
|
|
|
|
// 8. HANDLER: LEADERBOARD REQUEST (neu, für WebSocket-Gateway) - Queue Group
|
|
_, _ = ec.QueueSubscribe("leaderboard.request", "leaderboard-handlers", func(req *game.LeaderboardRequest) {
|
|
top10 := server.GlobalLeaderboard.GetTop10()
|
|
log.Printf("📊 Leaderboard-Request (Mode=%s): %d Einträge (Pod: %s)", req.Mode, len(top10), serverID)
|
|
|
|
// Response an den angegebenen Channel senden
|
|
if req.ResponseChannel != "" {
|
|
resp := game.LeaderboardResponse{
|
|
Entries: top10,
|
|
}
|
|
ec.Publish(req.ResponseChannel, &resp)
|
|
log.Printf("📤 Leaderboard-Response gesendet an %s", req.ResponseChannel)
|
|
}
|
|
})
|
|
|
|
log.Println("✅ Server bereit. Warte auf Spieler...")
|
|
|
|
// 9. GIN-SERVER STARTEN (statische Dateien + WebSocket)
|
|
router := SetupGinServer(ec, "8080")
|
|
if err := router.Run(":8080"); err != nil {
|
|
log.Fatal("❌ Gin-Server Fehler:", err)
|
|
}
|
|
}
|
|
|
|
func getEnv(key, defaultValue string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func loadServerAssets(w *game.World) {
|
|
assetDir := "./cmd/client/web/assets"
|
|
chunkDir := filepath.Join(assetDir, "chunks")
|
|
|
|
// Manifest laden
|
|
manifestPath := filepath.Join(assetDir, "assets.json")
|
|
data, err := ioutil.ReadFile(manifestPath)
|
|
if err == nil {
|
|
var m game.AssetManifest
|
|
json.Unmarshal(data, &m)
|
|
w.Manifest = m
|
|
log.Printf("📦 Manifest geladen: %d Assets", len(m.Assets))
|
|
} else {
|
|
log.Println("⚠️ Manifest nicht gefunden:", manifestPath)
|
|
}
|
|
|
|
// Chunks laden
|
|
files, err := ioutil.ReadDir(chunkDir)
|
|
if err == nil {
|
|
for _, f := range files {
|
|
if filepath.Ext(f.Name()) == ".json" {
|
|
fullPath := filepath.Join(chunkDir, f.Name())
|
|
cData, err := ioutil.ReadFile(fullPath)
|
|
if err == nil {
|
|
var chunk game.Chunk
|
|
json.Unmarshal(cData, &chunk)
|
|
if chunk.ID == "" {
|
|
chunk.ID = f.Name()[0 : len(f.Name())-5]
|
|
}
|
|
w.ChunkLibrary[chunk.ID] = chunk
|
|
log.Printf("🧩 Chunk geladen: %s", chunk.ID)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
log.Println("⚠️ Chunk Ordner nicht gefunden:", chunkDir)
|
|
}
|
|
}
|