Browse Source

refactoring

pull/1/head
Stephanie Gredell 7 months ago
parent
commit
2c8ea2f31d
  1. 1
      debug.log
  2. 6
      internal/level/levels_test.go
  3. 115
      internal/simulation/cachenode.go
  4. 22
      internal/simulation/cdn.go
  5. 93
      internal/simulation/cdnnode.go
  6. 83
      internal/simulation/databasenode.go
  7. 114
      internal/simulation/datapipelinenode.go
  8. 348
      internal/simulation/engine.go
  9. 155
      internal/simulation/engine_test.go
  10. 113
      internal/simulation/loadbalancer.go
  11. 70
      internal/simulation/loadbalancer_test.go
  12. 101
      internal/simulation/messagequeuenode.go
  13. 75
      internal/simulation/microservicenode.go
  14. 73
      internal/simulation/monitoringnode.go
  15. 13
      internal/simulation/testdata/simple_design.json
  16. 87
      internal/simulation/thirdpartyservicenode.go
  17. 24
      internal/simulation/util.go
  18. 27
      internal/simulation/webserver.go
  19. 73
      internal/simulation/webservernode.go
  20. 4
      router/handlers/simulation.go
  21. 440
      static/game-mode.css

1
debug.log

@ -0,0 +1 @@
no Go files in /Users/stephaniegredell/projects/systemdesigngame

6
internal/level/levels_test.go

@ -1,8 +1,6 @@
package level package level
import ( import (
"fmt"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
) )
@ -10,10 +8,6 @@ import (
func TestLoadLevels(t *testing.T) { func TestLoadLevels(t *testing.T) {
path := filepath.Join("..", "..", "data", "levels.json") path := filepath.Join("..", "..", "data", "levels.json")
cwd, _ := os.Getwd()
fmt.Println("Current working directory: ", cwd)
fmt.Println("loading path: ", path)
levels, err := LoadLevels(path) levels, err := LoadLevels(path)
if err != nil { if err != nil {
t.Fatalf("failed to load levels.json: %v", err) t.Fatalf("failed to load levels.json: %v", err)

115
internal/simulation/cachenode.go

@ -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
}

22
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
}

93
internal/simulation/cdnnode.go

@ -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
}

83
internal/simulation/databasenode.go

@ -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
}

114
internal/simulation/datapipelinenode.go

@ -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
}

348
internal/simulation/engine.go

