package main import ( "context" "encoding/json" "fmt" "html" "log" "math/rand" "net/http" "os" "strconv" "time" "github.com/google/uuid" "github.com/redis/go-redis/v9" ) // ========================================== // 1. KONSTANTEN // ========================================== const ( Gravity = 0.6 JumpPower = -12.0 GroundY = 350.0 PlayerHeight = 50.0 PlayerYBase = GroundY - PlayerHeight // 300.0 GameSpeed = 5.0 GameWidth = 800.0 ) // ========================================== // 2. GLOBALE VARIABLEN // ========================================== var ( ctx = context.Background() rdb *redis.Client defaultConfig GameConfig adminUser string adminPass string ) // ========================================== // 3. STRUCTS // ========================================== type ObstacleDef struct { ID string `json:"id"` Width float64 `json:"width"` Height float64 `json:"height"` Color string `json:"color"` Image string `json:"image"` CanTalk bool `json:"canTalk"` SpeechLines []string `json:"speechLines"` YOffset float64 `json:"yOffset"` } type GameConfig struct { Obstacles []ObstacleDef `json:"obstacles"` Backgrounds []string `json:"backgrounds"` } type ActiveObstacle struct { ID string `json:"id"` X float64 `json:"x"` Y float64 `json:"y"` Width float64 `json:"w"` Height float64 `json:"h"` } type Input struct { Tick int `json:"t"` Act string `json:"act"` } type ValidateRequest struct { SessionID string `json:"sessionId"` Inputs []Input `json:"inputs"` TotalTicks int `json:"totalTicks"` } type ValidateResponse struct { Status string `json:"status"` VerifiedScore int `json:"verifiedScore"` ServerObs []ActiveObstacle `json:"serverObs"` } type StartResponse struct { SessionID string `json:"sessionId"` Seed uint32 `json:"seed"` } type SubmitNameRequest struct { SessionID string `json:"sessionId"` Name string `json:"name"` } type SubmitResponse struct { ClaimCode string `json:"claimCode"` } type LeaderboardEntry struct { Rank int64 `json:"rank"` Name string `json:"name"` Score int `json:"score"` IsMe bool `json:"isMe"` } type AdminActionRequest struct { SessionID string `json:"sessionId"` Action string `json:"action"` } type AdminEntry struct { SessionID string `json:"sessionId"` Name string `json:"name"` Score int `json:"score"` Code string `json:"code"` Time string `json:"time"` } type ClaimDeleteRequest struct { SessionID string `json:"sessionId"` ClaimCode string `json:"claimCode"` } // ========================================== // 4. PSEUDO RNG // ========================================== type PseudoRNG struct { State uint32 } func NewRNG(seed int64) *PseudoRNG { // DEBUG: RNG Init // log.Printf("[RNG] Init mit Seed: %d", seed) return &PseudoRNG{State: uint32(seed)} } func (r *PseudoRNG) NextFloat() float64 { calc := (uint64(r.State)*1664525 + 1013904223) % 4294967296 r.State = uint32(calc) return float64(r.State) / 4294967296.0 } func (r *PseudoRNG) NextRange(min, max float64) float64 { return min + (r.NextFloat() * (max - min)) } func (r *PseudoRNG) PickDef(defs []ObstacleDef) *ObstacleDef { if len(defs) == 0 { return nil } idx := int(r.NextRange(0, float64(len(defs)))) return &defs[idx] } // ========================================== // 5. HELPER // ========================================== func getEnv(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { return value } return fallback } func parseOr(s string, def float64) float64 { if s == "" { return def } v, err := strconv.ParseFloat(s, 64) if err != nil { return def } return v } func generateClaimCode() string { const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, 8) for i := range b { b[i] = charset[rand.Intn(len(charset))] } return string(b) } // Middleware für Basic Auth func BasicAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() if !ok || user != adminUser || pass != adminPass { w.Header().Set("WWW-Authenticate", `Basic realm="Lehrerzimmer"`) http.Error(w, "Unauthorized", 401) return } next(w, r) } } // Middleware für Request Logging (DAMIT DU SIEHST OB REQUESTS ANKOMMEN) func LogRequest(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { start := time.Now() log.Printf("➡ [REQ] %s %s von %s", r.Method, r.URL.Path, r.RemoteAddr) next(w, r) log.Printf("⬅ [RES] %s %s fertig in %v", r.Method, r.URL.Path, time.Since(start)) } } // ========================================== // 6. MAIN // ========================================== func main() { // Config laden redisAddr := getEnv("REDIS_ADDR", "localhost:6379") adminUser = getEnv("ADMIN_USER", "lehrer") adminPass = getEnv("ADMIN_PASS", "geheim123") log.Printf("🔧 CONFIG: Redis=%s | AdminUser=%s", redisAddr, adminUser) // Redis verbinden log.Println("🔌 Verbinde zu Redis...") rdb = redis.NewClient(&redis.Options{Addr: redisAddr}) pong, err := rdb.Ping(ctx).Result() if err != nil { log.Fatalf("❌ REDIS ERROR: %v", err) } log.Printf("✅ Redis verbunden: %s", pong) initGameConfig() // File Server fs := http.FileServer(http.Dir("./static")) http.Handle("/", fs) // API Routes (mit Logging Wrapper) http.HandleFunc("/api/config", LogRequest(handleConfig)) http.HandleFunc("/api/start", LogRequest(handleStart)) http.HandleFunc("/api/validate", LogRequest(handleValidate)) // Hier passiert die Magie http.HandleFunc("/api/submit-name", LogRequest(handleSubmitName)) http.HandleFunc("/api/leaderboard", LogRequest(handleLeaderboard)) http.HandleFunc("/api/claim/delete", LogRequest(handleClaimDelete)) // Admin Routes http.HandleFunc("/admin", BasicAuth(handleAdminPage)) http.HandleFunc("/api/admin/list", BasicAuth(handleAdminList)) http.HandleFunc("/api/admin/action", BasicAuth(handleAdminAction)) log.Println("🦖 Server läuft auf Port 8080") log.Fatal(http.ListenAndServe(":8080", nil)) } func initGameConfig() { defaultConfig = GameConfig{ Obstacles: []ObstacleDef{ {ID: "desk", Width: 40, Height: 30, Color: "#8B4513", Image: "desk.png"}, {ID: "teacher", Width: 30, Height: 60, Color: "#000080", Image: "teacher.png", CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!", "Nachsitzen!"}}, {ID: "trashcan", Width: 25, Height: 35, Color: "#555", Image: "trash.png"}, {ID: "eraser", Width: 30, Height: 20, Color: "#fff", Image: "eraser.png", YOffset: 45.0}, }, Backgrounds: []string{"background.jpg"}, } log.Println("✅ Game Config initialisiert") } // ========================================== // 7. HANDLER (MIT DEBUG LOGS) // ========================================== func handleConfig(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(defaultConfig) } func handleStart(w http.ResponseWriter, r *http.Request) { sessionID := uuid.New().String() // Seed erstellen (32-Bit sicher) rawSeed := time.Now().UnixNano() seed32 := uint32(rawSeed) // Initiale Hindernisse (leer) emptyObs, _ := json.Marshal([]ActiveObstacle{}) log.Printf("🆕 START SESSION: %s | Seed: %d", sessionID, seed32) // In Redis speichern err := rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{ "seed": seed32, "rng_state": seed32, "score": 0, "is_dead": 0, "pos_y": PlayerYBase, // WICHTIG: Start auf dem Boden (300.0) "vel_y": 0.0, "obstacles": string(emptyObs), }).Err() if err != nil { log.Printf("❌ REDIS WRITE ERROR bei Start: %v", err) http.Error(w, "DB Error", 500) return } // Expire setzen rdb.Expire(ctx, "session:"+sessionID, 1*time.Hour) json.NewEncoder(w).Encode(StartResponse{SessionID: sessionID, Seed: seed32}) } func handleValidate(w http.ResponseWriter, r *http.Request) { var req ValidateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("⚠️ Validate Decode Error: %v", err) http.Error(w, "Bad Request", 400) return } key := "session:" + req.SessionID // Alle Daten holen vals, err := rdb.HGetAll(ctx, key).Result() if err != nil || len(vals) == 0 { log.Printf("⚠️ Session nicht gefunden oder leer: %s", req.SessionID) http.Error(w, "Session invalid", 401) return } // Check ob schon tot if vals["is_dead"] == "1" { // log.Printf("💀 Validierung abgelehnt: Spieler %s ist bereits tot.", req.SessionID) json.NewEncoder(w).Encode(ValidateResponse{Status: "dead", VerifiedScore: 0}) return } // State parsen posY := parseOr(vals["pos_y"], PlayerYBase) velY := parseOr(vals["vel_y"], 0.0) score := int(parseOr(vals["score"], 0)) rngStateVal, _ := strconv.ParseInt(vals["rng_state"], 10, 64) // RNG initialisieren rng := NewRNG(rngStateVal) // Hindernisse laden var obstacles []ActiveObstacle if val, ok := vals["obstacles"]; ok && val != "" { json.Unmarshal([]byte(val), &obstacles) } else { obstacles = []ActiveObstacle{} } // log.Printf("🎮 SIMULATION START (%s) | Ticks: %d | PosY: %.2f | Obstacles: %d", req.SessionID, req.TotalTicks, posY, len(obstacles)) playerDead := false // --- SIMULATION LOOP --- for i := 0; i < req.TotalTicks; i++ { // A. INPUT didJump := false isCrouching := false for _, inp := range req.Inputs { if inp.Tick == i { if inp.Act == "JUMP" { didJump = true } if inp.Act == "DUCK" { isCrouching = true } } } // B. PHYSIK // Toleranz 1.0px isGrounded := posY >= PlayerYBase-1.0 currentHeight := PlayerHeight if isCrouching { currentHeight = PlayerHeight / 2 if !isGrounded { velY += 2.0 } // Fast fall } if didJump && isGrounded && !isCrouching { velY = JumpPower } velY += Gravity posY += velY if posY > PlayerYBase { posY = PlayerYBase velY = 0 } // Hitbox Y berechnen hitboxY := posY if isCrouching { hitboxY = posY + (PlayerHeight - currentHeight) } // C. OBSTACLES & KOLLISION nextObstacles := []ActiveObstacle{} rightmostX := 0.0 for _, obs := range obstacles { obs.X -= GameSpeed // Ist Hindernis schon vorbei? (Hinter Spieler) if obs.X+obs.Width < 50.0 { // log.Printf("🗑️ Obstacle %s passed safely", obs.ID) continue // Nicht in nextObstacles aufnehmen -> wird gelöscht } // Kollisions-Logik paddingX := 10.0 paddingY_Top := 25.0 paddingY_Bottom := 5.0 pLeft, pRight := 50.0+paddingX, 50.0+30.0-paddingX pTop, pBottom := hitboxY+paddingY_Top, hitboxY+currentHeight-paddingY_Bottom oLeft, oRight := obs.X+paddingX, obs.X+obs.Width-paddingX oTop, oBottom := obs.Y+paddingY_Top, obs.Y+obs.Height-paddingY_Bottom if pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom { log.Printf("💥 KOLLISION! Session: %s | Obs: %s at %.2f | PlayerY: %.2f | Crouch: %v", req.SessionID, obs.ID, obs.X, posY, isCrouching) playerDead = true } // Behalten wenn noch relevant (sichtbar oder kurz davor) // Wir behalten es etwas länger, damit Client Synchronisation nicht springt if obs.X+obs.Width > -100 { nextObstacles = append(nextObstacles, obs) if obs.X+obs.Width > rightmostX { rightmostX = obs.X + obs.Width } } } obstacles = nextObstacles // D. SPAWNING if rightmostX < GameWidth-10.0 { rawGap := 400.0 + rng.NextRange(0, 500) gap := float64(int(rawGap)) spawnX := rightmostX + gap if spawnX < GameWidth { spawnX = GameWidth } var possibleDefs []ObstacleDef for _, d := range defaultConfig.Obstacles { if d.ID == "eraser" { if score >= 500 { possibleDefs = append(possibleDefs, d) } } else { possibleDefs = append(possibleDefs, d) } } def := rng.PickDef(possibleDefs) if def != nil && def.CanTalk { if rng.NextFloat() > 0.7 { rng.NextFloat() } } if def != nil { spawnY := GroundY - def.Height - def.YOffset // log.Printf("✨ SPAWN: %s at %.2f (Y: %.2f)", def.ID, spawnX, spawnY) obstacles = append(obstacles, ActiveObstacle{ ID: def.ID, X: spawnX, Y: spawnY, Width: def.Width, Height: def.Height, }) } } if !playerDead { score++ } else { // Wenn tot, brechen wir die Loop ab, um Rechenzeit zu sparen // und senden den Dead-Status zurück break } } // --- SPEICHERN --- status := "alive" if playerDead { status = "dead" rdb.HSet(ctx, key, "is_dead", 1) } obsJson, _ := json.Marshal(obstacles) err = rdb.HSet(ctx, key, map[string]interface{}{ "score": score, "pos_y": fmt.Sprintf("%f", posY), "vel_y": fmt.Sprintf("%f", velY), "rng_state": rng.State, "obstacles": string(obsJson), }).Err() if err != nil { log.Printf("❌ REDIS SAVE ERROR: %v", err) } rdb.Expire(ctx, key, 1*time.Hour) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(ValidateResponse{ Status: status, VerifiedScore: score, ServerObs: obstacles, }) } func handleSubmitName(w http.ResponseWriter, r *http.Request) { var req SubmitNameRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Bad Request", 400) return } log.Printf("📝 SUBMIT NAME: %s für Session %s", req.Name, req.SessionID) safeName := html.EscapeString(req.Name) sessionKey := "session:" + req.SessionID scoreVal, err := rdb.HGet(ctx, sessionKey, "score").Result() if err != nil { log.Printf("⚠️ Submit fehlgeschlagen: Session nicht gefunden %s", req.SessionID) http.Error(w, "Session expired", 404) return } scoreInt, _ := strconv.Atoi(scoreVal) claimCode := generateClaimCode() timestamp := time.Now().Format("02.01.2006 15:04") rdb.HSet(ctx, sessionKey, map[string]interface{}{ "name": safeName, "claim_code": claimCode, "created_at": timestamp, }) rdb.ZAdd(ctx, "leaderboard:unverified", redis.Z{ Score: float64(scoreInt), Member: req.SessionID, }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(SubmitResponse{ClaimCode: claimCode}) } func handleLeaderboard(w http.ResponseWriter, r *http.Request) { mySessionID := r.URL.Query().Get("sessionId") targetKey := "leaderboard:public" var entries []LeaderboardEntry top3, _ := rdb.ZRevRangeWithScores(ctx, targetKey, 0, 2).Result() for i, z := range top3 { rank := int64(i + 1) sid := z.Member.(string) name, _ := rdb.HGet(ctx, "session:"+sid, "name").Result() if name == "" { name = "Unbekannt" } entries = append(entries, LeaderboardEntry{ Rank: rank, Name: name, Score: int(z.Score), IsMe: (sid == mySessionID), }) } if mySessionID != "" { myRank, err := rdb.ZRevRank(ctx, targetKey, mySessionID).Result() if err == nil { if myRank > 2 { start := myRank - 1 stop := myRank + 1 neighbors, _ := rdb.ZRevRangeWithScores(ctx, targetKey, start, stop).Result() for i, z := range neighbors { rank := start + int64(i) + 1 sid := z.Member.(string) name, _ := rdb.HGet(ctx, "session:"+sid, "name").Result() if name == "" { name = "Unbekannt" } entries = append(entries, LeaderboardEntry{ Rank: rank, Name: name, Score: int(z.Score), IsMe: (sid == mySessionID), }) } } } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(entries) } func handleAdminPage(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./secure/admin.html") } func handleAdminList(w http.ResponseWriter, r *http.Request) { listType := r.URL.Query().Get("type") redisKey := "leaderboard:unverified" if listType == "public" { redisKey = "leaderboard:public" } vals, _ := rdb.ZRevRangeWithScores(ctx, redisKey, 0, -1).Result() var adminList []AdminEntry for _, z := range vals { sid := z.Member.(string) info, _ := rdb.HGetAll(ctx, "session:"+sid).Result() name := info["name"] if name == "" { name = "Unbekannt" } adminList = append(adminList, AdminEntry{ SessionID: sid, Name: name, Score: int(z.Score), Code: info["claim_code"], Time: info["created_at"], }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(adminList) } func handleAdminAction(w http.ResponseWriter, r *http.Request) { var req AdminActionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Bad Request", 400) return } log.Printf("👮 ADMIN ACTION: %s on %s", req.Action, req.SessionID) if req.Action == "approve" { score, err := rdb.ZScore(ctx, "leaderboard:unverified", req.SessionID).Result() if err != nil { http.Error(w, "Entry not found", 404) return } rdb.ZAdd(ctx, "leaderboard:public", redis.Z{Score: score, Member: req.SessionID}) rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID) } else if req.Action == "delete" { rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID) rdb.ZRem(ctx, "leaderboard:public", req.SessionID) } w.WriteHeader(http.StatusOK) } func handleClaimDelete(w http.ResponseWriter, r *http.Request) { var req ClaimDeleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Bad Request", 400) return } sessionKey := "session:" + req.SessionID realCode, err := rdb.HGet(ctx, sessionKey, "claim_code").Result() if err != nil || realCode == "" { log.Printf("⚠️ Claim Delete Failed: Session/Code missing %s", req.SessionID) http.Error(w, "Not found", 404) return } if realCode != req.ClaimCode { log.Printf("⛔ Claim Delete Denied: Wrong Code %s vs %s", req.ClaimCode, realCode) http.Error(w, "Wrong Code", 403) return } log.Printf("🗑️ USER DELETE: Session %s deleted via code", req.SessionID) rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID) rdb.ZRem(ctx, "leaderboard:public", req.SessionID) rdb.HDel(ctx, sessionKey, "name") w.WriteHeader(http.StatusOK) }