Private
Public Access
1
0
Files
EscapeFromTeacher/cmd/server/main.go
Sebastian Unterschütz 400a7e752b
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 2m27s
Add PlayerCode for enhanced score tracking and host validation logic in cooperative mode. Optimize UI for mobile devices with new responsive styles.
2026-01-04 17:34:19 +01:00

346 lines
9.7 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
)
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 (broadcast - alle Pods hören, aber nur zuständiger erstellt)
sub, err := ec.Subscribe("game.join", 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", req.Name, roomID)
// Prüfe ob dieser Pod für den Raum zuständig ist
if !isResponsibleForRoom(roomID) {
log.Printf("⏭️ Überspringe JOIN - nicht zuständig für Raum '%s'", roomID)
return
}
mu.Lock()
defer mu.Unlock()
// Raum finden oder erstellen
room, exists := rooms[roomID]
if !exists {
log.Printf("🆕 Erstelle neuen Raum: '%s' (zuständiger Pod)", roomID)
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.", playerID, roomID)
})
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)
_, _ = ec.Subscribe("leaderboard.get", func(subject, reply string, _ *struct{}) {
top10 := server.GlobalLeaderboard.GetTop10()
log.Printf("📊 Leaderboard-Request beantwortet: %d Einträge", len(top10))
ec.Publish(reply, top10)
})
// 8. HANDLER: LEADERBOARD REQUEST (neu, für WebSocket-Gateway)
_, _ = ec.Subscribe("leaderboard.request", func(req *game.LeaderboardRequest) {
top10 := server.GlobalLeaderboard.GetTop10()
log.Printf("📊 Leaderboard-Request (Mode=%s): %d Einträge", req.Mode, len(top10))
// 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
}
// isResponsibleForRoom prüft ob dieser Pod für den Raum zuständig ist
func isResponsibleForRoom(roomID string) bool {
// Wenn nur 1 Replica läuft, sind wir immer zuständig
totalReplicas := getEnv("TOTAL_REPLICAS", "1")
if totalReplicas == "1" {
return true
}
// Hash-basierte Zuweisung: RoomID → Pod
// Einfacher Ansatz: erster Buchstabe von RoomID
// A-M → Pod 0, N-Z → Pod 1
if len(roomID) == 0 {
return true
}
firstChar := roomID[0]
// Für 2 Replicas: A-M und N-Z splitten
if totalReplicas == "2" {
// Pod Namen sind meist: escape-game-0, escape-game-1, etc.
if firstChar <= 'M' || firstChar <= 'm' {
return serverID[len(serverID)-1] == '0' || !hasDigitSuffix()
}
return serverID[len(serverID)-1] == '1' || hasDigitSuffix()
}
// Fallback: immer zuständig
return true
}
func hasDigitSuffix() bool {
if len(serverID) == 0 {
return false
}
lastChar := serverID[len(serverID)-1]
return lastChar >= '0' && lastChar <= '9'
}
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)
}
}