@ -2,10 +2,22 @@ package simulation
import ( import (
"fmt" "fmt"
"math/rand"
"systemdesigngame/internal/design" "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 // a unit that flows through the system
type Request struct { type Request struct {
ID string ID string
@ -21,305 +33,149 @@ type Request struct {
Path []string 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 // what hte system looks like given a tick
type TickSnapshot struct { type TickSnapshot struct {
TickMs int TickMs int
// Queue size at each node // Queue size at each node
QueueSizes map[string]int QueueSizes map[string]int
NodeHealth map[string]NodeState NodeHealth map[string]bool
// what each node output that tick before routing // what each node output that tick before routing
Emitted map[string][]*Request Emitted map[string][]*Request
} }
// used for tracking health/debugging each node at tick type SimulationEngine struct {
type NodeState struct { Nodes map[string]*NodeInstance
QueueSize int Edges map[string][]string
Alive bool 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(d design.Design, tickMS int) *SimulationEngine {
func NewEngineFromDesign(design design.Design, duration int, tickMs int) *Engine { 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 design.Nodes { for _, n := range d.Nodes {
var simNode SimulationNode logic := GetLogicForType(n.Type)
switch n.Type { if logic == nil {
case "webserver": continue
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{ // create a NodeInstance using data from the json
nodes[n.ID] = &NodeInstance{
ID: n.ID, ID: n.ID,
Label: asString(n.Props["label"]), Type: n.Type,
Tool: asString(n.Props["tool"]), Props: n.Props,
AlertMetric: asString(n.Props["metric"]),
ThresholdValue: int(asFloat64(n.Props["threshold"])),
ThresholdUnit: asString(n.Props["unit"]),
Queue: []*Request{}, Queue: []*Request{},
Alive: true, Alive: true,
} Logic: logic,
default:
continue
}
if simNode != nil {
nodeMap[simNode.GetID()] = simNode
} }
} }
// Wire up connections // build a map of the connections (edges)
for _, conn := range design.Connections { for _, c := range d.Connections {
if sourceNode, ok := nodeMap[conn.Source]; ok { edges[c.Source] = append(edges[c.Source], c.Target)
if targetSetter, ok := sourceNode.(interface{ AddTarget(string) }); ok {
targetSetter.AddTarget(conn.Target)
}
}
} }
return &Engine{ return &SimulationEngine{
Nodes: nodeMap, Nodes: nodes,
Duration: duration, Edges: edges,
TickMs: tickMs, 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() { func (e *SimulationEngine) Run(duration int, tickMs int) []*TickSnapshot {
// clear and set defaults snapshots := []*TickSnapshot{}
const tickMS = 100 currentTime := 0
currentTimeMs := 0
e.Timeline = e.Timeline[:0]
// start ticking. This is really where the simulation begins for tick := 0; tick < duration; tick++ {
for tick := 0; tick < e.Duration; tick++ { if e.RPS > 0 && e.EntryNode != "" {
count := int(float64(e.RPS) * float64(e.TickMS) / 1000.0)
// find the entry points (where traffic enters) or else print a warning reqs := make([]*Request, count)
entries := e.findEntryPoints()
if len(entries) == 0 {
fmt.Println("[ERROR] No entry points found! Simulation will not inject requests.")
}
// inject new requests of each entry node every tick for i := 0; i < count; i++ {
for _, node := range entries { reqs[i] = &Request{
if shouldInject(tick) { ID: fmt.Sprintf("req-%d-%d", tick, i),
req := &Request{ Origin: e.EntryNode,
ID: generateRequestID(tick),
Timestamp: currentTimeMs,
LatencyMS: 0,
Origin: node.GetID(),
Type: "GET", 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{ snapshot := &TickSnapshot{
TickMs: tick, 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 { for id, node := range e.Nodes {
// capture health data before processing // if the node is not alive, don't even bother.
snapshot.NodeHealth[id] = NodeState{ if !node.Alive {
QueueSize: len(node.GetQueue()), continue
Alive: node.IsAlive(),
} }
// tick all nodes // this will preopulate some props so that we can use different load balancing algorithms
node.Tick(tick, currentTimeMs) if node.Type == "loadbalancer" && node.Props["algorithm"] == "least-connection" {
queueSizes := make(map[string]interface{})
// get all processed requets and fan it out to all connected targets for _, targetID := range e.Edges[id] {
for _, req := range node.Emit() { queueSizes[targetID] = len(e.Nodes[targetID].Queue)
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)
}
} }
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 // at this point, all nodes have ticked. record the emitted requests
e.Timeline = append(e.Timeline, snapshot) emitted[id] = outputs
currentTimeMs += tickMS
}
}
func (e *Engine) findEntryPoints() []SimulationNode {
var entries []SimulationNode
for _, node := range e.Nodes {
if node.Type() == "loadBalancer" {
entries = append(entries, node)
}
}
return entries
}
func (e *Engine) injectRequests(entries []SimulationNode, requests []*Request) { // clear the queue after processing. Queues should only contain requests for the next tick that need processing.
for i, req := range requests { node.Queue = nil
node := entries[i%len(entries)]
node.Receive(req)
}
}
func shouldInject(tick int) bool { // update if the node is still alive
return tick%100 == 0 node.Alive = alive
}
func generateRequestID(tick int) string { // populate snapshot
return fmt.Sprintf("req-%d-%d", tick, rand.Intn(1000)) snapshot.QueueSizes[id] = len(node.Queue)
snapshot.NodeHealth[id] = node.Alive
snapshot.Emitted[id] = outputs
} }
func asFloat64(v interface{}) float64 { // iterate over each emitted request
switch val := v.(type) { for from, reqs := range emitted {
case float64: // figure out where those requests should go to
return val for _, to := range e.Edges[from] {
case int: // add those requests to the target node's input (queue)
return float64(val) e.Nodes[to].Queue = append(e.Nodes[to].Queue, reqs...)
case int64:
return float64(val)
case float32:
return float64(val)
default:
return 0
} }
} }
func asString(v interface{}) string { snapshots = append(snapshots, snapshot)
s, ok := v.(string) currentTime += e.TickMS
if !ok {
return ""
} }
return s return snapshots
} }
func hasVisited(req *Request, nodeID string) bool { func GetLogicForType(t string) NodeLogic {
for _, id := range req.Path { switch t {
if id == nodeID { case "webserver":
return true return WebServerLogic{}
} case "loadbalancer":
return LoadBalancerLogic{}
case "cdn":
return CDNLogic{}
default:
return nil
} }
return false
} }

155
internal/simulation/engine_test.go

@ -1,130 +1,107 @@
package simulation package simulation
import ( import (
"fmt"
"os"
"path/filepath"
"testing" "testing"
"encoding/json"
"systemdesigngame/internal/design" "systemdesigngame/internal/design"
) )
func TestNewEngineFromDesign(t *testing.T) { func TestSimpleChainSimulation(t *testing.T) {
designInput := &design.Design{ d := design.Design{
Nodes: []design.Node{ Nodes: []design.Node{
{ {ID: "a", Type: "webserver", Props: map[string]any{"capacityRPS": 1, "baseLatencyMs": 10}},
ID: "web1", {ID: "b", Type: "webserver", Props: map[string]any{"capacityRPS": 1, "baseLatencyMs": 10}},
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",
},
},
}, },
Connections: []design.Connection{ Connections: []design.Connection{
{ {Source: "a", Target: "b"},
Source: "web1",
Target: "cache1",
},
}, },
} }
engine := NewEngineFromDesign(*designInput, 10, 100) engine := NewEngineFromDesign(d, 100)
if len(engine.Nodes) != 2 { engine.Nodes["a"].Queue = append(engine.Nodes["a"].Queue, &Request{
t.Fatalf("expected 2 nodes, got %d", len(engine.Nodes)) ID: "req-1",
} Origin: "a",
Type: "GET",
Timestamp: 0,
Path: []string{"a"},
})
if len(engine.Nodes["web1"].GetTargets()) != 1 { snaps := engine.Run(2, 100)
t.Fatalf("expected web1 to have 1 target, got %d", len(engine.Nodes["web1"].GetTargets()))
}
if engine.Nodes["web1"].GetTargets()[0] != "cache1" { if len(snaps) != 2 {
t.Fatalf("expected web1 target to be cache1") t.Fatalf("expected 2 snapshots, got %d", len(snaps))
}
} }
func TestComplexSimulationRun(t *testing.T) { if len(snaps[0].Emitted["a"]) != 1 {
filePath := filepath.Join("testdata", "complex_design.json") t.Errorf("expected a to emit 1 request at tick 0")
data, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read JSON file: %v", err)
} }
if snaps[0].QueueSizes["b"] != 0 {
var d design.Design t.Errorf("expected b's queue to be 0 after tick 0 (not yet processed)")
if err := json.Unmarshal([]byte(data), &d); err != nil {
t.Fatalf("Failed to unmarshal JSON: %v", err)
} }
engine := NewEngineFromDesign(d, 10, 100) if len(snaps[1].Emitted["b"]) != 1 {
if engine == nil { t.Errorf("expected b to emit 1 request at tick 1")
t.Fatal("Engine should not be nil")
} }
engine.Run()
if len(engine.Timeline) == 0 {
t.Fatal("Expected timeline snapshots after Run, got none")
} }
// Optional: check that some nodes received or emitted requests func TestSingleTickRouting(t *testing.T) {
for id, node := range engine.Nodes { d := design.Design{
if len(node.Emit()) > 0 { Nodes: []design.Node{
t.Logf("Node %s has activity", id) {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"},
},
} }
func TestSimulationRunEndToEnd(t *testing.T) { engine := NewEngineFromDesign(d, 100)
data, err := os.ReadFile("testdata/simple_design.json")
fmt.Print(data)
if err != nil {
t.Fatalf("Failed to read test data: %v", err)
}
var design design.Design engine.Nodes["a"].Queue = append(engine.Nodes["a"].Queue, &Request{
if err := json.Unmarshal(data, &design); err != nil { ID: "req-1",
t.Fatalf("Failed to unmarshal JSON: %v", err) Origin: "a",
} Type: "GET",
Timestamp: 0,
Path: []string{"a"},
})
engine := NewEngineFromDesign(design, 20, 100) // 20 ticks, 100ms per tick snaps := engine.Run(1, 100)
engine.Run()
if len(engine.Timeline) != 20 { if len(snaps) != 1 {
t.Errorf("Expected 20 timeline entries, got %d", len(engine.Timeline)) t.Fatalf("expected 1 snapshot, got %d", len(snaps))
} }
anyTraffic := false if len(snaps[0].Emitted["a"]) != 1 {
for _, snapshot := range engine.Timeline { t.Errorf("expected a to emit 1 request, got %d", len(snaps[0].Emitted["a"]))
for _, nodeState := range snapshot.NodeHealth {
if nodeState.QueueSize > 0 {
anyTraffic = true
break
} }
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))
} }
} }
if !anyTraffic { func TestHighRPSSimulation(t *testing.T) {
t.Errorf("Expected at least one node to have non-zero queue size over time") d := design.Design{
Nodes: []design.Node{
{ID: "entry", Type: "webserver", Props: map[string]any{"capacityRPS": 5000, "baseLatencyMs": 1}},
},
Connections: []design.Connection{},
} }
// Optional: check a few expected node IDs engine := NewEngineFromDesign(d, 100)
for _, id := range []string{"node-1", "node-2"} { engine.EntryNode = "entry"
if _, ok := engine.Nodes[id]; !ok { engine.RPS = 100000
t.Errorf("Expected node %s to be present in simulation", id)
snaps := engine.Run(10, 100)
totalEmitted := 0
for _, snap := range snaps {
totalEmitted += len(snap.Emitted["entry"])
} }
expected := 10 * 5000 // capacity-limited output
if totalEmitted != expected {
t.Errorf("expected %d total emitted requests, got %d", expected, totalEmitted)
} }
} }

113
internal/simulation/loadbalancer.go

@ -1,91 +1,64 @@
package simulation package simulation
import ( import (
"math/rand" "fmt"
) )
type LoadBalancerNode struct { type LoadBalancerLogic 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
}
func (lb *LoadBalancerNode) GetID() string {
return lb.ID
}
func (lb *LoadBalancerNode) Type() string { func (l LoadBalancerLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) {
return "loadBalancer" // Extract the load balancing algorithm from the props.
} algorithm := AsString(props["algorithm"])
// Number of downstream targets
func (lb *LoadBalancerNode) IsAlive() bool { targets := int(AsFloat64(props["_numTargets"]))
return lb.Alive
}
// Acceps an incoming request by adding it to the Queue which will be processed on the next tick if len(queue) == 0 {
func (lb *LoadBalancerNode) Receive(req *Request) { return nil, true
lb.Queue = append(lb.Queue, req)
} }
func (lb *LoadBalancerNode) Tick(tick int, currentTimeMs int) { // Hold the processed requests to be emitted
// clear out the process so it starts fresh output := []*Request{}
lb.Processed = nil
// for each pending request... switch algorithm {
for _, req := range lb.Queue { case "least-connection":
// if there are no targets to forward to, skip processing // extrat current queue sizes from downstream targets
if len(lb.Targets) == 0 { queueSizesRaw, ok := props["_queueSizes"].(map[string]interface{})
continue if !ok {
return nil, true
} }
// placeholder for algorithm-specific logic. TODO. // find target with smallest queue
switch lb.Algorithm { for _, req := range queue {
case "random": minTarget := "target-0"
fallthrough minSize := int(AsFloat64(queueSizesRaw[minTarget]))
case "round-robin": for i := 1; i < targets; i++ {
fallthrough targetKey := fmt.Sprintf("target-%d", i)
default: size := int(AsFloat64(queueSizesRaw[targetKey]))
lb.Counter++ if size < minSize {
minTarget = targetKey
minSize = size
} }
// 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)
} }
// clear the queue after processing. Ready for next tick. // Clone the request and append the selected target to its path
lb.Queue = lb.Queue[:0] reqCopy := *req
reqCopy.Path = append(reqCopy.Path, minTarget)
output = append(output, &reqCopy)
} }
default:
// return the list of process requests and then clear the processed requests // Retrieve the last used index
func (lb *LoadBalancerNode) Emit() []*Request { next := int(AsFloat64(props["_rrIndex"]))
out := lb.Processed for _, req := range queue {
lb.Processed = nil // Clone ther equest and append the selected target to its path
return out reqCopy := *req
reqCopy.Path = append(reqCopy.Path, fmt.Sprintf("target-%d", next))
output = append(output, &reqCopy)
// Advance to next target
next = (next + 1) % targets
} }
func (lb *LoadBalancerNode) GetTargets() []string { props["_rrIndex"] = float64(next)
return lb.Targets
} }
func (lb *LoadBalancerNode) GetQueue() []*Request { return output, true
return lb.Queue
} }

70
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)
}
})
}

101
internal/simulation/messagequeuenode.go

@ -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
}

75
internal/simulation/microservicenode.go

@ -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
}

73
internal/simulation/monitoringnode.go

@ -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
}

13
internal/simulation/testdata/simple_design.json vendored

@ -1,7 +1,7 @@
{ {
"nodes": [ "nodes": [
{ {
"id": "node-1", "id": "loadbalancer",
"type": "loadBalancer", "type": "loadBalancer",
"position": { "x": 0, "y": 0 }, "position": { "x": 0, "y": 0 },
"props": { "props": {
@ -10,19 +10,22 @@
} }
}, },
{ {
"id": "node-2", "id": "webserver",
"type": "webserver", "type": "webserver",
"position": { "x": 100, "y": 0 }, "position": { "x": 100, "y": 0 },
"props": { "props": {
"label": "Web Server", "label": "Web Server",
"instanceSize": "medium" "instanceSize": "medium",
"capacityRPS": 5,
"baseLatencyMs": 50,
"penaltyPerRPS": 10
} }
} }
], ],
"connections": [ "connections": [
{ {
"source": "node-1", "source": "loadbalancer",
"target": "node-2", "target": "webserver",
"label": "Traffic", "label": "Traffic",
"direction": "forward", "direction": "forward",
"protocol": "HTTP", "protocol": "HTTP",

87
internal/simulation/thirdpartyservicenode.go

@ -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))
}

24
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
}

27
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
}

73
internal/simulation/webservernode.go

@ -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
}

4
router/handlers/simulation.go

@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"systemdesigngame/internal/design" "systemdesigngame/internal/design"
"systemdesigngame/internal/simulation"
) )
type SimulationHandler struct{} type SimulationHandler struct{}
@ -28,9 +27,6 @@ func (h *SimulationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return 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) // For now, return a mock successful response but eventually, we want to go to the results page(s)
response := SimulationResponse{ response := SimulationResponse{
Success: true, Success: true,

440
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;
}
}
Loading…
Cancel
Save