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 ) 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: %s", redisAddr) // 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) mu.Lock() defer mu.Unlock() // Raum finden oder erstellen 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 'game-servers'... (Sub Valid: %v)", sub.IsValid()) // 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) } }