Browse Source

Simplify levels and adjust routes to use level id instead of level name

main
Stephanie Gredell 5 months ago
parent
commit
5cb4052c49
  1. 314
      data/levels.json
  2. 4
      go.mod
  3. 8
      go.sum
  4. 61
      internal/level/level.go
  5. 8
      internal/level/levels_test.go
  6. 91
      router/handlers/chat.go
  7. 23
      router/handlers/game.go
  8. 12
      router/handlers/simulation.go
  9. 2
      router/router.go
  10. 126
      static/app.js
  11. 13
      static/canvas.html
  12. 6
      static/challenges.html
  13. 22
      static/chat.html
  14. 4
      static/game.html
  15. 4
      static/pluginRegistry.js
  16. 23
      static/style.css

314
data/levels.json

@ -1,37 +1,8 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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"
]
}
]

4
go.mod

@ -6,12 +6,16 @@ toolchain go1.23.10 @@ -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
)

8
go.sum

@ -4,8 +4,16 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA @@ -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=

61
internal/level/level.go

@ -4,14 +4,13 @@ import ( @@ -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"`
TargetRPS int `json:"targetRps"`
DurationSec int `json:"durationSec"`
@ -36,15 +35,7 @@ type Level struct { @@ -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) { @@ -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]
if !ok {
return nil, fmt.Errorf("level name %s not found", name)
}
lvl, ok := diffMap[string(difficulty)]
func GetLevelByID(id string) (*Level, error) {
lvl, ok := Registry[id]
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 {
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
}

8
internal/level/levels_test.go

@ -19,12 +19,12 @@ func TestLoadLevels(t *testing.T) { @@ -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")
}
}

91
router/handlers/chat.go

@ -1,7 +1,96 @@ @@ -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 = "<user did not send text>"
} 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")
}

23
router/handlers/game.go

