diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2c0256a --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Game Security (Must match between frontend and backend) +GAME_SECRET= +VITE_GAME_SECRET= + +# Backend Config +PORT=8080 +DB_PATH=game.db + +# Frontend Config +VITE_API_URL=http://localhost:8080/api +VITE_GA_ID= diff --git a/.gitignore b/.gitignore index a6f8a8d..5edba23 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,12 @@ dist-ssr *.njsproj *.sln *.sw? -package-lock.json \ No newline at end of file +package-lock.json + +*.db +*.db-shm +*.db-wal + +.env +.env.local +.env.*.local \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..b9d0380 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,19 @@ +# Makefile for Rick Stack Backend + +BUILD_DIR=bin +BINARY_NAME=rick-server + +build: + go build -o $(BUILD_DIR)/$(BINARY_NAME) main.go + +run: + go run main.go + +test: + go test ./... + +clean: + rm -rf $(BUILD_DIR) + rm -f game.db game.db-shm game.db-wal + +all: build diff --git a/backend/database/db.go b/backend/database/db.go new file mode 100644 index 0000000..6a61204 --- /dev/null +++ b/backend/database/db.go @@ -0,0 +1,111 @@ +package database + +import ( + "database/sql" + _ "modernc.org/sqlite" + "log" + "time" +) + +var DB *sql.DB + +func InitDB(dbPath string) (*sql.DB, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + + // Enable WAL mode for performance + if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil { + log.Printf("failed to enable WAL: %v", err) + } + + // Create tables + schema := ` + CREATE TABLE IF NOT EXISTS players ( + id TEXT PRIMARY KEY, + nickname TEXT DEFAULT 'Rick', + game_state BLOB, + seeds INTEGER GENERATED ALWAYS AS (json_extract(game_state, '$.seeds')) VIRTUAL, + max_dimension INTEGER GENERATED ALWAYS AS (json_extract(game_state, '$.maxDimensionLevel')) VIRTUAL, + inventory_count INTEGER GENERATED ALWAYS AS (json_array_length(json_extract(game_state, '$.inventory'))) VIRTUAL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_players_seeds ON players(seeds); + CREATE INDEX IF NOT EXISTS idx_players_max_dimension ON players(max_dimension); + CREATE INDEX IF NOT EXISTS idx_players_inventory_count ON players(inventory_count); + ` + _, err = db.Exec(schema) + if err != nil { + return nil, err + } + + DB = db + return db, nil +} + +func SavePlayer(id string, nickname string, gameStateJSON []byte) error { + _, err := DB.Exec(` + INSERT INTO players (id, nickname, game_state, updated_at) + VALUES (?, ?, jsonb(?), CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET + nickname=?, + game_state=jsonb(?), + updated_at=CURRENT_TIMESTAMP + `, id, nickname, gameStateJSON, nickname, gameStateJSON) + return err +} + +func GetPlayer(id string) (string, []byte, time.Time, error) { + var playerId string + var gameState []byte + var updatedAt time.Time + err := DB.QueryRow("SELECT id, json(game_state), updated_at FROM players WHERE id = ?", id).Scan(&playerId, &gameState, &updatedAt) + if err != nil { + return "", nil, time.Time{}, err + } + return playerId, gameState, updatedAt, nil +} + +type LeaderboardEntry struct { + ID string `json:"id"` + Nickname string `json:"nickname"` + Value int64 `json:"value"` +} + +func GetLeaderboard(category string, limit int) ([]LeaderboardEntry, error) { + var query string + switch category { + case "seeds": + query = "SELECT id, nickname, seeds FROM players ORDER BY seeds DESC LIMIT ?" + case "dimension": + query = "SELECT id, nickname, max_dimension FROM players ORDER BY max_dimension DESC LIMIT ?" + case "inventory": + query = "SELECT id, nickname, inventory_count FROM players ORDER BY inventory_count DESC LIMIT ?" + case "discovery": + query = ` + SELECT id, nickname, (SELECT count(*) FROM json_each(json_extract(game_state, '$.discoveredCards'))) as discovery_count + FROM players + ORDER BY discovery_count DESC + LIMIT ?` + default: + query = "SELECT id, nickname, seeds FROM players ORDER BY seeds DESC LIMIT ?" + } + + rows, err := DB.Query(query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []LeaderboardEntry + for rows.Next() { + var e LeaderboardEntry + if err := rows.Scan(&e.ID, &e.Nickname, &e.Value); err != nil { + return nil, err + } + entries = append(entries, e) + } + return entries, nil +} diff --git a/backend/game/logic.go b/backend/game/logic.go new file mode 100644 index 0000000..0508a74 --- /dev/null +++ b/backend/game/logic.go @@ -0,0 +1,48 @@ +package game + +import ( + "backend/models" + "fmt" + "time" +) + +// Constants for logical verification +const ( + MaxSeedsPerSecond = 500.0 // Adjusted for "Seeds" + MaxNewCardsPerSave = 200 +) + +func ValidateProgress(oldState *models.GameState, newState *models.GameState, lastUpdate time.Time) error { + // If no old state, it's a new player. + if oldState == nil { + return nil + } + + deltaSeconds := time.Since(lastUpdate).Seconds() + if deltaSeconds < 0 { + return fmt.Errorf("invalid time delta") + } + + // 1. Seeds Verification + seedsDiff := newState.Seeds - oldState.Seeds + if seedsDiff > 0 { + // Calculate max possible based on some game logic + maxPossibleSeeds := (MaxSeedsPerSecond * deltaSeconds) + float64(newState.MaxDimensionLevel*1000) + if float64(seedsDiff) > maxPossibleSeeds { + return fmt.Errorf("impossible seeds gain: %d in %.1fs", seedsDiff, deltaSeconds) + } + } + + // 2. Inventory Count Verification + inventoryDiff := len(newState.Inventory) - len(oldState.Inventory) + if inventoryDiff > MaxNewCardsPerSave { + return fmt.Errorf("impossible inventory growth: %d", inventoryDiff) + } + + // 3. Dimension Level check + if newState.MaxDimensionLevel < oldState.MaxDimensionLevel { + return fmt.Errorf("max dimension level cannot decrease") + } + + return nil +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..cb5fdbe --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,21 @@ +module backend + +go 1.25.4 + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/cors v1.2.2 + modernc.org/sqlite v1.47.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..5510952 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,55 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/handlers/game.go b/backend/handlers/game.go new file mode 100644 index 0000000..481a8ba --- /dev/null +++ b/backend/handlers/game.go @@ -0,0 +1,122 @@ +package handlers + +import ( + "backend/database" + "backend/game" + "backend/models" + "backend/security" + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +type SaveRequest struct { + ID string `json:"id"` + Nickname string `json:"nickname"` + GameState json.RawMessage `json:"game_state"` + Signature string `json:"signature"` +} + +type GenericResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data any `json:"data,omitempty"` +} + +func SaveHandler(w http.ResponseWriter, r *http.Request) { + var req SaveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid request body") + return + } + + if !security.VerifyHMAC(req.GameState, req.Signature) { + respondWithError(w, http.StatusForbidden, "Invalid signature") + return + } + + var newState models.GameState + if err := json.Unmarshal(req.GameState, &newState); err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid game state format") + return + } + + var oldState *models.GameState + var lastUpdate time.Time + + _, oldStateJSON, dbUpdatedAt, err := database.GetPlayer(req.ID) + if err == nil { + oldState, _ = models.FromJSON(oldStateJSON) + lastUpdate = dbUpdatedAt + + if security.IsThrottled(lastUpdate) { + respondWithError(w, http.StatusTooManyRequests, "Please wait 60 seconds between saves") + return + } + } else if err != sql.ErrNoRows { + log.Printf("DB error: %v", err) + } + + if err := game.ValidateProgress(oldState, &newState, lastUpdate); err != nil { + respondWithError(w, http.StatusForbidden, fmt.Sprintf("Anti-cheat: %v", err)) + return + } + + if err := database.SavePlayer(req.ID, req.Nickname, req.GameState); err != nil { + log.Printf("Save error: %v", err) + respondWithError(w, http.StatusInternalServerError, "Failed to save data") + return + } + + respondWithJSON(w, http.StatusOK, GenericResponse{Success: true, Message: "Game saved successfully"}) +} + +func LoadHandler(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + respondWithError(w, http.StatusBadRequest, "ID is required") + return + } + + _, stateJSON, _, err := database.GetPlayer(id) + if err == sql.ErrNoRows { + respondWithError(w, http.StatusNotFound, "Player not found") + return + } else if err != nil { + log.Printf("Load error: %v", err) + respondWithError(w, http.StatusInternalServerError, "Failed to load data") + return + } + + respondWithJSON(w, http.StatusOK, GenericResponse{Success: true, Data: json.RawMessage(stateJSON)}) +} + +func LeaderboardHandler(w http.ResponseWriter, r *http.Request) { + category := r.URL.Query().Get("category") + if category == "" { + category = "seeds" + } + + entries, err := database.GetLeaderboard(category, 100) + if err != nil { + log.Printf("Leaderboard error: %v", err) + respondWithError(w, http.StatusInternalServerError, "Failed to fetch leaderboard") + return + } + + respondWithJSON(w, http.StatusOK, GenericResponse{Success: true, Data: entries}) +} + +func respondWithError(w http.ResponseWriter, code int, message string) { + respondWithJSON(w, code, GenericResponse{Success: false, Message: message}) +} + +func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) { + response, _ := json.Marshal(payload) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + w.Write(response) +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..55c1155 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "backend/database" + "backend/handlers" + "fmt" + "log" + "net/http" + "os" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" +) + +func main() { + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "game.db" + } + + db, err := database.InitDB(dbPath) + if err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + defer db.Close() + + r := chi.NewRouter() + + // Middleware + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Content-Type", "X-HMAC-Signature"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: true, + MaxAge: 300, + })) + + // Routes + r.Route("/api", func(r chi.Router) { + r.Post("/save", handlers.SaveHandler) + r.Get("/load", handlers.LoadHandler) + r.Get("/leaderboard", handlers.LeaderboardHandler) + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Wubba lubba dub dub! Server is running.")) + }) + }) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + fmt.Printf("Rick's Server starting on port %s...\n", port) + log.Fatal(http.ListenAndServe(":"+port, r)) +} diff --git a/backend/models/game_state.go b/backend/models/game_state.go new file mode 100644 index 0000000..40bf1a3 --- /dev/null +++ b/backend/models/game_state.go @@ -0,0 +1,30 @@ +package models + +import ( + "encoding/json" +) + +type GameCard struct { + ID string `json:"id"` + CharacterID int `json:"characterId"` + Types []string `json:"types"` +} + +type GameState struct { + Seeds int64 `json:"seeds"` + Inventory []GameCard `json:"inventory"` + DiscoveredCards map[string][]string `json:"discoveredCards"` // map[characterId]types + DimensionLevel int `json:"dimensionLevel"` + MaxDimensionLevel int `json:"maxDimensionLevel"` + LastSaved int64 `json:"lastSaved"` // JS timestamp +} + +func (gs *GameState) ToJSON() ([]byte, error) { + return json.Marshal(gs) +} + +func FromJSON(data []byte) (*GameState, error) { + var gs GameState + err := json.Unmarshal(data, &gs) + return &gs, err +} diff --git a/backend/security/security.go b/backend/security/security.go new file mode 100644 index 0000000..a24177c --- /dev/null +++ b/backend/security/security.go @@ -0,0 +1,30 @@ +package security + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "os" + "time" +) + +var SecretKey = getSecretKey() + +func getSecretKey() []byte { + key := os.Getenv("GAME_SECRET") + if key == "" { + return []byte("love-u-rick-<3") + } + return []byte(key) +} + +func VerifyHMAC(message []byte, signature string) bool { + h := hmac.New(sha256.New, SecretKey) + h.Write(message) + expectedSignature := hex.EncodeToString(h.Sum(nil)) + return hmac.Equal([]byte(expectedSignature), []byte(signature)) +} + +func IsThrottled(lastUpdate time.Time) bool { + return time.Since(lastUpdate) < 60*time.Second +} diff --git a/src/App.tsx b/src/App.tsx index 96c921c..07fbfc8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,13 +13,14 @@ import Splicer from "./pages/Splicer"; import NotFound from "./pages/NotFound"; import { useEffect, useRef } from "react"; -import { useGameStore, calculateCurrentIncome } from "@/store/gameStore"; +import { useGameStore, calculateCurrentIncome, getGameState } from "@/store/gameStore"; import { toast } from "sonner"; import { formatCurrency } from "@/lib/utils"; import { GAME_CONFIG } from "@/config/gameConfig"; import { initGA, trackPageView } from "@/lib/analytics"; import { useLocation } from "react-router-dom"; import { AutoOpenManager } from "@/components/game/AutoOpenManager"; +import { cloudLoad, cloudSave } from "./lib/api"; const queryClient = new QueryClient(); @@ -40,6 +41,63 @@ const App = () => { initGA(); }, []); + // Initial Cloud Sync + useEffect(() => { + const sync = async () => { + const cloudRes = await cloudLoad(); + if (!cloudRes.success) { + console.warn("Initial sync: Server is offline, will try again later."); + return; + } + + const localState = useGameStore.getState(); + const cloudState = cloudRes.data; + + if (cloudState) { + if (cloudState.lastSaved > localState.lastSaved) { + // Cloud is newer + const confirmLoad = window.confirm("A newer save was found on the Central Finite Curve. Load it?"); + if (confirmLoad) { + useGameStore.setState(cloudState); + toast.success("Cloud save loaded!"); + } + } else if (localState.lastSaved > cloudState.lastSaved) { + // Local is newer, upload it + const stateToSave = getGameState(localState); + const saveRes = await cloudSave(stateToSave); + if (saveRes && saveRes.success) { + localState.setLastCloudSave(Date.now()); + console.log("Local save synced to cloud (initial)"); + } + } + } else { + // No cloud save exists, create first one + const stateToSave = getGameState(localState); + const saveRes = await cloudSave(stateToSave); + if (saveRes && saveRes.success) { + localState.setLastCloudSave(Date.now()); + console.log("First cloud save created"); + } + } + }; + // Small delay to let hydrate finish + setTimeout(sync, 2000); + }, []); + + // Cloud Auto-Save — every 60s + useEffect(() => { + const interval = setInterval(async () => { + const state = useGameStore.getState(); + const stateToSave = getGameState(state); + const res = await cloudSave(stateToSave); + if (res && res.success) { + state.setLastCloudSave(Date.now()); + console.log("Cloud auto-save successful"); + } + }, 60000); + return () => clearInterval(interval); + }, []); + // Offline income calculation — runs once on mount useEffect(() => { if (offlineProcessed.current) return; diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..e2e79ec --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,72 @@ +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8080/api"; +const SECRET_KEY = import.meta.env.VITE_GAME_SECRET || "love-u-rick-<3"; + +export async function generateHMAC( + message: string, + secret: string, +): Promise { + const encoder = new TextEncoder(); + const keyData = encoder.encode(secret); + const msgData = encoder.encode(message); + + const key = await crypto.subtle.importKey( + "raw", + keyData, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + + const signature = await crypto.subtle.sign("HMAC", key, msgData); + return Array.from(new Uint8Array(signature)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export function getUUID(): string { + let uuid = localStorage.getItem("rick-morty-player-id"); + if (!uuid) { + uuid = crypto.randomUUID(); + localStorage.setItem("rick-morty-player-id", uuid); + } + return uuid; +} + +export async function cloudSave(gameState: any) { + const uuid = getUUID(); + const nickname = gameState.nickname || "Rick"; + const stateString = JSON.stringify(gameState); + const signature = await generateHMAC(stateString, SECRET_KEY); + + try { + const response = await fetch(`${API_URL}/save`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: uuid, + nickname: nickname, + game_state: gameState, + signature: signature, + }), + }); + return await response.json(); + } catch (error) { + console.error("Cloud save failed:", error); + return { success: false, message: "Server offline" }; + } +} + +export async function cloudLoad() { + const uuid = getUUID(); + try { + const response = await fetch(`${API_URL}/load?id=${uuid}`); + if (response.status === 404) return { success: true, data: null }; // Not found is a success, but no data + if (!response.ok) return { success: false, message: "Server error" }; + + const result = await response.json(); + return { success: true, data: result.data }; + } catch (error) { + console.error("Cloud load failed:", error); + return { success: false, message: "Server offline" }; + } +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 78b6efe..be6b8af 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,13 +1,30 @@ -import { useGameStore } from "@/store/gameStore"; +import { useGameStore, getGameState } from "@/store/gameStore"; import { Header } from "@/components/game/Header"; import { Footer } from "@/components/game/Footer"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Link, useNavigate } from "react-router-dom"; -import { ArrowLeft, Trash2, AlertTriangle, ShieldAlert } from "lucide-react"; +import { ArrowLeft, Trash2, AlertTriangle, ShieldAlert, Cloud, RefreshCw, User, LogIn } from "lucide-react"; import { toast } from "sonner"; +import { cloudSave, cloudLoad, getUUID } from "@/lib/api"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; const Settings = () => { const hardReset = useGameStore((s) => s.hardReset); + const nickname = useGameStore((s) => s.nickname); + const setNickname = useGameStore((s) => s.setNickname); + const lastCloudSave = useGameStore((s) => s.lastCloudSave); + const setLastCloudSave = useGameStore((s) => s.setLastCloudSave); const navigate = useNavigate(); const handleHardReset = () => { @@ -28,6 +45,39 @@ const Settings = () => { } }; + const handleManualSave = async () => { + const state = useGameStore.getState(); + const stateToSave = getGameState(state); + const res = await cloudSave(stateToSave); + if (res && res.success) { + setLastCloudSave(Date.now()); + toast.success("Saved to The Finite Curve!"); + } else { + toast.error("Cloud synchronization failed", { + description: res?.message || "Check your connection to Rick's server." + }); + } + }; + + const handleManualLoad = async () => { + const cloudRes = await cloudLoad(); + if (cloudRes.success && cloudRes.data) { + useGameStore.setState(cloudRes.data); + toast.success("Loaded from The Central Finite Curve!"); + } else { + toast.error("Failed to load from cloud", { + description: cloudRes.message || "No save found or server is offline." + }); + } + }; + + + const handleGoogleLogin = () => { + toast.info("Google Login", { + description: "In a real Rickverse, this would redirect you to Google. For now, your device ID is your portal gun." + }); + }; + return (
@@ -45,6 +95,92 @@ const Settings = () => {
+
+ {/* Glow effect */} +
+ +
+ +

+ The Central Finite Curve +

+
+ +
+
+ + setNickname(e.target.value)} + placeholder="Enter Rick name..." + className="bg-background/50 border-primary/20 focus-visible:ring-primary" + /> +
+ +
+
+ Cloud Status + + + {lastCloudSave + ? (Date.now() - lastCloudSave > 120000 + ? "Connecting..." + : `Synced ${new Date(lastCloudSave).toLocaleTimeString()}`) + : "Not Synced"} + +
+ +
+ + + + + + + Sync with the Finite Curve? + + This will overwrite your cloud save with your current local progress. + + + + Abort + Sync Now + + + + + + + + + + + Import from the Finite Curve? + + This will overwrite your current local progress with the cloud data. + + + + Abort + Import Now + + + +
+ + +
+
+
+
Discord diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 0675bd5..af95b8a 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -11,6 +11,7 @@ import { createPackSlice, PackSlice } from "./slices/packSlice"; import { createCollectionSlice, CollectionSlice } from "./slices/collectionSlice"; import { createAutoOpenSlice, AutoOpenSlice } from "./slices/autoOpenSlice"; import { createCraftingSlice, CraftingSlice } from "./slices/craftingSlice"; +import { createCloudSlice, CloudSlice } from "./slices/cloudSlice"; import { migrateGameStore } from "./migrations"; // Re-export utilities for component usage @@ -23,19 +24,21 @@ export type GameStore = CurrencySlice & PackSlice & CollectionSlice & AutoOpenSlice & - CraftingSlice; + CraftingSlice & + CloudSlice; export const useGameStore = create()( persist( - (...a) => ({ - ...createCurrencySlice(...a), - ...createInventorySlice(...a), - ...createDimensionSlice(...a), - ...createUpgradeSlice(...a), - ...createPackSlice(...a), - ...createCollectionSlice(...a), - ...createAutoOpenSlice(...a), - ...createCraftingSlice(...a), + (set, get, ...a) => ({ + ...createCurrencySlice(set, get, ...a), + ...createInventorySlice(set, get, ...a), + ...createDimensionSlice(set, get, ...a), + ...createUpgradeSlice(set, get, ...a), + ...createPackSlice(set, get, ...a), + ...createCollectionSlice(set, get, ...a), + ...createAutoOpenSlice(set, get, ...a), + ...createCraftingSlice(set, get, ...a), + ...createCloudSlice(set, get, ...a), }), { name: "rick-morty-idle-save", @@ -44,10 +47,8 @@ export const useGameStore = create()( onRehydrateStorage: () => (state) => { if (state) { // Recalculate maxInventory to ensure consistency - // Check if upgrades and dimensionInventoryBonus exist before accessing const upgradeBonus = (state.upgrades?.inventory || 0) * GAME_CONFIG.UPGRADES.inventory.BONUS_PER_LEVEL; const dimensionBonus = state.dimensionInventoryBonus || 0; - state.maxInventory = GAME_CONFIG.INITIAL_MAX_INVENTORY + upgradeBonus + dimensionBonus; if (state.inventory && state.inventory.length === 0) { @@ -56,10 +57,20 @@ export const useGameStore = create()( 0, ); state.addCard(starter); - state.addDiscovery(starter.characterId, starter.types); } } }, }, ), ); + +export const getGameState = (state: GameStore) => ({ + seeds: state.seeds, + inventory: state.inventory, + discoveredCards: state.discoveredCards, + dimensionLevel: state.dimensionLevel, + maxDimensionLevel: state.maxDimensionLevel, + upgrades: state.upgrades, + nickname: state.nickname, + lastSaved: state.lastSaved, +}); diff --git a/src/store/slices/cloudSlice.ts b/src/store/slices/cloudSlice.ts new file mode 100644 index 0000000..24a8408 --- /dev/null +++ b/src/store/slices/cloudSlice.ts @@ -0,0 +1,21 @@ +import { StateCreator } from "zustand"; +import { GameStore } from "../gameStore"; + +export interface CloudSlice { + nickname: string; + lastCloudSave: number; + setNickname: (name: string) => void; + setLastCloudSave: (ts: number) => void; +} + +export const createCloudSlice: StateCreator< + GameStore, + [], + [], + CloudSlice +> = (set) => ({ + nickname: "Rick", + lastCloudSave: 0, + setNickname: (name) => set({ nickname: name }), + setLastCloudSave: (ts) => set({ lastCloudSave: ts }), +}); diff --git a/test-sig.js b/test-sig.js new file mode 100644 index 0000000..ffdc8de --- /dev/null +++ b/test-sig.js @@ -0,0 +1,28 @@ +const crypto = require("node:crypto"); + +async function generateHMAC(message, secret) { + const encoder = new TextEncoder(); + const keyData = encoder.encode(secret); + const msgData = encoder.encode(message); + + const key = await crypto.subtle.importKey( + "raw", + keyData, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + + const signature = await crypto.subtle.sign("HMAC", key, msgData); + return Array.from(new Uint8Array(signature)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +const secret = "love-u-rick-<3"; +const gameState = { seeds: 10 }; +const stateString = JSON.stringify(gameState); +generateHMAC(stateString, secret).then(sig => { + console.log("String:", stateString); + console.log("Sig:", sig); +});