21 changed files with 812 additions and 1243 deletions
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
no Go files in /Users/stephaniegredell/projects/systemdesigngame |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
|
||||
@ -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 |
||||
} |
||||
|
||||
@ -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) |
||||
} |
||||
}) |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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)) |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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…
Reference in new issue