@ -1,10 +1,11 @@ @@ -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,28 +15,32 @@ type PlayHandler struct { @@ -14,28 +15,32 @@ 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 {
LevelPayload template.JS
Levels []level.Level
Level *level.Level
Avatar string
Username string
}{
LevelPayload: template.JS(levelPayload),
Levels: allLevels,
Level: lvl,
Avatar: avatar,

12
router/handlers/simulation.go

@ -30,8 +30,7 @@ func (h *SimulationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -30,8 +30,7 @@ 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"`
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) { @@ -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 {

2
router/router.go

@ -14,7 +14,7 @@ func SetupRoutes(tmpl *template.Template) *http.ServeMux { @@ -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)

126
static/app.js

@ -38,12 +38,41 @@ export class CanvasApp { @@ -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 { @@ -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);
@ -310,17 +401,21 @@ export class CanvasApp { @@ -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 {};
@ -365,4 +460,25 @@ export class CanvasApp { @@ -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));
}
}

13
static/canvas.html

@ -1,14 +1,14 @@ @@ -1,14 +1,14 @@
{{ define "canvas" }}
<div id="canvas-wrapper">
<input type="radio" id="tab1" name="tab" checked>
<input type="radio" id="tab2" name="tab">
<input type="radio" id="tab3" name="tab">
<input class="tabinput" type="radio" id="tab1" name="tab" checked>
<input class="tabinput" type="radio" id="tab2" name="tab">
<input class="tabinput" type="radio" id="tab3" name="tab">
<div class="tabs">
<div class="tab-labels">
<label for="tab1">Requirements</label>
<label for="tab2">Design</label>
<label for="tab3">Metrics</label>
<label for="tab3">Resources</label>
</div>
<!-- Requirements -->
@ -45,6 +45,11 @@ @@ -45,6 +45,11 @@
</ul>
</div>
{{ end }}
<div class="continue-section">
<button class="continue-button" id="create-design-button">Create your design</button>
<button class="continue-button" id="learn-more-button">Learn more</button>
</div>
</div>
<!-- Design-->

6
static/challenges.html

@ -1,12 +1,10 @@ @@ -1,12 +1,10 @@
{{ define "challenges" }}
<div id="challenge-container">
<h2 class="sidebar-title">Challenges</h2>
<ul class="challenge-list">
{{range .Levels}}
<li class="challenge-item {{if and (eq .Name $.Level.Name) (eq .Difficulty $.Level.Difficulty)}}active{{end}}">
<div class="challenge-name">{{.Name}}</div>
<div class="challenge-difficulty {{.Difficulty}}">{{.Difficulty}}</div>
<li class="challenge-item {{if eq .ID $.Level.ID}}active{{end}}">
<div class="challenge-name"><a href="/play/{{.ID}}">{{.Name}}</a></div>
</li>
{{end}}
</ul>

22
static/chat.html

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
{{ define "chat" }}
<label for="chat-checkbox">
<div aria-label="Send message" id="start-chat">
<svg class="chat-bubble" width="32" height="32" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"
fill="none" stroke="white" stroke-width="4">
<path
@ -11,28 +10,13 @@ @@ -11,28 +10,13 @@
</div>
</label>
<input type="checkbox" name="chat-checkbox" id="chat-checkbox" class="chat-checkbox" />
<div class="chat">
<div class="chat" id="chat-box">
<div id="chat-header">
<p class="chat-title">System Design Assistant</p>
<p class="powered-by">Powered by AI</p>
</div>
<section id="messages">
<p class="me">
1
</p>
<p class="other">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Autem doloremque exercitationem, blanditiis
obcaecati
earum recusandae quia laudantium assumenda nihil mollitia velit eos molestias odio quasi facilis suscipit
rem
nulla sapiente ea voluptatum repudiandae dicta enim ut! Sed perferendis aliquid vel ad incidunt? In sit id
voluptatibus fugit voluptates, architecto sequi.
</p>
<p class="me">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Ex beatae vero sit delectus, rem totam molestias.
Officia, suscipit error. Voluptatibus.
</p>
<div class="loading-indicator">
<div class="loading-indicator" id="loading-indicator">
<div class="loading-dots">
<div class="loading-dot"></div>
<div class="loading-dot"></div>
@ -42,7 +26,7 @@ @@ -42,7 +26,7 @@
</div>
</section>
<footer>
<textarea name="chat-message" placeholder="Type your message here..."></textarea>
<textarea name="chat-message" placeholder="Type your message here..." disabled id="chat-message-box"></textarea>
<button aria-label="Send message">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor">

4
static/game.html

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Design Game</title>
<link rel="stylesheet" type="text/css" href="/static/style.css" />
</head>
@ -17,6 +18,9 @@ @@ -17,6 +18,9 @@
{{ template "canvas" . }}
{{ template "chat" . }}
<script>
window.levelData = {{.LevelPayload}};
</script>
<script type="module" src="/static/index.js"></script>
</body>

4
static/pluginRegistry.js

@ -8,4 +8,8 @@ export class PluginRegistry { @@ -8,4 +8,8 @@ export class PluginRegistry {
static get(type) {
return this.plugins[type];
}
static getAll() {
return Object.keys(this.plugins)
}
}

23
static/style.css

@ -343,7 +343,8 @@ input[type="number"] { @@ -343,7 +343,8 @@ input[type="number"] {
}
#node-props-save,
#run-button {
#run-button,
.continue-button {
margin-top: 8px;
padding: 10px;
background-color: var(--color-button);
@ -359,6 +360,16 @@ input[type="number"] { @@ -359,6 +360,16 @@ input[type="number"] {
}
}
.continue-section {
display: flex;
flex-direction: row;
gap: 30px;
}
.continue-button {
width: 30%;
}
#github-login-btn {
display: inline-flex;
align-items: center;
@ -497,9 +508,12 @@ input[name="tab"] { @@ -497,9 +508,12 @@ input[name="tab"] {
}
}
.challenge-name {
.challenge-name a:link,
.challenge-name a:visited {
font-weight: 500;
margin-bottom: 5px;
color: #fff;
text-decoration: none;
}
.challenge-difficulty {
@ -630,6 +644,8 @@ input[name="tab"] { @@ -630,6 +644,8 @@ input[name="tab"] {
overflow-y: auto;
scrollbar-width: var(--_scrollbar_width);
scrollbar-color: rgb(33, 38, 45) transparent;
height: 52vh;
width: 400px;
&::-webkit-scrollbar {
width: var(--_scrollbar_width);
@ -644,7 +660,8 @@ input[name="tab"] { @@ -644,7 +660,8 @@ input[name="tab"] {
border-radius: 2px;
}
p {
p.other,
p.me {
--_border_width: 2px;
--_border_radius: 8px;
max-width: 85%;

Loading…
Cancel
Save