diff --git a/debug.log b/debug.log new file mode 100644 index 0000000..a348e8d --- /dev/null +++ b/debug.log @@ -0,0 +1 @@ +no Go files in /Users/stephaniegredell/projects/systemdesigngame diff --git a/internal/level/levels_test.go b/internal/level/levels_test.go index 7669e2c..ce84f16 100644 --- a/internal/level/levels_test.go +++ b/internal/level/levels_test.go @@ -1,8 +1,6 @@ package level import ( - "fmt" - "os" "path/filepath" "testing" ) @@ -10,10 +8,6 @@ import ( func TestLoadLevels(t *testing.T) { path := filepath.Join("..", "..", "data", "levels.json") - cwd, _ := os.Getwd() - fmt.Println("Current working directory: ", cwd) - fmt.Println("loading path: ", path) - levels, err := LoadLevels(path) if err != nil { t.Fatalf("failed to load levels.json: %v", err) diff --git a/internal/simulation/cachenode.go b/internal/simulation/cachenode.go deleted file mode 100644 index 370bcc2..0000000 --- a/internal/simulation/cachenode.go +++ /dev/null @@ -1,115 +0,0 @@ -package simulation - -import ( - "math/rand" - "sort" -) - -type CacheEntry struct { - Value interface{} - Timestamp int - ExpireAt int - AccessCount int -} - -type CacheNode struct { - ID string - Label string - CacheTTL int - MaxEntries int - EvictionPolicy string - CurrentLoad int - Queue []*Request - Cache map[string]CacheEntry - Alive bool - Targets []string - Output []*Request -} - -func (c *CacheNode) GetID() string { - return c.ID -} - -func (c *CacheNode) Type() string { - return "cache" -} - -func (c *CacheNode) IsAlive() bool { - return c.Alive -} - -func (c *CacheNode) Tick(tick int, currentTimeMs int) { - for key, entry := range c.Cache { - if currentTimeMs > entry.ExpireAt { - delete(c.Cache, key) - } - } - - if len(c.Cache) > c.MaxEntries { - evictCount := len(c.Cache) - c.MaxEntries - keys := make([]string, 0, len(c.Cache)) - for k := range c.Cache { - keys = append(keys, k) - } - - switch c.EvictionPolicy { - case "Random": - rand.Shuffle(len(keys), func(i, j int) { keys[i], keys[j] = keys[j], keys[i] }) - case "LRU": - sort.Slice(keys, func(i, j int) bool { - return c.Cache[keys[i]].Timestamp < c.Cache[keys[j]].Timestamp - }) - case "LFU": - sort.Slice(keys, func(i, j int) bool { - return c.Cache[keys[i]].AccessCount < c.Cache[keys[j]].AccessCount - }) - } - - for i := 0; i < evictCount && i < len(keys); i++ { - delete(c.Cache, keys[i]) - } - } - - toProcess := min(len(c.Queue), 10) - for i := 0; i < toProcess; i++ { - req := c.Queue[i] - if entry, found := c.Cache[req.ID]; found && currentTimeMs <= entry.ExpireAt { - // Cache hit - req.LatencyMS += 2 - req.Path = append(req.Path, c.ID+"(hit)") - } else { - // Cache miss - req.LatencyMS += 5 - req.Path = append(req.Path, c.ID+"(miss)") - c.Cache[req.ID] = CacheEntry{ - Value: req, - Timestamp: currentTimeMs, - ExpireAt: currentTimeMs + c.CacheTTL*1000, - } - c.Output = append(c.Output, req) - } - } - c.Queue = c.Queue[toProcess:] -} - -func (c *CacheNode) Receive(req *Request) { - c.Queue = append(c.Queue, req) -} - -func (c *CacheNode) Emit() []*Request { - out := append([]*Request(nil), c.Output...) - c.Output = c.Output[:0] - return out -} - -func (c *CacheNode) AddTarget(targetID string) { - c.Targets = append(c.Targets, targetID) -} - -func (c *CacheNode) GetTargets() []string { - return c.Targets -} - -func (n *CacheNode) GetQueue() []*Request { - return n.Queue -} diff --git a/internal/simulation/cdn.go b/internal/simulation/cdn.go new file mode 100644 index 0000000..c1ce05d --- /dev/null +++ b/internal/simulation/cdn.go @@ -0,0 +1,22 @@ +package simulation + +import "math/rand" + +type CDNLogic struct{} + +func (c CDNLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { + hitRate := AsFloat64("hitRate") + var output []*Request + + for _, req := range queue { + if rand.Float64() < hitRate { + continue + } + + reqCopy := *req + reqCopy.Path = append(reqCopy.Path, "target-0") + output = append(output, &reqCopy) + } + + return output, true +} diff --git a/internal/simulation/cdnnode.go b/internal/simulation/cdnnode.go deleted file mode 100644 index af75b25..0000000 --- a/internal/simulation/cdnnode.go +++ /dev/null @@ -1,93 +0,0 @@ -package simulation - -import ( - "math/rand" -) - -type CDNNode struct { - ID string - Label string - TTL int - GeoReplication string - CachingStrategy string - Compression string - HTTP2 string - CacheHitRate float64 - CurrentLoad int - Queue []*Request - EdgeNodes map[string]*CDNNode - Alive bool - Targets []string - output []*Request - missQueue []*Request -} - -func (n *CDNNode) GetID() string { return n.ID } -func (n *CDNNode) Type() string { return "cdn" } -func (n *CDNNode) IsAlive() bool { return n.Alive } -func (n *CDNNode) QueueState() []*Request { return n.Queue } - -func (n *CDNNode) Tick(tick int, currentTimeMs int) { - if len(n.Queue) == 0 { - return - } - - maxProcessPerTick := 10 - processCount := min(len(n.Queue), maxProcessPerTick) - - queue := n.Queue - n.Queue = n.Queue[:0] - - for i := 0; i < processCount; i++ { - req := queue[i] - - hitRate := n.CacheHitRate - if hitRate == 0 { - hitRate = 0.8 - } - - if rand.Float64() < hitRate { - // Cache HIT - req.LatencyMS += 5 - req.Path = append(req.Path, n.ID) - n.output = append(n.output, req) - } else { - // Cache MISS - req.LatencyMS += 10 - req.Path = append(req.Path, n.ID+"(miss)") - n.missQueue = append(n.missQueue, req) - } - } - - if len(queue) > processCount { - n.Queue = append(n.Queue, queue[processCount:]...) - } -} - -func (n *CDNNode) Receive(req *Request) { - if req == nil { - return - } - - geoLatency := rand.Intn(20) + 5 // 5–25ms routing delay - req.LatencyMS += geoLatency - - n.Queue = append(n.Queue, req) -} - -func (n *CDNNode) Emit() []*Request { - out := append(n.output, n.missQueue...) - - n.output = n.output[:0] - n.missQueue = n.missQueue[:0] - - return out -} - -func (n *CDNNode) GetTargets() []string { - return n.Targets -} - -func (n *CDNNode) GetQueue() []*Request { - return n.Queue -} diff --git a/internal/simulation/databasenode.go b/internal/simulation/databasenode.go deleted file mode 100644 index 80a6e0f..0000000 --- a/internal/simulation/databasenode.go +++ /dev/null @@ -1,83 +0,0 @@ -package simulation - -type DatabaseNode struct { - ID string - Label string - Replication int - CurrentLoad int - Queue []*Request - Replicas []*DatabaseNode - Alive bool - Targets []string - Output []*Request - ReplicationQueue []*Request -} - -func (n *DatabaseNode) GetID() string { return n.ID } -func (n *DatabaseNode) Type() string { return "database" } -func (n *DatabaseNode) IsAlive() bool { return n.Alive } -func (n *DatabaseNode) GetQueue() []*Request { return n.Queue } - -func (n *DatabaseNode) Tick(tick int, currentTimeMs int) { - if len(n.Queue) == 0 { - return - } - - maxProcessPerTick := 3 - processCount := min(len(n.Queue), maxProcessPerTick) - - queue := n.Queue - n.Queue = n.Queue[:0] - - for i := 0; i < processCount; i++ { - req := queue[i] - - if req.Type == "READ" { - req.LatencyMS += 20 - req.Path = append(req.Path, n.ID) - n.Output = append(n.Output, req) - } else { - req.LatencyMS += 50 - req.Path = append(req.Path, n.ID) - - for _, replica := range n.Replicas { - replicationReq := &Request{ - ID: req.ID + "-repl", - Timestamp: req.Timestamp, - LatencyMS: req.LatencyMS + 5, - Origin: req.Origin, - Type: "REPLICATION", - Path: append(append([]string{}, req.Path...), "->"+replica.ID), - } - n.ReplicationQueue = append(n.ReplicationQueue, replicationReq) - } - - n.Output = append(n.Output, req) - } - } - - if len(n.Queue) > 10 { - for _, req := range n.Queue { - req.LatencyMS += 10 - } - } -} - -func (n *DatabaseNode) Receive(req *Request) { - if req == nil { - return - } - req.LatencyMS += 2 // DB connection overhead - n.Queue = append(n.Queue, req) -} - -func (n *DatabaseNode) Emit() []*Request { - out := append(n.Output, n.ReplicationQueue...) - n.Output = n.Output[:0] - n.ReplicationQueue = n.ReplicationQueue[:0] - return out -} - -func (n *DatabaseNode) GetTargets() []string { - return n.Targets -} diff --git a/internal/simulation/datapipelinenode.go b/internal/simulation/datapipelinenode.go deleted file mode 100644 index 02e1142..0000000 --- a/internal/simulation/datapipelinenode.go +++ /dev/null @@ -1,114 +0,0 @@ -package simulation - -import ( - "math/rand" -) - -type DataPipelineNode struct { - ID string - Label string - BatchSize int - Transformation string - CurrentLoad int - Queue []*Request - Alive bool - Targets []string - Output []*Request - LastFlushTimeMS int -} - -func (n *DataPipelineNode) GetID() string { return n.ID } - -func (n *DataPipelineNode) Tick(tick int, currentTimeMs int) { - if len(n.Queue) == 0 { - return - } - - if len(n.Queue) < n.BatchSize { - if n.LastFlushTimeMS == 0 { - n.LastFlushTimeMS = currentTimeMs - } - if currentTimeMs-n.LastFlushTimeMS >= 5000 { - n.processBatch(len(n.Queue), currentTimeMs) - n.LastFlushTimeMS = currentTimeMs - } - return - } - - batchesToProcess := len(n.Queue) / n.BatchSize - maxBatchesPerTick := 2 - actualBatches := min(batchesToProcess, maxBatchesPerTick) - - for i := 0; i < actualBatches; i++ { - n.processBatch(n.BatchSize, currentTimeMs) - n.LastFlushTimeMS = currentTimeMs - } -} - -func (n *DataPipelineNode) processBatch(size int, currentTimeMs int) { - if size == 0 { - return - } - - batch := n.Queue[:size] - n.Queue = n.Queue[size:] - batchLatency := 100 + (size * 5) - - for _, req := range batch { - req.LatencyMS += batchLatency - req.Path = append(req.Path, n.ID+"(batch)") - - switch n.Transformation { - case "aggregate": - req.Type = "AGGREGATED" - n.Output = append(n.Output, req) - case "filter": - if rand.Float64() < 0.9 { - n.Output = append(n.Output, req) - } - continue - case "enrich": - req.Type = "ENRICHED" - n.Output = append(n.Output, req) - case "normalize": - req.Type = "NORMALIZED" - n.Output = append(n.Output, req) - case "dedupe": - if rand.Float64() < 0.95 { - req.Type = "DEDUPED" - n.Output = append(n.Output, req) - } - continue - default: - n.Output = append(n.Output, req) - } - } -} - -func (n *DataPipelineNode) Receive(req *Request) { - if req == nil { - return - } - req.LatencyMS += 1 - n.Queue = append(n.Queue, req) - - if n.LastFlushTimeMS == 0 { - n.LastFlushTimeMS = req.Timestamp - } -} - -func (n *DataPipelineNode) Emit() []*Request { - out := append([]*Request(nil), n.Output...) - n.Output = n.Output[:0] - return out -} - -func (n *DataPipelineNode) Type() string { return "datapipeline" } -func (n *DataPipelineNode) IsAlive() bool { return n.Alive } -func (n *DataPipelineNode) GetTargets() []string { - return n.Targets -} - -func (n *DataPipelineNode) GetQueue() []*Request { - return n.Queue -} diff --git a/internal/simulation/engine.go b/internal/simulation/engine.go index cff7621..c65593a 100644 --- a/internal/simulation/engine.go +++ b/internal/simulation/engine.go @@ -2,10 +2,22 @@ package simulation import ( "fmt" - "math/rand" "systemdesigngame/internal/design" ) +type NodeLogic interface { + Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) +} + +type NodeInstance struct { + ID string + Type string + Props map[string]any + Queue []*Request + Alive bool + Logic NodeLogic +} + // a unit that flows through the system type Request struct { ID string @@ -21,305 +33,149 @@ type Request struct { Path []string } -// Every node implements this interface and is used by the engine to operate all nodes in a uniform way. -type SimulationNode interface { - GetID() string - Type() string - Tick(tick int, currentTimeMs int) // Advance the node's state - Receive(req *Request) // Accept new requests - Emit() []*Request - IsAlive() bool - GetTargets() []string // Connection to other nodes - GetQueue() []*Request // Requests currently pending -} - -type Engine struct { - // Map of Node ID -> actual node, Represents all nodes in the graph - Nodes map[string]SimulationNode - // all tick snapshots - Timeline []*TickSnapshot - // how many ticks to run - Duration int - // no used here but we could use it if we want it configurable - TickMs int -} - // what hte system looks like given a tick type TickSnapshot struct { TickMs int // Queue size at each node QueueSizes map[string]int - NodeHealth map[string]NodeState + NodeHealth map[string]bool // what each node output that tick before routing Emitted map[string][]*Request } -// used for tracking health/debugging each node at tick -type NodeState struct { - QueueSize int - Alive bool +type SimulationEngine struct { + Nodes map[string]*NodeInstance + Edges map[string][]string + TickMS int + EntryNode string // this should be the node ID where traffic should enter + RPS int // how many requests per second should be injected while running } -// Takes a level design and produces a runnable engine from it. -func NewEngineFromDesign(design design.Design, duration int, tickMs int) *Engine { +func NewEngineFromDesign(d design.Design, tickMS int) *SimulationEngine { + nodes := make(map[string]*NodeInstance) + edges := make(map[string][]string) - // Iterate over each nodes and then construct the simulation nodes - // Each constructed simulation node is then stored in the nodeMap - nodeMap := make(map[string]SimulationNode) + for _, n := range d.Nodes { + logic := GetLogicForType(n.Type) - for _, n := range design.Nodes { - var simNode SimulationNode - - switch n.Type { - case "webserver": - simNode = &WebServerNode{ - ID: n.ID, - Alive: true, - Queue: []*Request{}, - } - case "loadBalancer": - props := n.Props - simNode = &LoadBalancerNode{ - ID: n.ID, - Label: asString(props["label"]), - Algorithm: asString(props["algorithm"]), - Queue: []*Request{}, - Alive: true, - Targets: []string{}, - } - case "cache": - simNode = &CacheNode{ - ID: n.ID, - Label: asString(n.Props["label"]), - CacheTTL: int(asFloat64(n.Props["cacheTTL"])), - MaxEntries: int(asFloat64(n.Props["maxEntries"])), - EvictionPolicy: asString(n.Props["evictionPolicy"]), - CurrentLoad: 0, - Queue: []*Request{}, - Cache: make(map[string]CacheEntry), - Alive: true, - } - case "database": - simNode = &DatabaseNode{ - ID: n.ID, - Label: asString(n.Props["label"]), - Replication: int(asFloat64(n.Props["replication"])), - Queue: []*Request{}, - Alive: true, - } - case "cdn": - simNode = &CDNNode{ - ID: n.ID, - Label: asString(n.Props["label"]), - TTL: int(asFloat64(n.Props["ttl"])), - GeoReplication: asString(n.Props["geoReplication"]), - CachingStrategy: asString(n.Props["cachingStrategy"]), - Compression: asString(n.Props["compression"]), - HTTP2: asString(n.Props["http2"]), - Queue: []*Request{}, - Alive: true, - output: []*Request{}, - missQueue: []*Request{}, - } - case "messageQueue": - simNode = &MessageQueueNode{ - ID: n.ID, - Label: asString(n.Props["label"]), - QueueSize: int(asFloat64(n.Props["maxSize"])), - MessageTTL: int(asFloat64(n.Props["retentionSeconds"])), - DeadLetter: false, - Queue: []*Request{}, - Alive: true, - } - case "microservice": - simNode = &MicroserviceNode{ - ID: n.ID, - Label: asString(n.Props["label"]), - APIEndpoint: asString(n.Props["apiVersion"]), - RateLimit: int(asFloat64(n.Props["rpsCapacity"])), - CircuitBreaker: true, - Queue: []*Request{}, - CircuitState: "closed", - Alive: true, - } - case "third party service": - simNode = &ThirdPartyServiceNode{ - ID: n.ID, - Label: asString(n.Props["label"]), - APIEndpoint: asString(n.Props["provider"]), - RateLimit: int(asFloat64(n.Props["latency"])), - RetryPolicy: "exponential", - Queue: []*Request{}, - Alive: true, - } - case "data pipeline": - simNode = &DataPipelineNode{ - ID: n.ID, - Label: asString(n.Props["label"]), - BatchSize: int(asFloat64(n.Props["batchSize"])), - Transformation: asString(n.Props["transformation"]), - Queue: []*Request{}, - Alive: true, - } - case "monitoring/alerting": - simNode = &MonitoringNode{ - ID: n.ID, - Label: asString(n.Props["label"]), - Tool: asString(n.Props["tool"]), - AlertMetric: asString(n.Props["metric"]), - ThresholdValue: int(asFloat64(n.Props["threshold"])), - ThresholdUnit: asString(n.Props["unit"]), - Queue: []*Request{}, - Alive: true, - } - default: + if logic == nil { continue } - if simNode != nil { - nodeMap[simNode.GetID()] = simNode + // create a NodeInstance using data from the json + nodes[n.ID] = &NodeInstance{ + ID: n.ID, + Type: n.Type, + Props: n.Props, + Queue: []*Request{}, + Alive: true, + Logic: logic, } } - // Wire up connections - for _, conn := range design.Connections { - if sourceNode, ok := nodeMap[conn.Source]; ok { - if targetSetter, ok := sourceNode.(interface{ AddTarget(string) }); ok { - targetSetter.AddTarget(conn.Target) - } - } + // build a map of the connections (edges) + for _, c := range d.Connections { + edges[c.Source] = append(edges[c.Source], c.Target) } - return &Engine{ - Nodes: nodeMap, - Duration: duration, - TickMs: tickMs, + return &SimulationEngine{ + Nodes: nodes, + Edges: edges, + TickMS: tickMS, + RPS: 0, // ideally, this will come from the design (serialized json) + EntryNode: "", // default to empty, we check this later in the run method } } -func (e *Engine) Run() { - // clear and set defaults - const tickMS = 100 - currentTimeMs := 0 - e.Timeline = e.Timeline[:0] - - // start ticking. This is really where the simulation begins - for tick := 0; tick < e.Duration; tick++ { +func (e *SimulationEngine) Run(duration int, tickMs int) []*TickSnapshot { + snapshots := []*TickSnapshot{} + currentTime := 0 - // find the entry points (where traffic enters) or else print a warning - entries := e.findEntryPoints() - if len(entries) == 0 { - fmt.Println("[ERROR] No entry points found! Simulation will not inject requests.") - } + for tick := 0; tick < duration; tick++ { + if e.RPS > 0 && e.EntryNode != "" { + count := int(float64(e.RPS) * float64(e.TickMS) / 1000.0) + reqs := make([]*Request, count) - // inject new requests of each entry node every tick - for _, node := range entries { - if shouldInject(tick) { - req := &Request{ - ID: generateRequestID(tick), - Timestamp: currentTimeMs, - LatencyMS: 0, - Origin: node.GetID(), + for i := 0; i < count; i++ { + reqs[i] = &Request{ + ID: fmt.Sprintf("req-%d-%d", tick, i), + Origin: e.EntryNode, Type: "GET", - Path: []string{node.GetID()}, + Timestamp: tick * e.TickMS, + Path: []string{e.EntryNode}, } - node.Receive(req) } + + node := e.Nodes[e.EntryNode] + node.Queue = append(node.Queue, reqs...) } - // snapshot to record what happened this tick + emitted := map[string][]*Request{} snapshot := &TickSnapshot{ TickMs: tick, - NodeHealth: make(map[string]NodeState), + QueueSizes: map[string]int{}, + NodeHealth: map[string]bool{}, + Emitted: map[string][]*Request{}, } for id, node := range e.Nodes { - // capture health data before processing - snapshot.NodeHealth[id] = NodeState{ - QueueSize: len(node.GetQueue()), - Alive: node.IsAlive(), + // if the node is not alive, don't even bother. + if !node.Alive { + continue } - // tick all nodes - node.Tick(tick, currentTimeMs) - - // get all processed requets and fan it out to all connected targets - for _, req := range node.Emit() { - snapshot.Emitted[node.GetID()] = append(snapshot.Emitted[node.GetID()], req) - - for _, targetID := range node.GetTargets() { - if target, ok := e.Nodes[targetID]; ok && target.IsAlive() && !hasVisited(req, targetID) { - // Deep copy request and update path - newReq := *req - newReq.Path = append([]string{}, req.Path...) - newReq.Path = append(newReq.Path, targetID) - target.Receive(&newReq) - } + // this will preopulate some props so that we can use different load balancing algorithms + if node.Type == "loadbalancer" && node.Props["algorithm"] == "least-connection" { + queueSizes := make(map[string]interface{}) + for _, targetID := range e.Edges[id] { + queueSizes[targetID] = len(e.Nodes[targetID].Queue) } + node.Props["_queueSizes"] = queueSizes } - } + // simulate the node. outputs is the emitted requests (request post-processing) and alive tells you if the node is healthy + outputs, alive := node.Logic.Tick(node.Props, node.Queue, tick) - // store the snapshot and advance time - e.Timeline = append(e.Timeline, snapshot) - currentTimeMs += tickMS - } -} + // at this point, all nodes have ticked. record the emitted requests + emitted[id] = outputs -func (e *Engine) findEntryPoints() []SimulationNode { - var entries []SimulationNode - for _, node := range e.Nodes { - if node.Type() == "loadBalancer" { - entries = append(entries, node) - } - } - return entries -} + // clear the queue after processing. Queues should only contain requests for the next tick that need processing. + node.Queue = nil -func (e *Engine) injectRequests(entries []SimulationNode, requests []*Request) { - for i, req := range requests { - node := entries[i%len(entries)] - node.Receive(req) - } -} + // update if the node is still alive + node.Alive = alive -func shouldInject(tick int) bool { - return tick%100 == 0 -} - -func generateRequestID(tick int) string { - return fmt.Sprintf("req-%d-%d", tick, rand.Intn(1000)) -} + // populate snapshot + snapshot.QueueSizes[id] = len(node.Queue) + snapshot.NodeHealth[id] = node.Alive + snapshot.Emitted[id] = outputs + } -func asFloat64(v interface{}) float64 { - switch val := v.(type) { - case float64: - return val - case int: - return float64(val) - case int64: - return float64(val) - case float32: - return float64(val) - default: - return 0 - } -} + // iterate over each emitted request + for from, reqs := range emitted { + // figure out where those requests should go to + for _, to := range e.Edges[from] { + // add those requests to the target node's input (queue) + e.Nodes[to].Queue = append(e.Nodes[to].Queue, reqs...) + } + } -func asString(v interface{}) string { - s, ok := v.(string) - if !ok { - return "" + snapshots = append(snapshots, snapshot) + currentTime += e.TickMS } - return s + return snapshots } -func hasVisited(req *Request, nodeID string) bool { - for _, id := range req.Path { - if id == nodeID { - return true - } +func GetLogicForType(t string) NodeLogic { + switch t { + case "webserver": + return WebServerLogic{} + case "loadbalancer": + return LoadBalancerLogic{} + case "cdn": + return CDNLogic{} + default: + return nil } - return false } diff --git a/internal/simulation/engine_test.go b/internal/simulation/engine_test.go index d69fa2c..adb3a48 100644 --- a/internal/simulation/engine_test.go +++ b/internal/simulation/engine_test.go @@ -1,130 +1,107 @@ package simulation import ( - "fmt" - "os" - "path/filepath" "testing" - "encoding/json" "systemdesigngame/internal/design" ) -func TestNewEngineFromDesign(t *testing.T) { - designInput := &design.Design{ +func TestSimpleChainSimulation(t *testing.T) { + d := design.Design{ Nodes: []design.Node{ - { - ID: "web1", - Type: "webserver", - Props: map[string]interface{}{ - "cpu": float64(2), - "ramGb": float64(4), - "rpsCapacity": float64(100), - "monthlyCostUsd": float64(20), - }, - }, - { - ID: "cache1", - Type: "cache", - Props: map[string]interface{}{ - "label": "L1 Cache", - "cacheTTL": float64(60), - "maxEntries": float64(1000), - "evictionPolicy": "LRU", - }, - }, + {ID: "a", Type: "webserver", Props: map[string]any{"capacityRPS": 1, "baseLatencyMs": 10}}, + {ID: "b", Type: "webserver", Props: map[string]any{"capacityRPS": 1, "baseLatencyMs": 10}}, }, Connections: []design.Connection{ - { - Source: "web1", - Target: "cache1", - }, + {Source: "a", Target: "b"}, }, } - engine := NewEngineFromDesign(*designInput, 10, 100) + engine := NewEngineFromDesign(d, 100) - if len(engine.Nodes) != 2 { - t.Fatalf("expected 2 nodes, got %d", len(engine.Nodes)) + engine.Nodes["a"].Queue = append(engine.Nodes["a"].Queue, &Request{ + ID: "req-1", + Origin: "a", + Type: "GET", + Timestamp: 0, + Path: []string{"a"}, + }) + + snaps := engine.Run(2, 100) + + if len(snaps) != 2 { + t.Fatalf("expected 2 snapshots, got %d", len(snaps)) } - if len(engine.Nodes["web1"].GetTargets()) != 1 { - t.Fatalf("expected web1 to have 1 target, got %d", len(engine.Nodes["web1"].GetTargets())) + if len(snaps[0].Emitted["a"]) != 1 { + t.Errorf("expected a to emit 1 request at tick 0") + } + if snaps[0].QueueSizes["b"] != 0 { + t.Errorf("expected b's queue to be 0 after tick 0 (not yet processed)") } - if engine.Nodes["web1"].GetTargets()[0] != "cache1" { - t.Fatalf("expected web1 target to be cache1") + if len(snaps[1].Emitted["b"]) != 1 { + t.Errorf("expected b to emit 1 request at tick 1") } } -func TestComplexSimulationRun(t *testing.T) { - filePath := filepath.Join("testdata", "complex_design.json") - data, err := os.ReadFile(filePath) - if err != nil { - t.Fatalf("Failed to read JSON file: %v", err) +func TestSingleTickRouting(t *testing.T) { + d := design.Design{ + Nodes: []design.Node{ + {ID: "a", Type: "webserver", Props: map[string]any{"capacityRPS": 1.0, "baseLatencyMs": 10.0}}, + {ID: "b", Type: "webserver", Props: map[string]any{"capacityRPS": 1.0, "baseLatencyMs": 10.0}}, + }, + Connections: []design.Connection{ + {Source: "a", Target: "b"}, + }, } - var d design.Design - if err := json.Unmarshal([]byte(data), &d); err != nil { - t.Fatalf("Failed to unmarshal JSON: %v", err) - } + engine := NewEngineFromDesign(d, 100) - engine := NewEngineFromDesign(d, 10, 100) - if engine == nil { - t.Fatal("Engine should not be nil") - } + engine.Nodes["a"].Queue = append(engine.Nodes["a"].Queue, &Request{ + ID: "req-1", + Origin: "a", + Type: "GET", + Timestamp: 0, + Path: []string{"a"}, + }) - engine.Run() + snaps := engine.Run(1, 100) - if len(engine.Timeline) == 0 { - t.Fatal("Expected timeline snapshots after Run, got none") + if len(snaps) != 1 { + t.Fatalf("expected 1 snapshot, got %d", len(snaps)) } - // Optional: check that some nodes received or emitted requests - for id, node := range engine.Nodes { - if len(node.Emit()) > 0 { - t.Logf("Node %s has activity", id) - } + if len(snaps[0].Emitted["a"]) != 1 { + t.Errorf("expected a to emit 1 request, got %d", len(snaps[0].Emitted["a"])) } -} -func TestSimulationRunEndToEnd(t *testing.T) { - data, err := os.ReadFile("testdata/simple_design.json") - fmt.Print(data) - if err != nil { - t.Fatalf("Failed to read test data: %v", err) + if len(engine.Nodes["b"].Queue) != 1 { + t.Errorf("expected b to have 1 request queued for next tick, got %d", len(engine.Nodes["b"].Queue)) } +} - var design design.Design - if err := json.Unmarshal(data, &design); err != nil { - t.Fatalf("Failed to unmarshal JSON: %v", err) +func TestHighRPSSimulation(t *testing.T) { + d := design.Design{ + Nodes: []design.Node{ + {ID: "entry", Type: "webserver", Props: map[string]any{"capacityRPS": 5000, "baseLatencyMs": 1}}, + }, + Connections: []design.Connection{}, } - engine := NewEngineFromDesign(design, 20, 100) // 20 ticks, 100ms per tick - engine.Run() - - if len(engine.Timeline) != 20 { - t.Errorf("Expected 20 timeline entries, got %d", len(engine.Timeline)) - } + engine := NewEngineFromDesign(d, 100) + engine.EntryNode = "entry" + engine.RPS = 100000 - anyTraffic := false - for _, snapshot := range engine.Timeline { - for _, nodeState := range snapshot.NodeHealth { - if nodeState.QueueSize > 0 { - anyTraffic = true - break - } - } - } + snaps := engine.Run(10, 100) - if !anyTraffic { - t.Errorf("Expected at least one node to have non-zero queue size over time") + totalEmitted := 0 + for _, snap := range snaps { + totalEmitted += len(snap.Emitted["entry"]) } - // Optional: check a few expected node IDs - for _, id := range []string{"node-1", "node-2"} { - if _, ok := engine.Nodes[id]; !ok { - t.Errorf("Expected node %s to be present in simulation", id) - } + expected := 10 * 5000 // capacity-limited output + if totalEmitted != expected { + t.Errorf("expected %d total emitted requests, got %d", expected, totalEmitted) } } diff --git a/internal/simulation/loadbalancer.go b/internal/simulation/loadbalancer.go index 72b00be..95a6e52 100644 --- a/internal/simulation/loadbalancer.go +++ b/internal/simulation/loadbalancer.go @@ -1,91 +1,64 @@ package simulation import ( - "math/rand" + "fmt" ) -type LoadBalancerNode struct { - // unique identifier for the node - ID string - // human readable name - Label string - // load balancing strategy - Algorithm string - // list of incoming requests to be processed - Queue []*Request - // IDs of downstream nodes (e.g. webservers) - Targets []string - // use to track round-robin state (i.e. which target is next) - Counter int - // bool for health check - Alive bool - // requests that this node has handled (ready to be emitted) - Processed []*Request -} +type LoadBalancerLogic struct{} -func (lb *LoadBalancerNode) GetID() string { - return lb.ID -} +func (l LoadBalancerLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { + // Extract the load balancing algorithm from the props. + algorithm := AsString(props["algorithm"]) + // Number of downstream targets + targets := int(AsFloat64(props["_numTargets"])) -func (lb *LoadBalancerNode) Type() string { - return "loadBalancer" -} - -func (lb *LoadBalancerNode) IsAlive() bool { - return lb.Alive -} - -// Acceps an incoming request by adding it to the Queue which will be processed on the next tick -func (lb *LoadBalancerNode) Receive(req *Request) { - lb.Queue = append(lb.Queue, req) -} + if len(queue) == 0 { + return nil, true + } -func (lb *LoadBalancerNode) Tick(tick int, currentTimeMs int) { - // clear out the process so it starts fresh - lb.Processed = nil + // Hold the processed requests to be emitted + output := []*Request{} - // for each pending request... - for _, req := range lb.Queue { - // if there are no targets to forward to, skip processing - if len(lb.Targets) == 0 { - continue + switch algorithm { + case "least-connection": + // extrat current queue sizes from downstream targets + queueSizesRaw, ok := props["_queueSizes"].(map[string]interface{}) + if !ok { + return nil, true } - // placeholder for algorithm-specific logic. TODO. - switch lb.Algorithm { - case "random": - fallthrough - case "round-robin": - fallthrough - default: - lb.Counter++ + // find target with smallest queue + for _, req := range queue { + minTarget := "target-0" + minSize := int(AsFloat64(queueSizesRaw[minTarget])) + for i := 1; i < targets; i++ { + targetKey := fmt.Sprintf("target-%d", i) + size := int(AsFloat64(queueSizesRaw[targetKey])) + if size < minSize { + minTarget = targetKey + minSize = size + } + } + + // Clone the request and append the selected target to its path + reqCopy := *req + reqCopy.Path = append(reqCopy.Path, minTarget) + output = append(output, &reqCopy) + } + default: + // Retrieve the last used index + next := int(AsFloat64(props["_rrIndex"])) + for _, req := range queue { + // Clone ther equest and append the selected target to its path + reqCopy := *req + reqCopy.Path = append(reqCopy.Path, fmt.Sprintf("target-%d", next)) + output = append(output, &reqCopy) + // Advance to next target + next = (next + 1) % targets } - // Append the load balancer's ID to the request's path to record it's journey through the system - req.Path = append(req.Path, lb.ID) - - // Simulate networking delay - req.LatencyMS += 10 - - // Mark the request as processed so it can be emitted to targets - lb.Processed = append(lb.Processed, req) + props["_rrIndex"] = float64(next) } - // clear the queue after processing. Ready for next tick. - lb.Queue = lb.Queue[:0] -} - -// return the list of process requests and then clear the processed requests -func (lb *LoadBalancerNode) Emit() []*Request { - out := lb.Processed - lb.Processed = nil - return out -} - -func (lb *LoadBalancerNode) GetTargets() []string { - return lb.Targets -} - -func (lb *LoadBalancerNode) GetQueue() []*Request { - return lb.Queue + return output, true } diff --git a/internal/simulation/loadbalancer_test.go b/internal/simulation/loadbalancer_test.go new file mode 100644 index 0000000..23099dc --- /dev/null +++ b/internal/simulation/loadbalancer_test.go @@ -0,0 +1,70 @@ +package simulation + +import ( + "systemdesigngame/internal/design" + "testing" +) + +func TestLoadBalancerAlgorithms(t *testing.T) { + t.Run("round-rouble", func(t *testing.T) { + d := design.Design{ + Nodes: []design.Node{ + {ID: "lb", Type: "loadbalancer", Props: map[string]any{"algorithm": "round-robin"}}, + {ID: "a", Type: "webserver", Props: map[string]any{"capacityRPS": 10}}, + {ID: "b", Type: "webserver", Props: map[string]any{"capacityRPS": 10}}, + }, + Connections: []design.Connection{ + {Source: "lb", Target: "a"}, + {Source: "lb", Target: "b"}, + }, + } + + e := NewEngineFromDesign(d, 100) + e.EntryNode = "lb" + e.RPS = 4 + + snaps := e.Run(1, 100) + if len(snaps[0].Emitted["lb"]) != 4 { + t.Errorf("expected lb to emit 4 requests") + } + + path0 := snaps[0].Emitted["lb"][0].Path[1] + path1 := snaps[0].Emitted["lb"][1].Path[1] + + if path0 == path1 { + t.Errorf("expecting alternating targets, got %s and %s", path0, path1) + } + }) + + t.Run("least-connection", func(t *testing.T) { + d := design.Design{ + Nodes: []design.Node{ + {ID: "lb", Type: "loadbalancer", Props: map[string]any{"algorithm": "least-connection"}}, + {ID: "a", Type: "webserver", Props: map[string]any{"capacityRPS": 1}}, + {ID: "b", Type: "webserver", Props: map[string]any{"capacityRPS": 1}}, + }, + Connections: []design.Connection{ + {Source: "lb", Target: "a"}, + {Source: "lb", Target: "b"}, + }, + } + + e := NewEngineFromDesign(d, 100) + e.EntryNode = "lb" + e.RPS = 2 + + snaps := e.Run(1, 100) + if len(snaps[0].Emitted["lb"]) != 2 { + t.Errorf("expected lb to emit 2 requests") + } + + paths := []string{ + snaps[0].Emitted["lb"][0].Path[1], + snaps[0].Emitted["lb"][1].Path[1], + } + + if paths[0] == paths[1] { + t.Errorf("expected requests to be balanced, go %v", paths) + } + }) +} diff --git a/internal/simulation/messagequeuenode.go b/internal/simulation/messagequeuenode.go deleted file mode 100644 index 50fe648..0000000 --- a/internal/simulation/messagequeuenode.go +++ /dev/null @@ -1,101 +0,0 @@ -package simulation - -type MessageQueueNode struct { - ID string - Label string - QueueSize int - MessageTTL int // TTL in milliseconds - DeadLetter bool - CurrentLoad int - Queue []*Request - Alive bool - Targets []string - output []*Request - deadLetterOutput []*Request -} - -func (n *MessageQueueNode) GetID() string { return n.ID } -func (n *MessageQueueNode) Type() string { return "messagequeue" } -func (n *MessageQueueNode) IsAlive() bool { return n.Alive } - -func (n *MessageQueueNode) Tick(tick int, currentTimeMs int) { - if len(n.Queue) == 0 { - return - } - - // Message queues have very high throughput - maxProcessPerTick := 20 // Higher than database (3) or CDN (10) - processCount := min(len(n.Queue), maxProcessPerTick) - - // Check for queue overflow (simulate back pressure) - if len(n.Queue) > n.QueueSize { - // Move oldest messages to dead letter queue if enabled - if n.DeadLetter { - overflow := len(n.Queue) - n.QueueSize - for i := 0; i < overflow; i++ { - deadReq := n.Queue[i] - deadReq.Type = "DEAD_LETTER" - deadReq.Path = append(deadReq.Path, n.ID+"(dead)") - n.deadLetterOutput = append(n.deadLetterOutput, deadReq) - } - n.Queue = n.Queue[overflow:] - } else { - // Drop messages if no dead letter queue - n.Queue = n.Queue[:n.QueueSize] - } - } - - // Process messages with TTL check - for i := 0; i < processCount; i++ { - req := n.Queue[0] - n.Queue = n.Queue[1:] - - // Check TTL (time to live) - use current time in milliseconds - messageAgeMs := currentTimeMs - req.Timestamp - if messageAgeMs > n.MessageTTL { - // Message expired - if n.DeadLetter { - req.Type = "EXPIRED" - req.Path = append(req.Path, n.ID+"(expired)") - n.deadLetterOutput = append(n.deadLetterOutput, req) - } - // Otherwise drop expired message - continue - } - - // Message queue adds minimal latency (very fast) - req.LatencyMS += 2 - req.Path = append(req.Path, n.ID) - n.output = append(n.output, req) - } -} - -func (n *MessageQueueNode) Receive(req *Request) { - if req == nil { - return - } - - // Message queues have very low receive overhead - req.LatencyMS += 1 - - n.Queue = append(n.Queue, req) -} - -func (n *MessageQueueNode) Emit() []*Request { - // Return both normal messages and dead letter messages - allRequests := append(n.output, n.deadLetterOutput...) - - // Clear queues - n.output = n.output[:0] - n.deadLetterOutput = n.deadLetterOutput[:0] - - return allRequests -} - -func (n *MessageQueueNode) GetTargets() []string { - return n.Targets -} - -func (n *MessageQueueNode) GetQueue() []*Request { - return n.Queue -} diff --git a/internal/simulation/microservicenode.go b/internal/simulation/microservicenode.go deleted file mode 100644 index b47028b..0000000 --- a/internal/simulation/microservicenode.go +++ /dev/null @@ -1,75 +0,0 @@ -package simulation - -import "math/rand" - -type MicroserviceNode struct { - ID string - Label string - APIEndpoint string - RateLimit int // max requests per tick - CircuitBreaker bool - CircuitState string // "closed", "open", "half-open" - ErrorCount int - CurrentLoad int - Queue []*Request - Output []*Request - Alive bool - Targets []string -} - -func (n *MicroserviceNode) GetID() string { return n.ID } - -func (n *MicroserviceNode) Type() string { return "microservice" } - -func (n *MicroserviceNode) IsAlive() bool { return n.Alive } - -func (n *MicroserviceNode) Receive(req *Request) { - n.Queue = append(n.Queue, req) -} - -func (n *MicroserviceNode) Emit() []*Request { - out := append([]*Request(nil), n.Output...) - n.Output = n.Output[:0] - return out -} - -func (n *MicroserviceNode) GetTargets() []string { - return n.Targets -} - -func (n *MicroserviceNode) Tick(tick int, currentTimeMs int) { - n.CurrentLoad = 0 - n.ErrorCount = 0 - n.Output = nil - - toProcess := min(len(n.Queue), n.RateLimit) - for i := 0; i < toProcess; i++ { - req := n.Queue[i] - - if rand.Float64() < 0.02 { - n.ErrorCount++ - if n.CircuitBreaker { - n.CircuitState = "open" - n.Alive = false - } - - continue - } - - req.LatencyMS += 10 - req.Path = append(req.Path, n.ID) - n.Output = append(n.Output, req) - n.CurrentLoad++ - } - - if n.CircuitState == "open" && tick%10 == 0 { - n.CircuitState = "closed" - n.Alive = true - } - - n.Queue = n.Queue[toProcess:] -} - -func (n *MicroserviceNode) GetQueue() []*Request { - return n.Queue -} diff --git a/internal/simulation/monitoringnode.go b/internal/simulation/monitoringnode.go deleted file mode 100644 index 052cd20..0000000 --- a/internal/simulation/monitoringnode.go +++ /dev/null @@ -1,73 +0,0 @@ -package simulation - -type MonitoringNode struct { - ID string - Label string - Tool string - AlertMetric string - ThresholdValue int - ThresholdUnit string - Queue []*Request - Alive bool - Targets []string - Metrics map[string]int - Alerts []*Request -} - -func (n *MonitoringNode) GetID() string { return n.ID } - -func (n *MonitoringNode) Type() string { return "monitoring" } - -func (n *MonitoringNode) IsAlive() bool { return n.Alive } - -func (n *MonitoringNode) Receive(req *Request) { - n.Queue = append(n.Queue, req) -} - -func (n *MonitoringNode) Emit() []*Request { - out := append([]*Request(nil), n.Alerts...) - n.Alerts = n.Alerts[:0] - return out -} - -func (n *MonitoringNode) Tick(tick int, currentTimeMs int) { - if !n.Alive { - return - } - - if n.Metrics == nil { - n.Metrics = make(map[string]int) - } - - // Simulate processing requests as metrics - for _, req := range n.Queue { - // For now, pretend all requests are relevant to the AlertMetric - n.Metrics[n.AlertMetric] += 1 - req.LatencyMS += 1 - req.Path = append(req.Path, n.ID) - - if n.Metrics[n.AlertMetric] > n.ThresholdValue { - alert := &Request{ - ID: "alert-" + req.ID, - Timestamp: currentTimeMs, - Origin: n.ID, - Type: "alert", - LatencyMS: 0, - Path: []string{n.ID, "alert"}, - } - n.Alerts = append(n.Alerts, alert) - - // Reset after alert (or you could continue accumulating) - n.Metrics[n.AlertMetric] = 0 - } - } - - n.Queue = nil -} -func (n *MonitoringNode) GetTargets() []string { - return n.Targets -} - -func (n *MonitoringNode) GetQueue() []*Request { - return n.Queue -} diff --git a/internal/simulation/testdata/simple_design.json b/internal/simulation/testdata/simple_design.json index c3fec4e..757c214 100644 --- a/internal/simulation/testdata/simple_design.json +++ b/internal/simulation/testdata/simple_design.json @@ -1,7 +1,7 @@ { "nodes": [ { - "id": "node-1", + "id": "loadbalancer", "type": "loadBalancer", "position": { "x": 0, "y": 0 }, "props": { @@ -10,19 +10,22 @@ } }, { - "id": "node-2", + "id": "webserver", "type": "webserver", "position": { "x": 100, "y": 0 }, "props": { "label": "Web Server", - "instanceSize": "medium" + "instanceSize": "medium", + "capacityRPS": 5, + "baseLatencyMs": 50, + "penaltyPerRPS": 10 } } ], "connections": [ { - "source": "node-1", - "target": "node-2", + "source": "loadbalancer", + "target": "webserver", "label": "Traffic", "direction": "forward", "protocol": "HTTP", diff --git a/internal/simulation/thirdpartyservicenode.go b/internal/simulation/thirdpartyservicenode.go deleted file mode 100644 index 3e354a3..0000000 --- a/internal/simulation/thirdpartyservicenode.go +++ /dev/null @@ -1,87 +0,0 @@ -package simulation - -import "math/rand" - -type ThirdPartyServiceNode struct { - ID string - Label string - APIEndpoint string - RateLimit int // Max requests per tick - RetryPolicy string // "exponential", "fixed", etc. - CurrentLoad int - Queue []*Request - ErrorCount int - RetryCount int - Alive bool - Targets []string - Output []*Request -} - -// --- Interface Methods --- - -func (n *ThirdPartyServiceNode) GetID() string { return n.ID } -func (n *ThirdPartyServiceNode) Type() string { return "thirdpartyservice" } -func (n *ThirdPartyServiceNode) IsAlive() bool { return n.Alive } -func (n *ThirdPartyServiceNode) Receive(req *Request) { - n.Queue = append(n.Queue, req) -} -func (n *ThirdPartyServiceNode) Emit() []*Request { - out := append([]*Request(nil), n.Output...) - n.Output = n.Output[:0] - return out -} - -// Add missing Queue method for interface compliance -func (n *ThirdPartyServiceNode) GetQueue() []*Request { - return n.Queue -} - -// --- Simulation Logic --- - -func (n *ThirdPartyServiceNode) Tick(tick int, currentTimeMs int) { - if !n.Alive { - return - } - - // Simulate third-party call behavior with success/failure - maxProcess := min(n.RateLimit, len(n.Queue)) - newQueue := n.Queue[maxProcess:] - n.Queue = nil - - for i := 0; i < maxProcess; i++ { - req := newQueue[i] - success := simulateThirdPartySuccess(req) - - if success { - req.LatencyMS += 100 + randInt(0, 50) // simulate response time - req.Path = append(req.Path, n.ID) - n.Output = append(n.Output, req) - } else { - n.ErrorCount++ - n.RetryCount++ - if n.RetryPolicy == "exponential" && n.RetryCount < 3 { - n.Queue = append(n.Queue, req) // retry again next tick - } - } - } - - // Simulate degradation if too many errors - if n.ErrorCount > 10 { - n.Alive = false - } -} - -func (n *ThirdPartyServiceNode) GetTargets() []string { - return n.Targets -} - -// --- Helpers --- - -func simulateThirdPartySuccess(req *Request) bool { - // 90% success rate - return randInt(0, 100) < 90 -} - -func randInt(min, max int) int { - return min + int(rand.Float64()*float64(max-min)) -} diff --git a/internal/simulation/util.go b/internal/simulation/util.go new file mode 100644 index 0000000..2541e17 --- /dev/null +++ b/internal/simulation/util.go @@ -0,0 +1,24 @@ +package simulation + +func AsFloat64(v interface{}) float64 { + switch val := v.(type) { + case float64: + return val + case int: + return float64(val) + case int64: + return float64(val) + case float32: + return float64(val) + default: + return 0 + } +} + +func AsString(v interface{}) string { + s, ok := v.(string) + if !ok { + return "" + } + return s +} diff --git a/internal/simulation/webserver.go b/internal/simulation/webserver.go new file mode 100644 index 0000000..c3a2d57 --- /dev/null +++ b/internal/simulation/webserver.go @@ -0,0 +1,27 @@ +package simulation + +// keep this stateless. Trying to store state here is a big mistake. +// This is meant to be a pure logic handler. +type WebServerLogic struct { +} + +func (l WebServerLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { + maxRPS := int(AsFloat64(props["capacityRPS"])) + + toProcess := queue + if len(queue) > maxRPS { + toProcess = queue[:maxRPS] + } + + var output []*Request + for _, req := range toProcess { + output = append(output, &Request{ + ID: req.ID, + Timestamp: req.Timestamp, + Origin: req.Origin, + Type: req.Type, + }) + } + + return output, true +} diff --git a/internal/simulation/webservernode.go b/internal/simulation/webservernode.go deleted file mode 100644 index 75b2b5d..0000000 --- a/internal/simulation/webservernode.go +++ /dev/null @@ -1,73 +0,0 @@ -package simulation - -type WebServerNode struct { - ID string - Queue []*Request - CapacityRPS int - BaseLatencyMs int - PenaltyPerRPS float64 - Processed []*Request - Alive bool - Targets []string -} - -func (ws *WebServerNode) GetID() string { - return ws.ID -} - -func (ws *WebServerNode) Type() string { - return "webserver" -} - -func (ws *WebServerNode) IsAlive() bool { - return ws.Alive -} - -func (ws *WebServerNode) Tick(tick int, currentTimeMs int) { - toProcess := min(ws.CapacityRPS, len(ws.Queue)) - for i := 0; i < toProcess; i++ { - req := ws.Queue[i] - req.LatencyMS += ws.BaseLatencyMs - ws.Processed = append(ws.Processed, req) - } - - // Remove processed requests from the queue - ws.Queue = ws.Queue[toProcess:] - - // Apply penalty for overload - if len(ws.Queue) > 0 { - overload := len(ws.Queue) - for _, req := range ws.Queue { - req.LatencyMS += int(ws.PenaltyPerRPS * float64(overload)) - } - } -} - -func (ws *WebServerNode) Receive(req *Request) { - ws.Queue = append(ws.Queue, req) -} - -func (ws *WebServerNode) Emit() []*Request { - out := ws.Processed - ws.Processed = nil - return out -} - -func (ws *WebServerNode) AddTarget(targetID string) { - ws.Targets = append(ws.Targets, targetID) -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func (ws *WebServerNode) GetQueue() []*Request { - return ws.Queue -} - -func (ws *WebServerNode) GetTargets() []string { - return ws.Targets -} diff --git a/router/handlers/simulation.go b/router/handlers/simulation.go index 181ef38..8d0ffac 100644 --- a/router/handlers/simulation.go +++ b/router/handlers/simulation.go @@ -4,7 +4,6 @@ import ( "encoding/json" "net/http" "systemdesigngame/internal/design" - "systemdesigngame/internal/simulation" ) type SimulationHandler struct{} @@ -28,9 +27,6 @@ func (h *SimulationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - engine := simulation.NewEngineFromDesign(design, 10000, 100) - engine.Run() - // For now, return a mock successful response but eventually, we want to go to the results page(s) response := SimulationResponse{ Success: true, diff --git a/static/game-mode.css b/static/game-mode.css new file mode 100644 index 0000000..a59896a --- /dev/null +++ b/static/game-mode.css @@ -0,0 +1,440 @@ +/* Reusing the existing CSS from the game */ +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&display=swap'); + +/* === CSS VARIABLES === */ +:root { + /* Colors */ + --color-bg-body: #161b22; + --color-bg-dark: #121212; + --color-bg-sidebar: #111; + --color-bg-component: #1e1e1e; + --color-bg-hover: #2a2a2a; + --color-bg-accent: #005f87; + --color-bg-tab-active: #1a3d2a; + --color-border: #444; + --color-border-accent: #00ff88; + --color-border-panel: #30363d; + --color-text-primary: #ccc; + --color-text-muted: #888; + --color-text-accent: #00ff88; + --color-text-white: #fff; + --color-text-dark: #333; + --color-button: #238636; + --color-button-disabled: #555; + --color-connection: #333; + --color-connection-selected: #007bff; + --color-tooltip-bg: #333; + --color-tooltip-text: #fff; + + /* Sizes */ + --radius-small: 4px; + --radius-medium: 6px; + --radius-large: 8px; + --font-family-mono: 'JetBrains Mono', monospace; + --font-family-code: 'Fira Code', monospace; + --component-padding: 8px; + --component-gap: 12px; +} + +/* === RESET & BASE STYLES === */ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: var(--font-family-mono); + background-color: var(--color-bg-body); + color: var(--color-text-primary); + min-height: 100vh; +} + +/* === LAYOUT === */ +#page-container { + display: flex; + flex-direction: column; + width: 100%; + min-height: 100vh; +} + +#sd-header { + width: 100%; + background: none; + padding: 12px 24px; + font-weight: bold; + color: var(--color-text-accent); + border-bottom: 1px solid var(--color-text-dark); + display: flex; + align-items: center; + justify-content: space-between; +} + +.header-text { + font-size: 24px; + margin: 0; +} + +#main-content { + display: flex; + flex-direction: column; + flex: 1; + padding: 60px 24px; + align-items: center; + background: radial-gradient(circle at 30% 50%, rgba(0, 255, 136, 0.1), transparent 50%), + radial-gradient(circle at 70% 80%, rgba(255, 107, 53, 0.1), transparent 50%); +} + +/* === REUSED EXISTING CLASSES === */ +.requirements-section { + background: #161b22; + border: 1px solid #30363d; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; +} + +.requirements-list { + margin: 0; + padding: 0; + list-style: none; +} + +.requirement-item { + position: relative; + padding: 8px 0 8px 25px; + margin: 0; + border-bottom: 1px solid #30363d; +} + +.requirement-item:before { + content: "✓"; + color: #00ff88; + position: absolute; + left: 0; +} + +.panel-title { + font-weight: bold; + color: var(--color-text-white); + font-size: 15px; + margin-bottom: 0.5rem; +} + +.panel-metric { + margin-bottom: 0.4rem; +} + +.panel-metric .label { + display: inline-block; + width: 140px; + color: var(--color-text-muted); +} + +#github-login-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background-color: #fff; + color: #000000; + text-decoration: none; + border-radius: var(--radius-medium); + font-weight: 500; + font-family: var(--font-family-mono); + font-size: 12px; + border: 1px solid #2ea043; + transition: background-color 0.2s ease; +} + +#github-login-btn:hover { + background-color: #ccc; +} + +/* === CUSTOM STYLES FOR GAME MODE SELECTION === */ +.hero-section { + text-align: center; + margin-bottom: 60px; +} + +.hero-section h1 { + font-size: 48px; + font-weight: 700; + margin: 0 0 20px 0; + background: linear-gradient(135deg, var(--color-text-accent), var(--color-connection-selected)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero-section p { + font-size: 18px; + color: var(--color-text-muted); + max-width: 600px; + margin: 0 auto; + line-height: 1.6; +} + +.game-mode-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 30px; + max-width: 1000px; + width: 100%; + margin-bottom: 60px; +} + +.game-mode-card { + background: var(--color-bg-component); + border: 2px solid var(--color-border); + border-radius: var(--radius-large); + padding: 30px; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.game-mode-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, transparent, rgba(255, 255, 255, 0.05)); + opacity: 0; + transition: opacity 0.3s ease; +} + +.game-mode-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); + background-color: var(--color-bg-hover); +} + +.game-mode-card:hover::before { + opacity: 1; +} + +.game-mode-card.campaign { + border-color: var(--color-border-accent); +} + +.game-mode-card.campaign:hover { + border-color: var(--color-border-accent); + box-shadow: 0 10px 30px rgba(0, 255, 136, 0.3); +} + +.game-mode-card.practice { + border-color: #ff6b35; +} + +.game-mode-card.practice:hover { + border-color: #ff6b35; + box-shadow: 0 10px 30px rgba(255, 107, 53, 0.3); +} + +.mode-header { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 25px; +} + +.mode-icon { + width: 60px; + height: 60px; + border-radius: var(--radius-medium); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + font-weight: bold; + background: var(--color-bg-dark); + border: 1px solid var(--color-border-panel); +} + +.game-mode-card.campaign .mode-icon { + color: var(--color-text-accent); + border-color: var(--color-border-accent); +} + +.game-mode-card.practice .mode-icon { + color: #ff6b35; + border-color: #ff6b35; +} + +.mode-title { + font-size: 28px; + font-weight: 700; + margin: 0 0 5px 0; + color: var(--color-text-white); +} + +.mode-subtitle { + font-size: 16px; + color: var(--color-text-muted); + margin: 0; +} + +.mode-features { + margin-bottom: 25px; +} + +.mode-features .requirements-list .requirement-item { + border-bottom: 1px solid var(--color-border-panel); +} + +.game-mode-card.campaign .mode-features .requirement-item:before { + color: var(--color-text-accent); +} + +.game-mode-card.practice .mode-features .requirement-item:before { + color: #ff6b35; +} + +.mode-progress { + background: var(--color-bg-dark); + border: 1px solid var(--color-border-panel); + border-radius: var(--radius-medium); + padding: 16px; + margin-bottom: 25px; +} + +.progress-bar { + background: var(--color-border); + height: 8px; + border-radius: var(--radius-small); + overflow: hidden; + margin-top: 8px; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--color-text-accent), var(--color-connection-selected)); + width: 33%; + border-radius: var(--radius-small); +} + +.recent-activity { + font-size: 14px; +} + +.recent-activity .activity-item { + margin-bottom: 4px; +} + +.recent-activity .activity-item.active { + color: #ff6b35; +} + +.recent-activity .activity-item.inactive { + color: var(--color-text-muted); +} + +.mode-button { + width: 100%; + padding: 15px; + border: none; + border-radius: var(--radius-medium); + font-size: 16px; + font-weight: 600; + font-family: var(--font-family-mono); + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 0.5px; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.game-mode-card.campaign .mode-button { + background: linear-gradient(90deg, var(--color-text-accent), var(--color-connection-selected)); + color: var(--color-bg-body); +} + +.game-mode-card.practice .mode-button { + background: linear-gradient(90deg, #ff6b35, #f7931e); + color: var(--color-text-white); +} + +.mode-button:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +/* === STATS GRID === */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + max-width: 800px; + width: 100%; +} + +.stat-card { + background: var(--color-bg-component); + border: 1px solid var(--color-border); + border-radius: var(--radius-large); + padding: 20px; + text-align: center; +} + +.stat-value { + font-size: 28px; + font-weight: 700; + margin-bottom: 8px; +} + +.stat-card:nth-child(1) .stat-value { + color: var(--color-text-accent); +} + +.stat-card:nth-child(2) .stat-value { + color: var(--color-connection-selected); +} + +.stat-card:nth-child(3) .stat-value { + color: #ff6b35; +} + +.stat-card:nth-child(4) .stat-value { + color: #f7931e; +} + +.stat-label { + font-size: 14px; + color: var(--color-text-muted); +} + +/* === RESPONSIVE === */ +@media (max-width: 1024px) { + .game-mode-grid { + grid-template-columns: 1fr; + gap: 20px; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .hero-section h1 { + font-size: 36px; + } + + .game-mode-card { + padding: 20px; + } + + #main-content { + padding: 40px 16px; + } + + .stats-grid { + grid-template-columns: 1fr; + } +} \ No newline at end of file