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. 356
      internal/simulation/engine.go
  9. 155
      internal/simulation/engine_test.go
  10. 123
      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 @@ @@ -0,0 +1 @@
no Go files in /Users/stephaniegredell/projects/systemdesigngame

6
internal/level/levels_test.go

@ -1,8 +1,6 @@ @@ -1,8 +1,6 @@
package level
import (
"fmt"
"os"
"path/filepath"
"testing"
)
@ -10,10 +8,6 @@ import ( @@ -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)

115
internal/simulation/cachenode.go

@ -1,115 +0,0 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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
}

356
internal/simulation/engine.go

@ -2,10 +2,22 @@ package simulation @@ -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 { @@ -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
}

155
internal/simulation/engine_test.go

@ -1,130 +1,107 @@ @@ -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)
}
}

123
internal/simulation/loadbalancer.go

@ -1,91 +1,64 @@ @@ -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
}

70
internal/simulation/loadbalancer_test.go

@ -0,0 +1,70 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -1,7 +1,7 @@
{
"nodes": [
{
"id": "node-1",
"id": "loadbalancer",
"type": "loadBalancer",
"position": { "x": 0, "y": 0 },
"props": {
@ -10,19 +10,22 @@ @@ -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",

87
internal/simulation/thirdpartyservicenode.go

@ -1,87 +0,0 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 ( @@ -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) { @@ -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,

440
static/game-mode.css

@ -0,0 +1,440 @@ @@ -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