diff --git a/data/levels.json b/data/levels.json index fb9ad6b..021f82c 100644 --- a/data/levels.json +++ b/data/levels.json @@ -1,37 +1,8 @@ [ { - "id": "url-shortener-easy", - "name": "URL Shortener", - "description": "Build a basic service to shorten URLs with a single backend.", - "difficulty": "easy", - "targetRps": 100, - "durationSec": 60, - "maxMonthlyUsd": 100, - "maxP95LatencyMs": 200, - "requiredAvailabilityPct": 99.0, - "mustInclude": ["database"], - "hints": ["Start with a basic backend and persistent storage."], - "interviewerRequirements": [ - "Users should be able to shorten a URL via a basic web interface or API.", - "Each shortened URL should redirect to the original URL.", - "Data should be persisted so links remain valid after a restart." - ], - "functionalRequirements": [ - "Must include a database to persist mappings" - ], - "nonFunctionalRequirements": [ - "Target RPS: 100", - "Max P95 latency: 200ms", - "Required availability: 99.0%", - "Max monthly cost: $100", - "Simulation duration: 60 seconds" - ] - }, - { - "id": "url-shortener-medium", + "id": "url-shortener", "name": "URL Shortener", "description": "Scale your URL shortener to handle traffic spikes and ensure high availability.", - "difficulty": "medium", "targetRps": 1000, "durationSec": 180, "maxMonthlyUsd": 300, @@ -58,70 +29,9 @@ ] }, { - "id": "url-shortener-hard", - "name": "URL Shortener", - "description": "Design a globally distributed URL shortening service with low latency and high availability.", - "difficulty": "hard", - "targetRps": 10000, - "durationSec": 300, - "maxMonthlyUsd": 1000, - "maxP95LatencyMs": 100, - "requiredAvailabilityPct": 99.99, - "mustInclude": ["cdn", "database"], - "encouragedComponents": ["cache", "messageQueue"], - "hints": ["Think about write-path consistency and global replication."], - "interviewerRequirements": [ - "The service must support globally distributed traffic with low latency.", - "Users across the world should get fast redirects via local CDN edge nodes.", - "Writes (shorten requests) should be consistent and durable." - ], - "functionalRequirements": [ - "Must include a CDN and a database", - "Encouraged to include a cache and a message queue", - "Writes should pass through a queue before hitting storage (eventual consistency)" - ], - "nonFunctionalRequirements": [ - "Target RPS: 10000", - "Max P95 latency: 100ms", - "Required availability: 99.99%", - "Max monthly cost: $1000", - "Simulation duration: 300 seconds" - ] - }, - - { - "id": "chat-app-easy", - "name": "Chat App", - "description": "Implement a simple chat app for small group communication.", - "difficulty": "easy", - "targetRps": 50, - "durationSec": 120, - "maxMonthlyUsd": 150, - "maxP95LatencyMs": 300, - "requiredAvailabilityPct": 99.0, - "mustInclude": ["webserver", "database"], - "hints": ["You don’t need to persist every message yet."], - "interviewerRequirements": [ - "Users should be able to send and receive messages in real-time.", - "Messages should be stored to support reloading the page without data loss.", - "A basic frontend should connect to a backend service." - ], - "functionalRequirements": [ - "Must include a webserver and a database" - ], - "nonFunctionalRequirements": [ - "Target RPS: 50", - "Max P95 latency: 300ms", - "Required availability: 99.0%", - "Max monthly cost: $150", - "Simulation duration: 120 seconds" - ] - }, - { - "id": "chat-app-medium", + "id": "chat-app", "name": "Chat App", "description": "Support real-time chat across mobile and web, with message persistence.", - "difficulty": "medium", "targetRps": 500, "durationSec": 300, "maxMonthlyUsd": 500, @@ -148,70 +58,9 @@ ] }, { - "id": "chat-app-hard", - "name": "Chat App", - "description": "Design a Slack-scale chat platform supporting typing indicators, read receipts, and delivery guarantees.", - "difficulty": "hard", - "targetRps": 5000, - "durationSec": 600, - "maxMonthlyUsd": 1500, - "maxP95LatencyMs": 100, - "requiredAvailabilityPct": 99.99, - "mustInclude": ["messageQueue", "database"], - "discouragedComponents": ["single-instance webserver"], - "hints": ["Think about pub/sub, retries, and ordering guarantees."], - "interviewerRequirements": [ - "Messages must support delivery guarantees and deduplication.", - "Users must receive typing indicators and read receipts in near-real-time.", - "System must scale horizontally and tolerate node failures." - ], - "functionalRequirements": [ - "Must include a message queue and database", - "Discouraged from using a single-instance webserver", - "Encouraged to use a publish/subscribe system for fan-out" - ], - "nonFunctionalRequirements": [ - "Target RPS: 5000", - "Max P95 latency: 100ms", - "Required availability: 99.99%", - "Max monthly cost: $1500", - "Simulation duration: 600 seconds" - ] - }, - - { - "id": "netflix-easy", - "name": "Netflix Clone", - "description": "Build a basic video streaming service with direct file access.", - "difficulty": "easy", - "targetRps": 200, - "durationSec": 300, - "maxMonthlyUsd": 500, - "maxP95LatencyMs": 500, - "requiredAvailabilityPct": 99.0, - "mustInclude": ["cdn"], - "hints": ["You don’t need full-blown adaptive streaming yet."], - "interviewerRequirements": [ - "Users should be able to request and stream a video file.", - "Content should be served via a CDN to reduce latency and bandwidth cost.", - "Playback does not require adaptive streaming." - ], - "functionalRequirements": [ - "Must include a CDN to serve static video content" - ], - "nonFunctionalRequirements": [ - "Target RPS: 200", - "Max P95 latency: 500ms", - "Required availability: 99.0%", - "Max monthly cost: $500", - "Simulation duration: 300 seconds" - ] - }, - { - "id": "netflix-medium", + "id": "netflix-clone", "name": "Netflix Clone", "description": "Add video transcoding, caching, and recommendations.", - "difficulty": "medium", "targetRps": 1000, "durationSec": 600, "maxMonthlyUsd": 2000, @@ -238,70 +87,9 @@ ] }, { - "id": "netflix-hard", - "name": "Netflix Clone", - "description": "Design a globally resilient, multi-region Netflix-scale system with intelligent failover and real-time telemetry.", - "difficulty": "hard", - "targetRps": 10000, - "durationSec": 900, - "maxMonthlyUsd": 10000, - "maxP95LatencyMs": 200, - "requiredAvailabilityPct": 99.999, - "mustInclude": ["cdn", "data pipeline", "monitoring/alerting"], - "encouragedComponents": ["messageQueue", "cache", "third party service"], - "hints": ["You’ll need intelligent routing and fallback mechanisms."], - "interviewerRequirements": [ - "Users worldwide should stream with minimal latency through regional CDN edge nodes.", - "The system must support failover between regions.", - "Real-time metrics and alerting must be integrated for proactive issue detection." - ], - "functionalRequirements": [ - "Must include a CDN, data pipeline, and monitoring/alerting", - "Encouraged to use cache and queue for async video processing", - "Encouraged to simulate third-party service integrations (e.g. payment, licensing)" - ], - "nonFunctionalRequirements": [ - "Target RPS: 10000", - "Max P95 latency: 200ms", - "Required availability: 99.999%", - "Max monthly cost: $10000", - "Simulation duration: 900 seconds" - ] - }, - { - "id": "rate-limiter-easy", - "name": "Rate Limiter", - "description": "Build a basic in-memory rate limiter for a single instance service.", - "difficulty": "easy", - "targetRps": 200, - "durationSec": 60, - "maxMonthlyUsd": 50, - "maxP95LatencyMs": 100, - "requiredAvailabilityPct": 99.0, - "mustInclude": ["webserver"], - "hints": ["Use an in-memory store and sliding window or token bucket."], - "interviewerRequirements": [ - "Each client should be limited to N requests per minute.", - "Rate limits should be enforced in memory.", - "Only one instance is required—no cross-node coordination." - ], - "functionalRequirements": [ - "Must include a webserver that can reject requests over the configured RPS", - "Rate limiting must be enforced locally (no coordination with other nodes)" - ], - "nonFunctionalRequirements": [ - "Target RPS: 200", - "Max P95 latency: 100ms", - "Required availability: 99.0%", - "Max monthly cost: $50", - "Simulation duration: 60 seconds" - ] - }, - { - "id": "rate-limiter-medium", + "id": "rate-limiter", "name": "Rate Limiter", "description": "Design a rate limiter that works across multiple instances and enforces global quotas.", - "difficulty": "medium", "targetRps": 1000, "durationSec": 180, "maxMonthlyUsd": 300, @@ -329,70 +117,9 @@ ] }, { - "id": "rate-limiter-hard", - "name": "Rate Limiter", - "description": "Build a globally distributed rate limiter with per-user and per-region policies.", - "difficulty": "hard", - "targetRps": 5000, - "durationSec": 300, - "maxMonthlyUsd": 1000, - "maxP95LatencyMs": 30, - "requiredAvailabilityPct": 99.99, - "mustInclude": ["cache"], - "encouragedComponents": ["cdn", "data pipeline", "monitoring/alerting"], - "hints": ["Ensure low latency despite distributed state. Avoid single points of failure."], - "interviewerRequirements": [ - "Each user must be rate-limited independently and consistently across regions.", - "The system should avoid global bottlenecks while maintaining quota correctness.", - "Should include real-time metrics and alerting on quota violations or system degradation." - ], - "functionalRequirements": [ - "Must include a cache that replicates or partitions rate-limit state regionally", - "Rate limiting should be enforced with user-scoped and region-scoped policies", - "Must simulate availability zones with failover and latency variance" - ], - "nonFunctionalRequirements": [ - "Target RPS: 5000", - "Max P95 latency: 30ms", - "Required availability: 99.99%", - "Max monthly cost: $1000", - "Simulation duration: 300 seconds" - ] - }, - { - "id": "metrics-system-easy", - "name": "Metrics System", - "description": "Create a basic system that collects and stores custom app metrics locally.", - "difficulty": "easy", - "targetRps": 100, - "durationSec": 120, - "maxMonthlyUsd": 100, - "maxP95LatencyMs": 200, - "requiredAvailabilityPct": 99.0, - "mustInclude": ["webserver", "database"], - "hints": ["Start by storing metrics as timestamped values in a simple DB."], - "interviewerRequirements": [ - "Metrics should be received via HTTP and stored locally.", - "No external systems needed—simple write and read support.", - "Support querying metrics over a time range." - ], - "functionalRequirements": [ - "Must include a webserver to receive metric data", - "Must include a database to persist metrics with timestamps" - ], - "nonFunctionalRequirements": [ - "Target RPS: 100", - "Max P95 latency: 200ms", - "Required availability: 99.0%", - "Max monthly cost: $100", - "Simulation duration: 120 seconds" - ] - }, - { - "id": "metrics-system-medium", + "id": "metrics-system", "name": "Metrics System", "description": "Design a pull-based metrics system like Prometheus that scrapes multiple services.", - "difficulty": "medium", "targetRps": 1000, "durationSec": 300, "maxMonthlyUsd": 500, @@ -418,36 +145,5 @@ "Max monthly cost: $500", "Simulation duration: 300 seconds" ] - }, - { - "id": "metrics-system-hard", - "name": "Metrics System", - "description": "Build a scalable, multi-tenant metrics platform with real-time alerts and dashboard support.", - "difficulty": "hard", - "targetRps": 5000, - "durationSec": 600, - "maxMonthlyUsd": 1500, - "maxP95LatencyMs": 50, - "requiredAvailabilityPct": 99.99, - "mustInclude": ["monitoring/alerting", "data pipeline"], - "encouragedComponents": ["messageQueue", "cache", "third party service"], - "hints": ["Think about downsampling, alert thresholds, and dashboard queries."], - "interviewerRequirements": [ - "Support multi-tenant metrics isolation and quota enforcement.", - "Enable real-time alerting with low-latency threshold evaluation.", - "Expose APIs for dashboards and custom queries." - ], - "functionalRequirements": [ - "Must include a data pipeline that can scale with RPS and tenants", - "Must include monitoring/alerting logic for low-latency threshold detection", - "Encouraged to buffer high-volume ingestion via message queues" - ], - "nonFunctionalRequirements": [ - "Target RPS: 5000", - "Max P95 latency: 50ms", - "Required availability: 99.99%", - "Max monthly cost: $1500", - "Simulation duration: 600 seconds" - ] } -] +] \ No newline at end of file diff --git a/go.mod b/go.mod index 5d33b41..6bd6ec6 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,16 @@ toolchain go1.23.10 require ( github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/gorilla/mux v1.8.1 + github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 + github.com/potproject/claude-sdk-go v1.3.2 github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d ) require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/coder/websocket v1.8.12 // indirect + github.com/tmaxmax/go-sse v0.8.0 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect ) diff --git a/go.sum b/go.sum index d5a5504..d04a1a6 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,16 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/potproject/claude-sdk-go v1.3.2 h1:n27wJoGbObQ0xcWucNI1f4RY29+4vAWfyblTLfOLmSk= +github.com/potproject/claude-sdk-go v1.3.2/go.mod h1:0cfNkl21VJGW/XZg+5VfP5eJVRRtRj24cHSBQ/lMNPA= +github.com/tmaxmax/go-sse v0.8.0 h1:pPpTgyyi1r7vG2o6icebnpGEh3ebcnBXqDWkb7aTofs= +github.com/tmaxmax/go-sse v0.8.0/go.mod h1:HLoxqxdH+7oSUItjtnpxjzJedfr/+Rrm/dNWBcTxJFM= github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU= github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= diff --git a/internal/level/level.go b/internal/level/level.go index 8ffef6b..340d60a 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -4,14 +4,13 @@ import ( "encoding/json" "fmt" "os" - "strings" + "slices" ) type Level struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Difficulty Difficulty `json:"difficulty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` TargetRPS int `json:"targetRps"` DurationSec int `json:"durationSec"` @@ -36,15 +35,7 @@ type Level struct { NonFunctionalRequirements []string `json:"nonFunctionalRequirements,omitempty"` } -type Difficulty string - -const ( - DifficultyEasy Difficulty = "easy" - DifficultyMedium Difficulty = "medium" - DifficultyHard Difficulty = "hard" -) - -var Registry map[string]map[string]Level +var Registry map[string]Level type FailureEvent struct { Type string `json:"type"` @@ -67,53 +58,33 @@ func LoadLevels(path string) ([]Level, error) { } func InitRegistry(levels []Level) { - Registry = make(map[string]map[string]Level) + Registry = make(map[string]Level) for _, lvl := range levels { - // check if level already exists here - normalized := strings.ToLower(lvl.Name) - if _, ok := Registry[normalized]; !ok { - Registry[normalized] = make(map[string]Level) - } - // populate it - Registry[normalized][string(lvl.Difficulty)] = lvl + Registry[lvl.ID] = lvl } } -func GetLevel(name string, difficulty Difficulty) (*Level, error) { - name = strings.ToLower(name) - diffMap, ok := Registry[name] +func GetLevelByID(id string) (*Level, error) { + lvl, ok := Registry[id] if !ok { - return nil, fmt.Errorf("level name %s not found", name) - } - - lvl, ok := diffMap[string(difficulty)] - if !ok { - return nil, fmt.Errorf("difficulty %s not available for level '%s'", difficulty, name) + return nil, fmt.Errorf("level with ID %s not found", id) } return &lvl, nil } -func (d *Difficulty) UnmarshalJSON(b []byte) error { - var s string - if err := json.Unmarshal(b, &s); err != nil { - return err - } - - switch s { - case string(DifficultyEasy), string(DifficultyMedium), string(DifficultyHard): - *d = Difficulty(s) - return nil - default: - return fmt.Errorf("invalid difficulty: %q", s) - } -} - func AllLevels() []Level { var levels []Level - for _, diffMap := range Registry { - for _, lvl := range diffMap { - levels = append(levels, lvl) - } + for _, lvl := range Registry { + levels = append(levels, lvl) } + slices.SortFunc(levels, func(i Level, j Level) int { + if i.Name < j.Name { + return -1 + } + if i.Name > j.Name { + return 1 + } + return 0 + }) return levels } diff --git a/internal/level/levels_test.go b/internal/level/levels_test.go index ce84f16..7fe0680 100644 --- a/internal/level/levels_test.go +++ b/internal/level/levels_test.go @@ -19,12 +19,12 @@ func TestLoadLevels(t *testing.T) { InitRegistry(levels) - lvl, err := GetLevel("Metrics System", DifficultyHard) + lvl, err := GetLevelByID("metrics-system") if err != nil { - t.Fatalf("expected to retrieve Metrics System (hard), got %v", err) + t.Fatalf("expected to retrieve metrics-system, got %v", err) } - if lvl.Difficulty != DifficultyHard { - t.Errorf("unexpected difficulty: got %s, want %s", lvl.Difficulty, DifficultyHard) + if lvl.ID != "metrics-system" { + t.Errorf("unexpected level ID: got %s, want %s", lvl.ID, "metrics-system") } } diff --git a/router/handlers/chat.go b/router/handlers/chat.go index 0b5d769..b0a296c 100644 --- a/router/handlers/chat.go +++ b/router/handlers/chat.go @@ -1,7 +1,96 @@ package handlers -import "net/http" +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + + "github.com/gorilla/websocket" + claude "github.com/potproject/claude-sdk-go" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +type MessageReceived struct { + Message string `json:"message"` + DesignPayload string `json:"designPayload"` +} func Messages(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket upgrade failed: %v", err) + return + } + defer conn.Close() + + client := claude.NewClient(os.Getenv("CLAUDE_API_KEY")) + + for { + messageType, message, err := conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("WebSocket error: %v", err) + } + break + } + + fmt.Printf("message: %s", message) + + var messageReceived MessageReceived + err = json.Unmarshal(message, &messageReceived) + if err != nil { + fmt.Printf("error unmarshalling response: %v", err) + continue + } + + if messageReceived.Message == "" { + messageReceived.Message = "" + } else { + messageReceived.Message = string(message) + } + + prompt := fmt.Sprintf("You are a tutor that helps people learn system design. You will be given a JSON payload that looks like %s. The nodes are the components a user can put into their design and the connections will tell you how they are connected. The level name identifies what problem they are working on as well as a difficulty level. Each level has an easy, medium or hard setting. Also in the payload, there is a list of components that a user can use to build their design. Your hints and responses should only refer to these components and not refer to things that the user cannot use. Always refer to the nodes by their type. Please craft your response as if you're talking to the user. And do not reference the payload as \"payload\" but as their design. Also, please do not show the payload in your response. Do not refer to components as node-0 or whatever. Always refer to the type of component they are. Always assume that the source of traffic for any system is a user. The user component will not be visible in teh payload. Also make sure you use html to format your answer. Do not over format your response. Only use p tags. Format lists using proper lists html. Anytime the user sends a different payload back to you, make note of what is correct. Never give the actual answer, only helpful hints. If the available components do not allow the user to feasibly solve the system design problem, you should mention it and then tell them what exactly is missing from the list.", messageReceived.DesignPayload) + + m := claude.RequestBodyMessages{ + Model: "claude-3-7-sonnet-20250219", + MaxTokens: 1024, + SystemTypeText: []claude.RequestBodySystemTypeText{ + claude.UseSystemCacheEphemeral(prompt), + }, + Messages: []claude.RequestBodyMessagesMessages{ + { + Role: claude.MessagesRoleUser, + ContentTypeText: []claude.RequestBodyMessagesMessagesContentTypeText{ + { + Text: messageReceived.Message, + CacheControl: claude.UseCacheEphemeral(), + }, + }, + }, + }, + } + + ctx := context.Background() + res, err := client.CreateMessages(ctx, m) + if err != nil { + fmt.Printf("error creating messages: %v", err) + } + + // Echo the message back to client + err = conn.WriteMessage(messageType, []byte(res.Content[0].Text)) + if err != nil { + log.Printf("Write error: %v", err) + break + } + } + log.Println("Client disconnected") } diff --git a/router/handlers/game.go b/router/handlers/game.go index 91d1f57..aae43b5 100644 --- a/router/handlers/game.go +++ b/router/handlers/game.go @@ -1,10 +1,11 @@ package handlers import ( + "encoding/json" + "fmt" + "html" "html/template" "net/http" - "net/url" - "strings" "systemdesigngame/internal/auth" "systemdesigngame/internal/level" ) @@ -14,32 +15,36 @@ type PlayHandler struct { } func (h *PlayHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - levelName := strings.TrimPrefix(r.URL.Path, "/play/") - levelName, err := url.PathUnescape(levelName) - if err != nil { - http.Error(w, "Invalid level name", http.StatusBadRequest) - return - } + levelId := r.PathValue("levelId") username := r.Context().Value(auth.UserLoginKey).(string) avatar := r.Context().Value(auth.UserAvatarKey).(string) - lvl, err := level.GetLevel(strings.ToLower(levelName), level.DifficultyEasy) + lvl, err := level.GetLevelByID(levelId) if err != nil { http.Error(w, "Level not found: "+err.Error(), http.StatusNotFound) return } + levelPayload, err := json.Marshal(lvl) + unescapedHtml := html.UnescapeString(string(levelPayload)) + fmt.Printf("raw message: %v", string(json.RawMessage(unescapedHtml))) + if err != nil { + fmt.Printf("error marshaling level: %v", err) + } + allLevels := level.AllLevels() data := struct { - Levels []level.Level - Level *level.Level - Avatar string - Username string + LevelPayload template.JS + Levels []level.Level + Level *level.Level + Avatar string + Username string }{ - Levels: allLevels, - Level: lvl, - Avatar: avatar, - Username: username, + LevelPayload: template.JS(levelPayload), + Levels: allLevels, + Level: lvl, + Avatar: avatar, + Username: username, } h.Tmpl.ExecuteTemplate(w, "game.html", data) diff --git a/router/handlers/simulation.go b/router/handlers/simulation.go index d5bce73..47b5994 100644 --- a/router/handlers/simulation.go +++ b/router/handlers/simulation.go @@ -29,9 +29,8 @@ func (h *SimulationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } var requestBody struct { - Design design.Design `json:"design"` - LevelName string `json:"levelName,omitempty"` - Difficulty string `json:"difficulty,omitempty"` + Design design.Design `json:"design"` + LevelID string `json:"levelId,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { @@ -96,13 +95,8 @@ func (h *SimulationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var feedback []string var levelName string - if requestBody.LevelName != "" { - difficulty := level.DifficultyEasy // default - if requestBody.Difficulty != "" { - difficulty = level.Difficulty(requestBody.Difficulty) - } - - if lvl, err := level.GetLevel(requestBody.LevelName, difficulty); err == nil { + if requestBody.LevelID != "" { + if lvl, err := level.GetLevelByID(requestBody.LevelID); err == nil { levelName = lvl.Name passed, score, feedback = validateLevel(lvl, design, metrics) } else { diff --git a/router/router.go b/router/router.go index 1601fd5..7c6af19 100644 --- a/router/router.go +++ b/router/router.go @@ -14,7 +14,7 @@ func SetupRoutes(tmpl *template.Template) *http.ServeMux { mux.Handle("/", &handlers.HomeHandler{Tmpl: tmpl}) mux.Handle("/mode", auth.RequireAuth(&handlers.PlayHandler{Tmpl: tmpl})) - mux.Handle("/play/", auth.RequireAuth(&handlers.PlayHandler{Tmpl: tmpl})) + mux.Handle("/play/{levelId}", auth.RequireAuth(&handlers.PlayHandler{Tmpl: tmpl})) mux.Handle("/simulate", auth.RequireAuth(&handlers.SimulationHandler{})) mux.HandleFunc("/login", auth.LoginHandler) mux.HandleFunc("/callback", auth.CallbackHandler) diff --git a/static/app.js b/static/app.js index eef7b0c..10d9018 100644 --- a/static/app.js +++ b/static/app.js @@ -38,12 +38,41 @@ export class CanvasApp { this.computeGroup = document.getElementById('compute-group'); this.lbGroup = document.getElementById('lb-group'); this.mqGroup = document.getElementById('mq-group'); - + this.startChatBtn = document.getElementById('start-chat'); + this.chatElement = document.getElementById('chat-box'); + this.chatTextField = document.getElementById('chat-message-box'); + this.chatMessages = document.getElementById('messages'); + this.chatLoadingIndicator = document.getElementById('loading-indicator'); + this.level = window.levelData; + this.ws = null; + this.plugins = PluginRegistry.getAll() + this.createDesignBtn = document.getElementById('create-design-button'); + this.learnMoreBtn = document.getElementById('learn-more-button'); + this.tabs = document.getElementsByClassName('tabinput'); + + console.log(this.tabs) + this._reconnectDelay = 1000; + this._maxReconnectDelay = 15000; + this._reconnectTimer = null; this.initEventHandlers(); } initEventHandlers() { + const requirementstab = this.tabs[1]; + const designtab = this.tabs[1]; + const resourcestab = this.tabs[2]; + + this.learnMoreBtn.addEventListener('click', () => { + requirementstab.checked = false; + resourcestab.checked = true; + }); + + this.createDesignBtn.addEventListener('click', () => { + requirementstab.checked = false; + designtab.checked = true; + }); + this.arrowToolBtn.addEventListener('click', () => { this.arrowMode = !this.arrowMode; if (this.arrowMode) { @@ -57,6 +86,68 @@ export class CanvasApp { } } }); + this.startChatBtn.addEventListener('click', () => { + const scheme = location.protocol === "https:" ? "wss://" : "ws://"; + + this.ws = new WebSocket(scheme + location.host + "/ws"); + + this.ws.onopen = () => { + this.ws.send(JSON.stringify({ + 'designPayload': JSON.stringify(this.exportDesign()), + 'message': '' + })); + } + + this.ws.onmessage = (e) => { + this.chatLoadingIndicator.style.display = 'none'; + this.chatTextField.disabled = false; + this.chatTextField.focus(); + const message = document.createElement('p'); + message.innerHTML = e.data; + message.className = "other"; + this.chatMessages.insertBefore(message, this.chatLoadingIndicator) + } + + this.ws.onerror = (err) => { + console.log("ws error:", err); + this._scheduleReconnect() + }; + + this.ws.onclose = () => { + console.log("leaving chat...") + this.ws = null; + this._sentJoin = false; + delete this.players[this.pageData.username] + this._scheduleReconnect() + } + + + }) + this.chatTextField.addEventListener('keydown', (e) => { + + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + console.log('you sent a message') + + const message = document.createElement('p'); + message.innerHTML = this.chatTextField.value; + message.className = "me"; + this.chatMessages.insertBefore(message, this.chatLoadingIndicator); + + + this.ws.send(JSON.stringify({ + 'message': this.chatTextField.value, + 'designPayload': JSON.stringify(this.exportDesign()), + })); + + this.chatTextField.value = ''; + this.chatLoadingIndicator.style.display = 'block'; + } + + }) + // start a ws connection + // onopen, send the payload + this.sidebar.addEventListener('dragstart', (e) => { const type = e.target.getAttribute('data-type'); const plugin = PluginRegistry.get(type); @@ -93,21 +184,21 @@ export class CanvasApp { this.runButton.addEventListener('click', async () => { const designData = this.exportDesign(); - + // Try to get level info from URL or page context const levelInfo = this.getLevelInfo(); - + const requestBody = { design: designData, ...levelInfo }; - + console.log('Sending design to simulation:', JSON.stringify(requestBody)); - + // Disable button and show loading state this.runButton.disabled = true; this.runButton.textContent = 'Running Simulation...'; - + try { const response = await fetch('/simulate', { method: 'POST', @@ -116,13 +207,13 @@ export class CanvasApp { }, body: JSON.stringify(requestBody) }); - + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - + const result = await response.json(); - + if (result.Success) { console.log('Simulation successful:', result); this.showResults(result); @@ -130,7 +221,7 @@ export class CanvasApp { console.error('Simulation failed:', result.Error); this.showError(result.Error || 'Simulation failed'); } - + } catch (error) { console.error('Network error:', error); this.showError('Failed to run simulation: ' + error.message); @@ -310,17 +401,21 @@ export class CanvasApp { capacity: c.capacity || 1000 })); - return { nodes, connections }; + return { + nodes, + connections, + level: JSON.parse(this.level), + availableComponents: JSON.stringify(this.plugins) + }; } getLevelInfo() { // Try to extract level info from URL path like /play/url-shortener const pathParts = window.location.pathname.split('/'); if (pathParts.length >= 3 && pathParts[1] === 'play') { - const levelName = decodeURIComponent(pathParts[2]); + const levelId = decodeURIComponent(pathParts[2]); return { - levelName: levelName, - difficulty: 'easy' // Default difficulty, could be enhanced later + levelId: levelId }; } return {}; @@ -329,7 +424,7 @@ export class CanvasApp { showResults(result) { const metrics = result.Metrics; let message = ''; - + // Level validation results if (result.LevelName) { if (result.Passed) { @@ -339,7 +434,7 @@ export class CanvasApp { message += `Level "${result.LevelName}" FAILED\n`; message += `Score: ${result.Score}/100\n\n`; } - + // Add detailed feedback if (result.Feedback && result.Feedback.length > 0) { message += result.Feedback.join('\n') + '\n\n'; @@ -347,7 +442,7 @@ export class CanvasApp { } else { message += `Simulation Complete!\n\n`; } - + // Performance metrics message += `Performance Metrics:\n`; message += `• Throughput: ${metrics.throughput} req/sec\n`; @@ -355,9 +450,9 @@ export class CanvasApp { message += `• Availability: ${metrics.availability.toFixed(1)}%\n`; message += `• Monthly Cost: $${metrics.cost_monthly}\n\n`; message += `Timeline: ${result.Timeline.length} ticks simulated`; - + alert(message); - + // TODO: Later replace with redirect to results page or modal console.log('Full simulation data:', result); } @@ -365,4 +460,25 @@ export class CanvasApp { showError(errorMessage) { alert(`Simulation Error:\n\n${errorMessage}\n\nPlease check your design and try again.`); } + + _scheduleReconnect() { + if (this._stopped) return; + + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer) + this._reconnectTimer = null; + } + + const jitter = this._reconnectDelay * (Math.random() * 0.4 - 0.2); + const delay = Math.max(250, Math.min(this._maxReconnectDelay, this._reconnectDelay + jitter)); + console.log(`Reconnecting websocket...`) + + this._reconnectTimer = setTimeout(() => { + this._reconnectTimer = null; + this._initWebSocket(); + }, delay); + + this._reconnectDelay = Math.min(this._maxReconnectDelay, Math.round(this._reconnectDelay * 1.8)); + } + } diff --git a/static/canvas.html b/static/canvas.html index 54c2f17..4f83953 100644 --- a/static/canvas.html +++ b/static/canvas.html @@ -1,14 +1,14 @@ {{ define "canvas" }}
- - - + + +
- +
@@ -45,6 +45,11 @@
{{ end }} + +
+ + +
diff --git a/static/challenges.html b/static/challenges.html index bda51b4..8b87aa4 100644 --- a/static/challenges.html +++ b/static/challenges.html @@ -1,12 +1,10 @@ {{ define "challenges" }}
- diff --git a/static/chat.html b/static/chat.html index bc7f8df..9b457c2 100644 --- a/static/chat.html +++ b/static/chat.html @@ -1,7 +1,6 @@ {{ define "chat" }}