diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e06930e..8981825 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,4 +40,7 @@ jobs: run: go build ./... - name: Static JavaScript syntax check - run: node --check static/js/main.js + run: | + for f in static/js/*.js; do + node --check "$f" + done diff --git a/README.md b/README.md new file mode 100644 index 0000000..83a637b --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Hack the Planet + +A cinematic hacker-movie simulator by [Kastro Labs](https://kastro.is). Live at [htp.kastro.is](https://htp.kastro.is). + +**Mash any keys. Become a movie hacker.** Pick a fictional target, type literally anything while convincing code pours out of your fingertips, survive the firewall counterattack, crack the password one glowing character at a time, siphon the files, and walk away with a mission report worth screenshotting. + +It is 100% fiction. Nothing is scanned, contacted, or harmed. Every target is invented, every exploit is theatre, and the only thing under attack is realism. + +## How it plays + +1. **Boot** — BIOS theatrics. The reality driver is intentionally NOT FOUND. +2. **Jack in** — pick a two-word hacker handle with zero humility (or randomize one). +3. **Pick a target** — THE GIBSON, an orbital disco satellite, an evil bank's bonus pool, or the Global Genius Database. +4. **The run** — type *anything*; any keys work. Fill the ACCESS meter, out-type the TRACE during the firewall counterattack (go idle and you *will* get traced), then keep mashing through the exfil. +5. **Debrief** — keystroke stats, trace peak, an operator rating from SCRIPT KIDDIE to ZERO COOL CERTIFIED, and a copyable mission report. + +Extras: `ESC` summons a boss-screen spreadsheet, sound is synth-only and off by default, the world map decodes real TopoJSON with zero libraries, and the whole thing works on a phone via the big MASH button. + +## Running it + +```bash +go run . # http://localhost:8080 +PORT=3001 go run . # any port you like +``` + +One Go binary, all assets embedded, no runtime dependencies, no database, no analytics, no network calls except serving itself. + +## Stack + +- **Server:** Go standard library only (`embed` + `net/http`), ~100 lines. +- **Frontend:** vanilla ES modules, two canvases, CSS. No frameworks, no build step. +- **Audio:** WebAudio oscillators, generated on the fly. + +## Checks + +```bash +gofmt -l . +go test ./... +go vet ./... +go build ./... +``` diff --git a/generators.go b/generators.go deleted file mode 100644 index 96e9794..0000000 --- a/generators.go +++ /dev/null @@ -1,300 +0,0 @@ -package main - -import ( - "context" - "fmt" - "math/rand/v2" - "strings" - "time" -) - -func runGenerators(ctx context.Context, hub *Hub) { - go generateHexDump(ctx, hub) - go generateTerminal(ctx, hub) - go generateAlerts(ctx, hub) - go generateProgress(ctx, hub) - go generateMapConnections(ctx, hub) - go generateNodePulses(ctx, hub) -} - -func generateHexDump(ctx context.Context, hub *Hub) { - offset := 0 - for { - select { - case <-ctx.Done(): - return - case <-time.After(50 * time.Millisecond): - bytes := make([]byte, 16) - for i := range bytes { - bytes[i] = byte(rand.IntN(256)) - } - hexParts := make([]string, 16) - for i, b := range bytes { - hexParts[i] = fmt.Sprintf("%02X", b) - } - ascii := make([]byte, 16) - for i, b := range bytes { - if b >= 32 && b <= 126 { - ascii[i] = b - } else { - ascii[i] = '.' - } - } - hub.Broadcast(Message{ - Type: "hex", - Data: HexLine{ - Offset: fmt.Sprintf("%08X", offset), - Hex: strings.Join(hexParts, " "), - ASCII: string(ascii), - }, - }) - offset += 16 - } - } -} - -var terminalCommands = []struct { - text string - style string -}{ - {"$ open hollywood_mainframe --mode cinematic --target none", "command"}, - {"Toy interface confirmed. No real systems selected.", "output"}, - {"Movie logic loaded. Dramatic typing permitted.", "success"}, - {"$ dial --number 555-MAINFRAME --sound modem", "command"}, - {"Handshake squeal decoded as: welcome to the montage.", "output"}, - {"$ render --satellite-grid --city REYKJAVIK-%d", "command"}, - {"Plot uplink stable. Coffee machine relay %d glowing.", "output"}, - {"$ bypass --thing plot-firewall --method sunglasses", "command"}, - {"Layer %d politely ignored by the screenplay.", "output"}, - {"MAINFRAME VIBES ACQUIRED", "success"}, - {"$ whoami", "command"}, - {"person enjoying a harmless Kastro Labs web toy", "output"}, - {"$ download --file dramatic_pause.wav", "command"}, - {"Transfer complete: 0 bytes. No packets harmed.", "success"}, - {"$ render --scope imagination --real-targets 0", "command"}, - {"Fictional nodes discovered: %d. Actual networks touched: 0.", "output"}, - {"$ compile --nonsense neon-mainframe", "command"}, - {"Build result: screenshot-worthy chaos.", "success"}, - {"$ gdpr --check", "command"}, - {"No personal data collected by this toy sequence.", "success"}, - {"$ kastro labs --open-source-vibes", "command"}, - {"Reminder: this is theatre, not offensive capability.", "output"}, -} - -func generateTerminal(ctx context.Context, hub *Hub) { - idx := 0 - for { - select { - case <-ctx.Done(): - return - case <-time.After(time.Duration(800+rand.IntN(2500)) * time.Millisecond): - cmd := terminalCommands[idx%len(terminalCommands)] - text := fmt.Sprintf(cmd.text, - rand.IntN(255), rand.IntN(255), rand.IntN(255), rand.IntN(255), - rand.IntN(65536), rand.IntN(65536), rand.IntN(65536), rand.IntN(65536), - ) - hub.Broadcast(Message{ - Type: "terminal", - Data: TerminalLine{ - Text: text, - Style: cmd.style, - }, - }) - idx++ - } - } -} - -var alertMessages = []struct { - text string - severity string -}{ - {"PLOT FIREWALL WANTS A MEETING IN %d MINUTES", "warning"}, - {"MAINFRAME VIBES SPIKED TO %d%%", "info"}, - {"DRAMATIC MUSIC INTENSITY: LEVEL %d", "info"}, - {"COFFEE MACHINE RELAY %d REQUESTS PATCH NOTES", "warning"}, - {"NO REAL TARGETS SELECTED -- SAFETY INTERLOCK GREEN", "info"}, - {"SUSPICIOUSLY COOL MAP LINE DETECTED", "info"}, - {"MOVIE LOGIC SPIKE IN SECTOR %d", "critical"}, - {"SCREENSHOT MOMENT APPROACHING IN %d SECONDS", "warning"}, - {"PACKET WELFARE CHECK PASSED", "info"}, - {"BOSS MODE AVAILABLE: PRESS B", "warning"}, - {"KASTRO LABS WATERMARK STABLE", "info"}, - {"FICTIONAL SATELLITE %d HAS ENTERED THE CHAT", "info"}, -} - -func generateAlerts(ctx context.Context, hub *Hub) { - for { - select { - case <-ctx.Done(): - return - case <-time.After(time.Duration(3000+rand.IntN(7000)) * time.Millisecond): - a := alertMessages[rand.IntN(len(alertMessages))] - text := fmt.Sprintf(a.text, - rand.IntN(65535), rand.IntN(255), rand.IntN(255), rand.IntN(255), - ) - hub.Broadcast(Message{ - Type: "alert", - Data: Alert{ - Text: text, - Severity: a.severity, - }, - }) - } - } -} - -var progressLabels = []string{ - "CALIBRATING MAINFRAME VIBES", - "LOADING MOVIE LOGIC", - "POLISHING NEON GRID", - "BYPASSING PLOT FIREWALL", - "REWINDING VHS TRACKING", - "RENDERING SATELLITE THEATRE", - "COUNTING UNHARMED PACKETS", - "PREPARING SCREENSHOT MODE", - "SUMMONING DRAMATIC MODAL", - "AUDITING FAKE TERMINAL COPY", - "SPINNING EXECUTIVE CUBE", - "RESTORING WORKPLACE PLAUSIBILITY", -} - -func generateProgress(ctx context.Context, hub *Hub) { - type bar struct { - id string - label string - percent float64 - speed float64 - } - bars := make([]bar, 4) - for i := range bars { - bars[i] = bar{ - id: fmt.Sprintf("bar-%d", i), - label: progressLabels[rand.IntN(len(progressLabels))], - percent: rand.Float64() * 30, - speed: 0.3 + rand.Float64()*1.5, - } - } - - for { - select { - case <-ctx.Done(): - return - case <-time.After(200 * time.Millisecond): - for i := range bars { - bars[i].percent += bars[i].speed + rand.Float64()*0.5 - done := false - if bars[i].percent >= 100 { - bars[i].percent = 100 - done = true - } - hub.Broadcast(Message{ - Type: "progress", - Data: Progress{ - ID: bars[i].id, - Label: bars[i].label, - Percent: bars[i].percent, - Done: done, - }, - }) - if done { - bars[i] = bar{ - id: bars[i].id, - label: progressLabels[rand.IntN(len(progressLabels))], - percent: 0, - speed: 0.3 + rand.Float64()*1.5, - } - } - } - } - } -} - -type city struct { - name string - lat float64 - lng float64 -} - -var cities = []city{ - {"WASHINGTON DC", 38.9, -77.0}, - {"NEW YORK", 40.7, -74.0}, - {"LONDON", 51.5, -0.1}, - {"MOSCOW", 55.7, 37.6}, - {"BEIJING", 39.9, 116.4}, - {"TOKYO", 35.7, 139.7}, - {"SYDNEY", -33.9, 151.2}, - {"SAO PAULO", -23.5, -46.6}, - {"BERLIN", 52.5, 13.4}, - {"MUMBAI", 19.1, 72.9}, - {"CAIRO", 30.0, 31.2}, - {"JOHANNESBURG", -26.2, 28.0}, - {"SINGAPORE", 1.3, 103.8}, - {"SEOUL", 37.6, 127.0}, - {"TEL AVIV", 32.1, 34.8}, - {"DUBAI", 25.2, 55.3}, - {"STOCKHOLM", 59.3, 18.1}, - {"TORONTO", 43.7, -79.4}, - {"BUENOS AIRES", -34.6, -58.4}, - {"LAGOS", 6.5, 3.4}, -} - -func generateMapConnections(ctx context.Context, hub *Hub) { - for { - select { - case <-ctx.Done(): - return - case <-time.After(time.Duration(1500+rand.IntN(3000)) * time.Millisecond): - src := cities[rand.IntN(len(cities))] - dst := cities[rand.IntN(len(cities))] - for dst.name == src.name { - dst = cities[rand.IntN(len(cities))] - } - hub.Broadcast(Message{ - Type: "map_connection", - Data: MapConnection{ - SrcLat: src.lat, - SrcLng: src.lng, - DstLat: dst.lat, - DstLng: dst.lng, - SrcName: src.name, - DstName: dst.name, - }, - }) - } - } -} - -var nodes = []struct { - id string - name string -}{ - {"node-0", "KASTRO-LABS"}, - {"node-1", "PLOT-FIREWALL"}, - {"node-2", "COFFEE-RELAY"}, - {"node-3", "VHS-TRACKING"}, - {"node-4", "SATELLITE-VIBES"}, - {"node-5", "MAINFRAME-AURA"}, - {"node-6", "BOSS-MODE"}, - {"node-7", "NO-PACKETS-HARMED"}, - {"node-8", "DRAMATIC-TERMINAL"}, - {"node-9", "SCREENSHOT-BAIT"}, -} - -func generateNodePulses(ctx context.Context, hub *Hub) { - for { - select { - case <-ctx.Done(): - return - case <-time.After(time.Duration(1000+rand.IntN(2000)) * time.Millisecond): - n := nodes[rand.IntN(len(nodes))] - hub.Broadcast(Message{ - Type: "node_pulse", - Data: NodePulse{ - ID: n.id, - Name: n.name, - }, - }) - } - } -} diff --git a/go.mod b/go.mod index d76340e..6f00a7b 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,3 @@ module hacktheplanet go 1.26.2 - -require github.com/coder/websocket v1.8.14 diff --git a/go.sum b/go.sum deleted file mode 100644 index c80e2f0..0000000 --- a/go.sum +++ /dev/null @@ -1,2 +0,0 @@ -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= diff --git a/main.go b/main.go index a033307..13e2b6e 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,6 @@ +// Hack the Planet — a cinematic hacker-movie simulator by Kastro Labs. +// The entire simulation runs client-side; this binary only serves the +// embedded static assets. package main import ( @@ -5,15 +8,18 @@ import ( "embed" "flag" "fmt" + "io/fs" "log" + "net/http" "os" "os/signal" "strconv" "syscall" + "time" ) -//go:embed static/* -var staticFiles embed.FS +//go:embed static +var embedded embed.FS const banner = ` ██╗ ██╗ █████╗ ██████╗██╗ ██╗ ████████╗██╗ ██╗███████╗ @@ -34,32 +40,55 @@ func main() { port := flag.Int("port", 8080, "HTTP server port") flag.Parse() - // Platforms like Railway set PORT env var + // Platforms like Railway/Coolify inject PORT. if envPort := os.Getenv("PORT"); envPort != "" { if p, err := strconv.Atoi(envPort); err == nil { *port = p } } - fmt.Print(banner) + static, err := fs.Sub(embedded, "static") + if err != nil { + log.Fatal(err) + } - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintln(w, "ok — no packets harmed") + }) + mux.Handle("/", withHeaders(http.FileServerFS(static))) - hub := newHub() - go runGenerators(ctx, hub) + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", *port), + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } - srv := startServer(*port, staticFiles, hub) + fmt.Print(banner) + log.Printf(">> SIMULATION ONLINE -- http://localhost:%d", *port) - log.Printf(">> SYSTEM ONLINE -- http://localhost:%d", *port) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() go func() { - if err := srv.ListenAndServe(); err != nil { + if err := srv.ListenAndServe(); err != http.ErrServerClosed { log.Printf("server stopped: %v", err) + stop() } }() <-ctx.Done() - log.Println(">> SHUTTING DOWN...") - srv.Close() + log.Println(">> POWERING DOWN THE THEATRE...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + srv.Shutdown(shutdownCtx) +} + +func withHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-No-Packets-Were-Harmed", "true") + next.ServeHTTP(w, r) + }) } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..5bd529a --- /dev/null +++ b/main_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "io/fs" + "strings" + "testing" +) + +// The whole product is the embedded frontend; make sure it is actually in +// the binary and still says what it is. +func TestEmbeddedAssets(t *testing.T) { + required := []string{ + "static/index.html", + "static/css/style.css", + "static/js/main.js", + "static/js/data.js", + "static/js/panels.js", + "static/js/worldmap.js", + "static/js/fx.js", + "static/js/audio.js", + "static/data/land-110m.json", + "static/img/og-hack-the-planet.svg", + } + for _, path := range required { + if _, err := fs.Stat(embedded, path); err != nil { + t.Errorf("missing embedded asset %s: %v", path, err) + } + } +} + +func TestIndexStaysFictional(t *testing.T) { + b, err := fs.ReadFile(embedded, "static/index.html") + if err != nil { + t.Fatal(err) + } + html := string(b) + for _, marker := range []string{"Hack the Planet", "0 packets harmed", "fiction"} { + if !strings.Contains(html, marker) { + t.Errorf("index.html lost its %q marker", marker) + } + } +} diff --git a/messages.go b/messages.go deleted file mode 100644 index c070bca..0000000 --- a/messages.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -// Message is the envelope sent over WebSocket to the frontend. -type Message struct { - Type string `json:"type"` - Data interface{} `json:"data"` -} - -type HexLine struct { - Offset string `json:"offset"` - Hex string `json:"hex"` - ASCII string `json:"ascii"` -} - -type TerminalLine struct { - Text string `json:"text"` - Style string `json:"style"` // "command", "output", "success", "error" -} - -type Alert struct { - Text string `json:"text"` - Severity string `json:"severity"` // "info", "warning", "critical" -} - -type Progress struct { - ID string `json:"id"` - Label string `json:"label"` - Percent float64 `json:"percent"` - Done bool `json:"done"` -} - -type MapConnection struct { - SrcLat float64 `json:"srcLat"` - SrcLng float64 `json:"srcLng"` - DstLat float64 `json:"dstLat"` - DstLng float64 `json:"dstLng"` - SrcName string `json:"srcName"` - DstName string `json:"dstName"` -} - -type NodePulse struct { - ID string `json:"id"` - Name string `json:"name"` -} diff --git a/server.go b/server.go deleted file mode 100644 index 0bfd4ed..0000000 --- a/server.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "embed" - "fmt" - "io/fs" - "log" - "net/http" -) - -// noCacheHandler wraps a handler to add cache-busting headers -func noCacheHandler(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - w.Header().Set("Pragma", "no-cache") - w.Header().Set("Expires", "0") - h.ServeHTTP(w, r) - }) -} - -func startServer(port int, staticFS embed.FS, hub *Hub) *http.Server { - mux := http.NewServeMux() - - sub, err := fs.Sub(staticFS, "static") - if err != nil { - log.Fatal(err) - } - mux.Handle("GET /", noCacheHandler(http.FileServer(http.FS(sub)))) - mux.Handle("GET /ws", handleWebSocket(hub)) - - srv := &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: mux, - } - return srv -} diff --git a/static/css/style.css b/static/css/style.css index 7df7a78..e18db31 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,496 +1,617 @@ +/* Hack the Planet — CRT phosphor theme. */ + :root { - --yellow: #f2b705; - --yellow-soft: #ffd95a; - --black: #050505; - --void: #0b0904; - --bone: #faf4dc; - --muted: #c7ba87; - --green: #75ff8f; - --red: #ff3b3b; - --line: rgba(255, 212, 0, .32); - --line-dim: rgba(247, 240, 216, .14); - --panel: rgba(8, 8, 6, .91); - --mono: "Space Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; - --sans: "Outfit", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --display: "Archivo Black", "Outfit", ui-sans-serif, system-ui, sans-serif; + --bg: #020804; + --green: #39ff14; + --green-dim: rgba(57, 255, 20, 0.55); + --green-faint: rgba(57, 255, 20, 0.12); + --yellow: #ffd400; + --red: #ff2d55; + --mono: 'Share Tech Mono', ui-monospace, monospace; + --display: 'VT323', 'Share Tech Mono', monospace; } * { box-sizing: border-box; } -html { min-height: 100%; background: var(--black); } + +html, body { + margin: 0; + height: 100%; + overflow: hidden; +} + body { - min-height: 100%; - margin: 0; - color: var(--bone); - background: - radial-gradient(circle at 12% 18%, rgba(255, 212, 0, .16), transparent 28%), - radial-gradient(circle at 86% 8%, rgba(124, 255, 139, .10), transparent 26%), - linear-gradient(135deg, #020202 0%, #0f0c03 52%, #040404 100%); - font-family: var(--sans); - overflow-x: hidden; - text-rendering: geometricPrecision; -} -body::before, -body::after { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 30; -} -body::before { - background: repeating-linear-gradient(0deg, rgba(0, 0, 0, .32) 0 1px, transparent 1px 4px); - mix-blend-mode: multiply; -} -body::after { - background: radial-gradient(circle at center, transparent 0 48%, rgba(0, 0, 0, .58) 100%); -} -button, input { font: inherit; } -button, a { -webkit-tap-highlight-color: transparent; } -button { cursor: pointer; } -button:focus-visible, a:focus-visible { outline: 3px solid var(--green); outline-offset: 3px; } -a { color: var(--yellow); } -[hidden] { display: none !important; } - -#atmosphere { - position: fixed; - inset: 0; - width: 100%; - height: 100%; - z-index: 0; - opacity: .46; -} -.arcade { - position: relative; - z-index: 2; - min-height: 100vh; - display: grid; - grid-template-rows: auto auto 1fr; - gap: clamp(14px, 2vw, 24px); - padding: clamp(12px, 2vw, 24px); -} -.topbar, -.boot-panel, -.mission-select, -.stage, -.report { - border: 2px solid var(--line); - background: linear-gradient(180deg, rgba(255, 212, 0, .055), transparent 36%), var(--panel); - box-shadow: 0 30px 90px rgba(0, 0, 0, .42), inset 0 1px 0 rgba(255,255,255,.06); + background: var(--bg); + color: var(--green); + font-family: var(--mono); + font-size: 15px; + text-shadow: 0 0 6px rgba(57, 255, 20, 0.35); } + +/* ---------- global layers ---------- */ + +#rain { + position: fixed; + inset: 0; + z-index: 0; + opacity: 0.35; +} + +#flash { + position: fixed; + inset: 0; + z-index: 50; + opacity: 0; + pointer-events: none; +} + +#crt { + position: fixed; + inset: 0; + z-index: 40; + pointer-events: none; + background: + repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.18) 0 1px, transparent 1px 3px), + radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.55) 100%); +} + .topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; - min-height: 68px; - padding: 10px 12px; -} -.brand { - display: flex; - align-items: center; - gap: 12px; - color: var(--bone); - text-decoration: none; -} -.brand-mark { - display: grid; - place-items: center; - width: 44px; - height: 44px; - background: var(--yellow); - border: 2px solid var(--bone); - box-shadow: 7px 7px 0 #000, 0 0 42px rgba(255, 212, 0, .32); -} -.brand-mark img { width: 78%; height: 78%; object-fit: contain; } -.brand strong, -.brand em { display: block; } -.brand strong { font: 950 15px/1 var(--display); letter-spacing: .16em; text-transform: uppercase; } -.brand em { margin-top: 5px; color: var(--muted); font: 12px/1.1 var(--mono); font-style: normal; } -.machine-status { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; } -.machine-status span, -.machine-status button { - min-height: 34px; - display: inline-grid; - place-items: center; - padding: 7px 10px; - border: 1px solid var(--line-dim); - background: rgba(0, 0, 0, .35); - color: var(--muted); - font: 700 11px var(--mono); - text-transform: uppercase; -} -.machine-status b { color: var(--green); } -.machine-status button { color: var(--yellow); } - -.boot-panel { - align-self: stretch; - display: grid; - grid-template-columns: minmax(0, 1.05fr) minmax(320px, .74fr); - gap: clamp(18px, 4vw, 54px); - align-items: center; - padding: clamp(22px, 5vw, 64px); -} -.eyebrow { - margin: 0 0 10px; - color: var(--yellow); - font: 950 12px var(--mono); - letter-spacing: .22em; - text-transform: uppercase; -} -h1, h2 { text-wrap: balance; } -.boot-copy h1, -.section-head h2, -.report-card h2 { - margin: 0; - max-width: 12ch; - font-family: var(--display); - font-size: clamp(56px, 8.7vw, 128px); - line-height: .82; - letter-spacing: -.085em; - text-transform: uppercase; -} -.lede { - max-width: 66ch; - margin: 22px 0 0; - color: rgba(247, 240, 216, .82); - font-size: clamp(17px, 1.45vw, 21px); - line-height: 1.5; -} -.safety-list { - display: grid; - gap: 10px; - max-width: 720px; - margin: 26px 0 0; - padding: 0; - list-style: none; -} -.safety-list li { - padding: 13px 14px; - border-left: 6px solid var(--yellow); - background: rgba(255, 212, 0, .075); - color: rgba(247, 240, 216, .82); - font: 14px/1.45 var(--mono); -} -.safety-list strong { color: var(--bone); } -.boot-card { - display: grid; - gap: 14px; - padding: 18px; - border: 3px solid var(--yellow); - background: #111; - box-shadow: 14px 14px 0 #000, inset 0 0 0 7px #1f1b0a; - transform: rotate(1.2deg); -} -.cabinet-label { - padding: 8px 10px; - color: var(--black); - background: var(--yellow); - font: 950 13px var(--mono); - letter-spacing: .18em; - text-align: center; -} -.boot-screen { - min-height: 330px; - display: grid; - place-items: center; - gap: 16px; - padding: 24px; - border: 2px solid rgba(124, 255, 139, .38); - background: - linear-gradient(rgba(124, 255, 139, .07) 50%, transparent 50%) 0 0/100% 8px, - radial-gradient(circle, rgba(124, 255, 139, .17), rgba(0, 0, 0, .82)); - color: var(--green); - font-family: var(--mono); - text-align: center; - text-shadow: 0 0 18px rgba(124, 255, 139, .6); -} -.boot-screen span { font-size: 13px; letter-spacing: .26em; } -.boot-screen strong { font-size: clamp(30px, 4vw, 52px); line-height: .9; letter-spacing: -.06em; } -.primary-button, -.ritual-button, -.mission-card, -.report-actions button, -.terminal-head button { - border: 2px solid var(--bone); - color: var(--black); - background: var(--yellow); - box-shadow: 7px 7px 0 #000; - text-transform: uppercase; -} -.primary-button { - min-height: 58px; - padding: 15px 18px; - font: 950 13px var(--mono); - letter-spacing: .14em; -} -.primary-button span, -.primary-button em { display: block; } -.primary-button em { margin-top: 4px; font: 700 10px var(--mono); letter-spacing: .18em; opacity: .7; } -.primary-button:hover, -.ritual-button:hover, -.mission-card:hover { transform: translate(-2px, -2px); box-shadow: 10px 10px 0 #000; } -.primary-button:active, -.ritual-button:active, -.mission-card:active { transform: translate(3px, 3px); box-shadow: 3px 3px 0 #000; } - -.mission-select, -.report { - align-self: start; - padding: clamp(20px, 4vw, 48px); + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 30; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--green-faint); + background: rgba(2, 8, 4, 0.8); + letter-spacing: 0.08em; +} + +.brand em { + color: var(--yellow); + font-style: normal; + font-size: 0.8em; + margin-left: 6px; +} + +.topbar-right { + display: flex; + gap: 14px; + align-items: center; +} + +.footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 30; + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 16px; + font-size: 12px; + color: var(--green-dim); + border-top: 1px solid var(--green-faint); + background: rgba(2, 8, 4, 0.8); +} + +.footer a { color: var(--yellow); text-decoration: none; } + +/* ---------- screens ---------- */ + +.screen { + position: fixed; + inset: 48px 0 36px; + z-index: 10; + display: none; + padding: 24px; + overflow-y: auto; +} + +.screen.active { display: block; } + +.glow { + color: var(--green); + text-shadow: 0 0 12px rgba(57, 255, 20, 0.7); + letter-spacing: 0.06em; +} + +.dim { color: var(--green-dim); } +.accent { color: var(--yellow); } + +h1 { + font-family: var(--display); + font-size: clamp(28px, 4.5vw, 52px); + font-weight: 400; + margin: 0 0 8px; +} + +/* ---------- buttons ---------- */ + +.btn, .chip { + font-family: var(--mono); + font-size: 14px; + letter-spacing: 0.1em; + color: var(--bg); + background: var(--green); + border: 1px solid var(--green); + padding: 10px 22px; + cursor: pointer; + text-shadow: none; + box-shadow: 0 0 14px rgba(57, 255, 20, 0.4); + transition: transform 0.08s ease, box-shadow 0.15s ease; +} + +.btn:hover { transform: translateY(-1px); box-shadow: 0 0 22px rgba(57, 255, 20, 0.65); } +.btn:active { transform: translateY(1px); } + +.btn.ghost { + background: transparent; + color: var(--green); + box-shadow: none; + text-shadow: 0 0 6px rgba(57, 255, 20, 0.35); +} + +.chip { + font-size: 11px; + padding: 4px 10px; + background: transparent; + color: var(--green-dim); + border-color: var(--green-faint); + box-shadow: none; +} + +.btn-row { display: flex; gap: 12px; margin-top: 22px; flex-wrap: wrap; } +.btn-row.center { justify-content: center; } + +/* ---------- boot ---------- */ + +#screen-boot { + display: none; + padding: 6vh 8vw; +} + +#screen-boot.active { display: block; } + +.boot-log div { + white-space: pre-wrap; + line-height: 1.6; + font-size: clamp(13px, 1.6vw, 17px); +} + +.boot-skip { + position: absolute; + bottom: 18px; + right: 24px; + color: var(--green-dim); + font-size: 12px; + animation: blink 1.4s steps(2) infinite; +} + +@keyframes blink { 50% { opacity: 0.2; } } + +/* ---------- login ---------- */ + +#screen-login.active, #screen-missions.active, +#screen-splash.active, #screen-traced.active { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; } -.section-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 24px; } -.section-head h2 { max-width: none; font-size: clamp(48px, 8vw, 110px); } -.section-head p:not(.eyebrow) { max-width: 45ch; margin: 0; color: var(--muted); font: 14px/1.5 var(--mono); } -.mission-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } + +.login-box { max-width: 560px; width: 100%; } + +.login-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 26px; + border: 1px solid var(--green-dim); + background: rgba(57, 255, 20, 0.04); + padding: 12px 16px; +} + +.prompt { color: var(--yellow); font-size: 20px; } + +#handle-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--green); + font-family: var(--mono); + font-size: 20px; + letter-spacing: 0.12em; + text-transform: uppercase; + caret-color: var(--yellow); +} + +#handle-input::placeholder { color: var(--green-faint); } + +.login-box .btn-row { justify-content: center; } + +/* ---------- missions ---------- */ + +#screen-missions .dim { margin-bottom: 26px; } + +.mission-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; + width: min(1080px, 100%); +} + .mission-card { - min-height: 360px; - display: flex; - flex-direction: column; - align-items: stretch; - justify-content: space-between; - gap: 20px; - padding: 18px; - text-align: left; -} -.mission-card:nth-child(2) { background: var(--bone); } -.mission-card:nth-child(3) { background: var(--green); } -.mission-number { color: rgba(0, 0, 0, .45); font: 950 54px/.8 var(--mono); letter-spacing: -.1em; } -.mission-card strong { display: block; font-family: var(--display); font-size: clamp(32px, 4vw, 56px); line-height: .88; letter-spacing: -.055em; } -.mission-card em { color: rgba(0,0,0,.72); font: 14px/1.45 var(--mono); font-style: normal; text-transform: none; } -.mission-card b { align-self: start; padding: 7px 9px; border: 2px solid #000; font: 950 11px var(--mono); letter-spacing: .13em; } - -.stage { - min-height: calc(100vh - 116px); - display: grid; - grid-template-columns: minmax(230px, .62fr) minmax(380px, 1.24fr) minmax(270px, .74fr); - gap: 14px; - padding: 14px; -} -.mission-rail, -.terminal-card, -.screen-bezel { - border: 1px solid var(--line-dim); - background: rgba(0, 0, 0, .36); -} -.mission-rail, -.terminal-card { padding: 14px; min-width: 0; } -.mission-rail h2 { margin: 0; font-family: var(--display); font-size: clamp(38px, 4vw, 70px); line-height: .88; letter-spacing: -.055em; text-transform: uppercase; } -#stage-brief { color: var(--muted); font: 13px/1.5 var(--mono); } -.meter-block { display: grid; gap: 8px; margin: 24px 0; font: 800 11px var(--mono); text-transform: uppercase; } -.meter { height: 16px; border: 1px solid var(--line); background: rgba(255,255,255,.06); overflow: hidden; } -.meter i { display: block; width: 0%; height: 100%; background: linear-gradient(90deg, var(--green), var(--yellow), var(--red)); box-shadow: 0 0 20px rgba(255,212,0,.35); transition: width .45s ease; } -#logic-value { color: var(--yellow); } -.step-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } -.step-list li { display: grid; grid-template-columns: 28px 1fr; gap: 8px; align-items: start; color: var(--muted); font: 12px/1.4 var(--mono); } -.step-list li::before { content: attr(data-index); display: grid; place-items: center; height: 24px; border: 1px solid var(--line-dim); color: var(--muted); } -.step-list li.active { color: var(--yellow); } -.step-list li.active::before { border-color: var(--yellow); background: var(--yellow); color: var(--black); } -.step-list li.done { color: var(--green); } -.microcopy { margin-top: 24px; color: var(--green); font: 11px/1.45 var(--mono); text-transform: uppercase; } -.planet-console { min-width: 0; display: grid; grid-template-rows: 1fr auto; gap: 14px; } -.screen-bezel { position: relative; min-height: 440px; overflow: hidden; box-shadow: inset 0 0 0 8px rgba(255,212,0,.08); } -.screen-labels { position: absolute; z-index: 3; left: 14px; right: 14px; top: 14px; display: flex; justify-content: space-between; gap: 12px; color: var(--yellow); font: 950 11px var(--mono); letter-spacing: .16em; text-transform: uppercase; } -#planet-canvas { width: 100%; height: 100%; display: block; } -.planet-readout { position: absolute; left: 18px; right: 18px; bottom: 18px; z-index: 4; padding: 13px 14px; border: 1px solid var(--line); background: rgba(0,0,0,.64); color: var(--bone); font: 950 clamp(19px, 2vw, 30px)/1 var(--mono); letter-spacing: -.05em; text-transform: uppercase; } -.ritual-button { min-height: 112px; display: grid; place-items: center; gap: 4px; padding: 18px; } -.ritual-button span { font: 950 clamp(31px, 4.8vw, 68px)/.88 var(--display); letter-spacing: -.055em; } -.ritual-button em { font: 950 11px var(--mono); letter-spacing: .18em; font-style: normal; } -.ritual-button[disabled] { opacity: .52; cursor: progress; } -.terminal-card { min-height: 0; display: flex; flex-direction: column; } -.terminal-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--line-dim); color: var(--yellow); font: 950 11px var(--mono); letter-spacing: .15em; text-transform: uppercase; } -.terminal-head button { min-height: 30px; padding: 5px 8px; box-shadow: 4px 4px 0 #000; font: 950 10px var(--mono); } -.terminal { flex: 1; min-height: 360px; overflow: auto; padding-top: 12px; color: var(--green); font: 12px/1.55 var(--mono); text-shadow: 0 0 12px rgba(124,255,139,.2); } -.term-line { margin-bottom: 7px; white-space: pre-wrap; } -.term-line.warn { color: var(--yellow); } -.term-line.red { color: var(--red); } -.term-line.muted { color: var(--muted); } -.term-line.bone { color: var(--bone); } - -.report { display: grid; gap: 18px; justify-items: center; } -.report-card { - width: min(980px, 100%); - padding: clamp(24px, 5vw, 56px); - border: 4px solid var(--yellow); - background: var(--bone); - color: var(--black); - box-shadow: 16px 16px 0 #000; -} -.report-kicker { display: inline-block; margin-bottom: 18px; padding: 8px 10px; background: var(--black); color: var(--yellow); font: 950 12px var(--mono); letter-spacing: .16em; text-transform: uppercase; } -.report-card h2 { max-width: 13ch; color: var(--black); } -.report-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin: 28px 0 0; } -.report-grid div { padding: 12px; border: 2px solid #000; background: rgba(255, 212, 0, .2); } -.report-grid dt { margin-bottom: 8px; font: 950 11px var(--mono); letter-spacing: .16em; text-transform: uppercase; } -.report-grid dd { margin: 0; font: 900 19px/1.1 var(--display); letter-spacing: -.035em; } -.report-note { margin: 24px 0 0; font: 14px/1.5 var(--mono); } -.report-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 12px; } -.report-actions button, -.report-actions a { min-height: 46px; display: inline-grid; place-items: center; padding: 12px 14px; font: 950 12px var(--mono); letter-spacing: .12em; text-transform: uppercase; } -.report-actions button:not(.primary-button) { color: var(--yellow); background: #000; box-shadow: none; } -.report-actions a { border: 1px solid var(--line); background: rgba(0,0,0,.28); text-decoration: none; } -.toast-stack { position: fixed; z-index: 60; right: 16px; bottom: 16px; display: grid; gap: 8px; width: min(440px, calc(100vw - 32px)); } -.toast { padding: 11px 12px; border: 1px solid var(--line); background: rgba(0,0,0,.92); color: var(--yellow); box-shadow: 8px 8px 0 #000; font: 12px var(--mono); } - -@media (max-width: 1180px) { - .boot-panel, .stage { grid-template-columns: 1fr; } - .stage { min-height: 0; } - .screen-bezel { min-height: 520px; } - .mission-grid { grid-template-columns: 1fr; } - .mission-card { min-height: 230px; } - .section-head { align-items: start; flex-direction: column; } -} -@media (max-width: 720px) { - .arcade { padding: 10px; } - .topbar { align-items: flex-start; flex-direction: column; } - .machine-status { justify-content: flex-start; } - .boot-panel, .mission-select, .report { padding: 18px; } - .boot-copy h1 { font-size: 52px; } - .boot-card { transform: none; } - .boot-screen { min-height: 240px; } - .screen-bezel { min-height: 390px; } - .report-grid { grid-template-columns: 1fr; } + font-family: var(--mono); + text-align: left; + background: rgba(57, 255, 20, 0.04); + border: 1px solid var(--green-faint); + color: var(--green); + padding: 18px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 8px; + transition: border-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease; } -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; } -} - -/* Option B polish pass: Kastro cyber-brutalist arcade ritual */ -::selection { background: var(--yellow); color: var(--black); } -.marquee-strip { - overflow: hidden; - border: 2px solid var(--line); - background: var(--yellow); - color: var(--black); - box-shadow: 8px 8px 0 #000; - white-space: nowrap; - font: 950 12px var(--mono); - letter-spacing: .18em; - text-transform: uppercase; -} -.marquee-strip span { - display: inline-block; - padding: 9px clamp(18px, 4vw, 42px); - border-right: 2px solid rgba(0,0,0,.55); - animation: marqueePulse 5.5s steps(5) infinite; -} -.topbar { backdrop-filter: blur(8px); } -.boot-panel, -.mission-select, -.stage, -.report { position: relative; overflow: hidden; } -.boot-panel::before, -.mission-select::before, -.stage::before, -.report::before { - content: ""; - position: absolute; - inset: 0; - pointer-events: none; - background: - linear-gradient(90deg, rgba(242,183,5,.08) 1px, transparent 1px) 0 0/46px 46px, - linear-gradient(rgba(242,183,5,.05) 1px, transparent 1px) 0 0/46px 46px; - mask: linear-gradient(90deg, #000, transparent 70%); -} -.boot-copy, .boot-card, .section-head, .mission-grid, .mission-rail, .planet-console, .terminal-card, .report-card, .report-actions { position: relative; z-index: 1; } -.boot-copy h1 { max-width: 11ch; text-shadow: 5px 5px 0 rgba(242,183,5,.18), 0 0 34px rgba(250,244,220,.12); } -.lede { border-top: 2px solid var(--line); padding-top: 18px; } -.safety-list { grid-template-columns: repeat(3, 1fr); } -.safety-list li { border-left: 0; border-top: 6px solid var(--yellow); background: rgba(250,244,220,.055); } -.boot-card { isolation: isolate; animation: cabinetFloat 4.5s ease-in-out infinite; } -.boot-card::before { - content: ""; - position: absolute; - inset: -22px; - z-index: -1; - background: radial-gradient(circle, rgba(242,183,5,.28), transparent 62%); - filter: blur(18px); -} -.boot-screen { position: relative; overflow: hidden; } -.boot-screen::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient(115deg, transparent 0 38%, rgba(117,255,143,.18) 45%, transparent 54% 100%); - transform: translateX(-120%); - animation: screenSweep 4.8s ease-in-out infinite; -} -.cabinet-corner { position: absolute; width: 22px; height: 22px; border-color: var(--yellow); border-style: solid; z-index: 2; } -.cabinet-corner.tl { left: 10px; top: 10px; border-width: 3px 0 0 3px; } -.cabinet-corner.tr { right: 10px; top: 10px; border-width: 3px 3px 0 0; } -.cabinet-corner.bl { left: 10px; bottom: 10px; border-width: 0 0 3px 3px; } -.cabinet-corner.br { right: 10px; bottom: 10px; border-width: 0 3px 3px 0; } -.primary-button, .ritual-button, .mission-card { transition: transform .16s ease, box-shadow .16s ease, filter .16s ease; } -.primary-button:hover, .ritual-button:hover, .mission-card:hover { filter: saturate(1.12) contrast(1.08); } -.section-head { border-bottom: 2px solid var(--line); padding-bottom: 18px; } -.mission-card { position: relative; overflow: hidden; } -.mission-card::after { - content: attr(data-mission); - position: absolute; - right: -10px; - bottom: -16px; - color: rgba(0,0,0,.08); - font: 950 82px/.8 var(--display); - text-transform: uppercase; -} -.mission-card:focus-visible { outline-offset: 7px; } -.stage { animation: stageIgnition .38s steps(2) both; } -body.simulation-running .screen-bezel { box-shadow: inset 0 0 0 8px rgba(242,183,5,.16), 0 0 70px rgba(242,183,5,.18); } -body.simulation-running .ritual-button { animation: buttonThump .7s ease-in-out infinite alternate; } -.meter i { position: relative; } -.meter i::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,.55), transparent); animation: meterSweep 1.1s linear infinite; } -.terminal-card { border-color: rgba(117,255,143,.28); } -.terminal { scrollbar-color: var(--yellow) rgba(255,255,255,.06); } -.report-card { - position: relative; - overflow: hidden; - background: - linear-gradient(90deg, rgba(0,0,0,.06) 1px, transparent 1px) 0 0/34px 34px, - linear-gradient(rgba(0,0,0,.045) 1px, transparent 1px) 0 0/34px 34px, - var(--bone); -} -.report-card::after { - content: "HTP"; - position: absolute; - right: -8px; - top: 18px; - color: rgba(0,0,0,.055); - font: 950 190px/.8 var(--display); - letter-spacing: -.08em; -} -.report-topline { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } -.report-stamp { transform: rotate(2deg); padding: 8px 10px; border: 3px solid var(--black); color: var(--black); font: 950 12px var(--mono); letter-spacing: .14em; text-transform: uppercase; box-shadow: 5px 5px 0 var(--yellow); } -.report-card h2 { max-width: 16ch; font-size: clamp(42px, 5.2vw, 74px); line-height: .9; } -.report-card h2, .report-grid, .report-note { position: relative; z-index: 1; } -.report-grid { margin-top: 20px; } -.report-note { margin-top: 18px; } -.report-grid div:nth-child(1) { background: var(--yellow); } -.report-grid div:nth-child(5) { grid-column: 1 / -1; background: #fff7c8; } -.toast { animation: toastIn .22s steps(2) both; } -@keyframes marqueePulse { 0%, 100% { opacity: 1; } 50% { opacity: .72; } } -@keyframes cabinetFloat { 0%,100% { transform: rotate(1.2deg) translateY(0); } 50% { transform: rotate(.6deg) translateY(-6px); } } -@keyframes screenSweep { 0%, 55% { transform: translateX(-120%); } 78%,100% { transform: translateX(120%); } } -@keyframes stageIgnition { from { filter: contrast(1.8) brightness(1.35); transform: translateY(8px); } to { filter: none; transform: none; } } -@keyframes buttonThump { from { transform: translateY(0); } to { transform: translateY(-4px); } } -@keyframes meterSweep { from { transform: translateX(-100%); } to { transform: translateX(100%); } } -@keyframes toastIn { from { transform: translate(10px, 10px); opacity: 0; } to { transform: none; opacity: 1; } } -@media (min-width: 1181px) and (max-height: 820px) { - .boot-panel { align-items: start; padding-block: 28px; } - .boot-copy h1 { font-size: clamp(48px, 7.2vw, 100px); } - .boot-screen { min-height: 260px; } - .safety-list { grid-template-columns: repeat(3, 1fr); margin-top: 18px; } -} -@media (max-width: 980px) { - .safety-list { grid-template-columns: 1fr; } - .marquee-strip span { padding-inline: 18px; } -} -@media (max-width: 720px) { - .brand strong { font-size: 13px; } - .machine-status span, .machine-status button { min-height: 38px; } - .boot-copy h1 { font-size: clamp(46px, 16vw, 72px); } - .lede { font-size: 16px; } - .mission-card::after { font-size: 56px; } - .ritual-button { min-height: 96px; } - .report-card { box-shadow: 8px 8px 0 #000; } + +.mission-card:hover { + border-color: var(--green); + transform: translateY(-2px); + box-shadow: 0 0 18px rgba(57, 255, 20, 0.25); +} + +.mission-card strong { + font-family: var(--display); + font-size: 26px; + font-weight: 400; + letter-spacing: 0.05em; +} + +.mission-card em { color: var(--yellow); font-style: normal; font-size: 13px; } +.mission-card p { margin: 0; color: var(--green-dim); font-size: 13px; line-height: 1.5; } +.m-threat { color: var(--red); font-size: 11px; letter-spacing: 0.15em; } +.m-host { color: var(--green-faint); font-size: 11px; letter-spacing: 0.08em; } + +/* ---------- the run ---------- */ + +#screen-run.active { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 16px; +} + +.run-head { + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + font-size: 14px; + letter-spacing: 0.1em; +} + +.run-grid { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 1.5fr 1fr; + grid-template-rows: 1fr 1.2fr; + gap: 10px; +} + +.panel { + border: 1px solid var(--green-faint); + background: rgba(2, 8, 4, 0.72); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.panel-title { + font-size: 11px; + letter-spacing: 0.18em; + color: var(--green-dim); + padding: 7px 10px; + border-bottom: 1px solid var(--green-faint); + flex: none; +} + +.panel-terminal { grid-row: 1 / span 2; } + +.terminal { + flex: 1; + overflow-y: auto; + padding: 10px 12px; + font-size: 13px; + line-height: 1.5; +} + +.terminal .tl { white-space: pre-wrap; word-break: break-word; } +.terminal .code { color: var(--green); opacity: 0.9; } +.terminal .sys { color: var(--green-dim); } +.terminal .cmd { color: var(--yellow); } +.terminal .good { color: var(--green); text-shadow: 0 0 10px rgba(57, 255, 20, 0.8); } +.terminal .bad { color: var(--red); text-shadow: 0 0 10px rgba(255, 45, 85, 0.7); } + +.panel-map { position: relative; } + +#map-canvas { flex: 1; width: 100%; height: 100%; min-height: 0; } + +.panel-target { gap: 0; } + +.crack { + flex: none; + display: flex; + gap: 6px; + justify-content: center; + padding: 14px 10px; + min-height: 64px; + align-items: center; +} + +.crack-idle { color: var(--green-faint); letter-spacing: 0.2em; font-size: 12px; } + +.crack-slot { + font-family: var(--display); + font-size: clamp(22px, 2.6vw, 36px); + width: 1.2em; + text-align: center; + border: 1px solid var(--green-faint); + color: var(--green-dim); + background: rgba(57, 255, 20, 0.03); +} + +.crack-slot.locked { + color: var(--bg); + background: var(--green); + border-color: var(--green); + box-shadow: 0 0 14px rgba(57, 255, 20, 0.7); +} + +.exfil { + flex: 1; + overflow-y: auto; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.exfil-row { + display: grid; + grid-template-columns: minmax(0, 1.2fr) 1fr 44px; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.exfil-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--green-dim); } +.exfil-row.done .exfil-name { color: var(--green); } + +.exfil-bar { + height: 8px; + border: 1px solid var(--green-faint); + display: block; +} + +.exfil-bar i { + display: block; + height: 100%; + width: 0; + background: var(--green); + box-shadow: 0 0 8px rgba(57, 255, 20, 0.6); +} + +.exfil-pct { text-align: right; color: var(--green-dim); } + +/* status strip */ + +.run-status { + flex: none; + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + border: 1px solid var(--green-faint); + background: rgba(2, 8, 4, 0.72); + padding: 8px 14px; +} + +.stage { color: var(--yellow); letter-spacing: 0.12em; font-size: 13px; } + +.meter-group { display: flex; align-items: center; gap: 8px; } +.meter-group label { font-size: 11px; letter-spacing: 0.15em; color: var(--green-dim); } +.meter-group b { font-size: 12px; min-width: 38px; } + +.meter { + width: clamp(90px, 14vw, 200px); + height: 10px; + border: 1px solid var(--green-dim); +} + +.meter i { display: block; height: 100%; width: 0; transition: width 0.12s linear; } +.meter.access i { background: var(--green); box-shadow: 0 0 10px rgba(57, 255, 20, 0.7); } +.meter.trace i { background: var(--red); box-shadow: 0 0 10px rgba(255, 45, 85, 0.7); } +.meter.trace + b { color: var(--red); } + +.hint { + flex: 1; + text-align: right; + color: var(--yellow); + font-size: 12px; + letter-spacing: 0.08em; + min-width: 180px; +} + +#mash { display: none; } + +/* alarm mode */ + +body.alarm .panel, body.alarm .run-status { border-color: rgba(255, 45, 85, 0.4); } +body.alarm .stage, body.alarm .hint { color: var(--red); animation: blink 0.6s steps(2) infinite; } + +@keyframes fx-shake { + 0%, 100% { transform: translate(0, 0); } + 20% { transform: translate(-6px, 3px); } + 40% { transform: translate(5px, -4px); } + 60% { transform: translate(-4px, -2px); } + 80% { transform: translate(3px, 4px); } +} + +.fx-shake { animation: fx-shake 0.35s linear; } + +/* ---------- splash screens ---------- */ + +.splash-title { + font-family: var(--display); + font-size: clamp(48px, 11vw, 150px); + margin: 0; + letter-spacing: 0.04em; + text-shadow: 0 0 30px rgba(57, 255, 20, 0.8), 0 0 80px rgba(57, 255, 20, 0.4); +} + +.splash-title.red { + color: var(--red); + text-shadow: 0 0 30px rgba(255, 45, 85, 0.8), 0 0 80px rgba(255, 45, 85, 0.4); } + +.splash-sub { + font-size: clamp(16px, 2.4vw, 26px); + color: var(--yellow); + letter-spacing: 0.1em; + margin: 14px 0 4px; +} + +/* ---------- debrief ---------- */ + +#screen-debrief.active { display: flex; align-items: center; justify-content: center; } + +.report { + width: min(720px, 100%); + border: 1px solid var(--green-dim); + background: rgba(2, 8, 4, 0.85); + padding: 26px 30px; + box-shadow: 0 0 40px rgba(57, 255, 20, 0.15); +} + +.report-head { + display: flex; + justify-content: space-between; + font-size: 11px; + letter-spacing: 0.2em; + color: var(--green-dim); + margin-bottom: 14px; +} + +.stamp { + color: var(--yellow); + border: 1px solid var(--yellow); + padding: 2px 8px; + transform: rotate(2deg); +} + +.victory { color: var(--yellow); margin: 0 0 18px; line-height: 1.5; } + +.report-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px 20px; + margin: 0 0 18px; +} + +.report-grid dt { font-size: 10px; letter-spacing: 0.2em; color: var(--green-dim); } +.report-grid dd { margin: 2px 0 0; font-size: 14px; } + +.rank-block { + border-top: 1px solid var(--green-faint); + padding-top: 14px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.rank-block strong { font-family: var(--display); font-size: 34px; font-weight: 400; } +.rank-block em { color: var(--green-dim); font-style: normal; font-size: 13px; } + +/* ---------- boss mode ---------- */ + +#boss { + display: none; + position: fixed; + inset: 0; + z-index: 100; + background: #e8e8e8; + color: #222; + font-family: Arial, Helvetica, sans-serif; + cursor: pointer; + padding: 40px 5vw; +} + +body.boss #boss { display: block; } + +.boss-sheet h2 { font-size: 16px; font-weight: 600; color: #333; } + +.boss-sheet table { + border-collapse: collapse; + width: 100%; + background: #fff; + font-size: 13px; +} + +.boss-sheet th, .boss-sheet td { + border: 1px solid #c5c5c5; + padding: 5px 10px; + text-align: left; +} + +.boss-sheet th { background: #f1f1f1; font-weight: 600; } +.boss-sheet p { font-size: 11px; color: #888; margin-top: 18px; } + +/* ---------- responsive ---------- */ + +@media (max-width: 900px) { + .run-grid { + grid-template-columns: 1fr; + grid-template-rows: minmax(160px, 1.4fr) minmax(120px, 1fr) minmax(150px, 1.1fr); + } + + .panel-terminal { grid-row: auto; } + + .run-status { gap: 8px 14px; padding: 6px 10px; font-size: 11px; } + .meter { width: clamp(70px, 22vw, 140px); } + .hint { text-align: left; min-width: 0; flex-basis: 100%; } + + #mash { + display: block; + flex: none; + font-family: var(--mono); + font-size: 16px; + letter-spacing: 0.15em; + padding: 16px; + color: var(--bg); + background: var(--green); + border: none; + box-shadow: 0 0 20px rgba(57, 255, 20, 0.5); + cursor: pointer; + touch-action: manipulation; + -webkit-user-select: none; + user-select: none; + } + + body.alarm #mash { background: var(--red); box-shadow: 0 0 20px rgba(255, 45, 85, 0.6); } + + .footer { font-size: 10px; flex-wrap: wrap; } + .screen { padding: 16px; } +} + @media (prefers-reduced-motion: reduce) { - .marquee-strip span, .boot-card, .boot-screen::after, .meter i::after, .toast, body.simulation-running .ritual-button { animation: none !important; } + *, *::before, *::after { animation: none !important; transition: none !important; } + #rain { display: none; } } diff --git a/static/img/og-hack-the-planet.svg b/static/img/og-hack-the-planet.svg index 3bd3bbf..f705cf1 100644 --- a/static/img/og-hack-the-planet.svg +++ b/static/img/og-hack-the-planet.svg @@ -1,70 +1,29 @@ - - Hack the Planet by Kastro Labs share card - Kastro cyber-brutalist cyberpunk arcade ritual share image with explicit harmless browser-toy safety copy. + - - - - - - - - - + + + - - - - - - - - - + + - - - - - - - - - - - - - - - - - - KASTRO LABS - CYBERPUNK ARCADE RITUAL · REALITY LOCK GREEN - - - - HACK THE - PLANET - TOUCH NOTHING REAL - - - - - - HTP/OS-ARCADE - - - NO PACKETS - NO TARGETS - PURE CINEMA - - - - - - BROWSER TOY · FICTIONAL MISSIONS · SHAREABLE REPORT - HTP.KASTRO.IS + + + $ uplink --target gibson.ellingson.fict --drama max + hop 1/6 ➜ REYKJAVIK [encrypted, fictional] + hop 2/6 ➜ SINGAPORE [encrypted, fictional] + >> PROXY CHAIN ESTABLISHED. You are everywhere and nowhere. + >> PASSWORD ACCEPTED: "GOD" + >> 0 real bytes moved. 0 packets harmed. + ACCESS GRANTED + HACK THE PLANET + mash any keys · become a movie hacker · 100% fiction + htp.kastro.is — a Kastro Labs toy + diff --git a/static/index.html b/static/index.html index 9e57a0d..2b16be1 100644 --- a/static/index.html +++ b/static/index.html @@ -1,154 +1,178 @@ - - - Hack the Planet by Kastro Labs - - - - - - - - - - - - - - - - - - - - - - + + + Hack the Planet — a cinematic hacker simulator by Kastro Labs + + + + + + + + + + + + + + + + + + - + + + -
-
- - - Kastro LabsHTP arcade cabinet // reality locked - - -
+
+ ⛧ BLACKICE//OS by Kastro Labs + + --:--:-- UTC + + +
-
- reality lock greenlocal pixels onlyno targetsno packetscinematic nonsense authorised -
+ +
+
+

press any key to skip

+
-
-
-

Reality lock engaged

-

Hack the planet. Touch nothing real.

-

A browser-only cyberpunk arcade ritual: pick a fictional mission, hammer the big yellow button, and leave with a shareable hacker-movie report. No targets. No packets. No nonsense pretending otherwise.

-
    -
  • No targets. Missions are invented props.
  • -
  • No packets. Animation, text, and timers only.
  • -
  • No hacking. Just movie logic in Kastro yellow.
  • -
-
-
-
HTP/OS-ARCADE
-
- - INSERT IMAGINATION - NO PACKETS
NO CRIMES
PURE CINEMA
-
- -
-
+ +
+ +
- + +
+

WELCOME, OPERATOR.

+

select a target. every one of them is fictional — that is what makes you untouchable.

+
+
- + +
+

CONNECTION TRACED

+

+

no consequences exist in fiction. your dignity respawns instantly.

+
+ + +
+
- -
+ +
+
+
+ KASTRO LABS · MISSION REPORT + 100% FICTION +
+

MISSION COMPLETE

+

+
+
OPERATOR
+
TARGET
+
TIME ON KEYBOARD
+
KEYSTROKES
+
TRACE PEAK
+
PACKETS HARMED
0
+
+
+ OPERATOR RATING + + +
+
+ + + +
+
+
-
- + +
+
+

Q3_Synergy_Review_FINAL_v7 (1).xlsx — Read-Only

+ + + + + + + +
ABCD
1InitiativeOwnerStatusSynergy
2Stakeholder alignmentBradOngoing3.7
3Process optimizationBradBlocked2.1
4Vertical integrationAlso BradQ44.0
5Leverage learningsTBDTBDTBD
+

press ESC or click to resume definitely-not-hacking

+
+
+ + + + diff --git a/static/js/alerts.js b/static/js/alerts.js deleted file mode 100644 index cc2a49f..0000000 --- a/static/js/alerts.js +++ /dev/null @@ -1,31 +0,0 @@ -const MAX_ALERTS = 12; - -export function initAlerts(container, flashEl) { - let count = 0; - - return function onAlert(data) { - const item = document.createElement('div'); - item.className = 'alert-item ' + data.severity; - - const time = new Date().toLocaleTimeString('en-US', { hour12: false }); - item.textContent = '[' + time + '] ' + data.text; - - container.appendChild(item); - count++; - - while (count > MAX_ALERTS) { - container.removeChild(container.firstChild); - count--; - } - - container.scrollTop = container.scrollHeight; - - // screen flash on critical - if (data.severity === 'critical') { - flashEl.style.background = 'rgba(255, 0, 64, 0.3)'; - flashEl.classList.remove('flash'); - void flashEl.offsetWidth; // reflow - flashEl.classList.add('flash'); - } - }; -} diff --git a/static/js/audio.js b/static/js/audio.js index 1283233..a26edd4 100644 --- a/static/js/audio.js +++ b/static/js/audio.js @@ -1,64 +1,69 @@ -let audioCtx = null; -let enabled = false; +// Synth sound effects via WebAudio. No samples, no network, off by default. -function ensureContext() { - if (!audioCtx) { - audioCtx = new AudioContext(); +class Synth { + constructor() { + this.ctx = null; + this.enabled = false; + } + + toggle() { + this.enabled = !this.enabled; + if (this.enabled && !this.ctx) { + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); } - return audioCtx; -} + if (this.enabled) this.ctx.resume(); + return this.enabled; + } -export function isEnabled() { - return enabled; -} + tone({ freq = 440, type = 'square', dur = 0.06, gain = 0.04, slide = 0 }) { + if (!this.enabled || !this.ctx) return; + const t = this.ctx.currentTime; + const osc = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, t); + if (slide) osc.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), t + dur); + g.gain.setValueAtTime(gain, t); + g.gain.exponentialRampToValueAtTime(0.0001, t + dur); + osc.connect(g).connect(this.ctx.destination); + osc.start(t); + osc.stop(t + dur + 0.02); + } -export function toggle() { - enabled = !enabled; - if (enabled) ensureContext(); - return enabled; -} + // Quiet blip per keystroke, pitch wanders so it sounds alive. + key() { + this.tone({ freq: 700 + Math.random() * 700, dur: 0.035, gain: 0.022 }); + } -export function beep(freq = 440, duration = 0.08, vol = 0.1) { - if (!enabled) return; - const ctx = ensureContext(); - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.connect(gain); - gain.connect(ctx.destination); - osc.frequency.value = freq; - osc.type = 'sine'; - gain.gain.setValueAtTime(vol, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration); - osc.start(); - osc.stop(ctx.currentTime + duration); -} + beep() { + this.tone({ freq: 980, dur: 0.08, gain: 0.04 }); + } -export function keyclick() { - if (!enabled) return; - const ctx = ensureContext(); - const bufferSize = ctx.sampleRate * 0.02; - const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); - const data = buffer.getChannelData(0); - for (let i = 0; i < bufferSize; i++) { - data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.1)); - } - const source = ctx.createBufferSource(); - source.buffer = buffer; - const gain = ctx.createGain(); - gain.gain.value = 0.03; - source.connect(gain); - gain.connect(ctx.destination); - source.start(); -} + alarm() { + this.tone({ freq: 880, type: 'sawtooth', dur: 0.18, gain: 0.05, slide: -440 }); + } -export function alertSound(severity) { - if (!enabled) return; - if (severity === 'critical') { - beep(880, 0.15, 0.15); - setTimeout(() => beep(660, 0.15, 0.15), 150); - } else if (severity === 'warning') { - beep(660, 0.1, 0.1); - } else { - beep(440, 0.06, 0.05); - } + hop() { + this.tone({ freq: 520, type: 'triangle', dur: 0.1, gain: 0.05, slide: 260 }); + } + + crack() { + this.tone({ freq: 1400 + Math.random() * 400, type: 'square', dur: 0.05, gain: 0.035 }); + } + + granted() { + if (!this.enabled || !this.ctx) return; + [523.25, 659.25, 783.99, 1046.5].forEach((f, i) => { + setTimeout(() => this.tone({ freq: f, type: 'triangle', dur: 0.35, gain: 0.06 }), i * 110); + }); + } + + traced() { + if (!this.enabled || !this.ctx) return; + [392, 311, 233].forEach((f, i) => { + setTimeout(() => this.tone({ freq: f, type: 'sawtooth', dur: 0.3, gain: 0.05 }), i * 160); + }); + } } + +export const synth = new Synth(); diff --git a/static/js/data.js b/static/js/data.js new file mode 100644 index 0000000..d49e92e --- /dev/null +++ b/static/js/data.js @@ -0,0 +1,278 @@ +// All the words. Missions, fake code, chatter, names. +// Everything here is fiction — the only real thing is the vibe. + +export const BOOT_LINES = [ + { t: 'BLACKICE BIOS v4.2.0 — KASTRO LABS THEATRICS DIVISION', d: 200 }, + { t: 'CPU0: 1x DRAMA-9000 @ 4.77 MHz (turbo button: ON)', d: 90 }, + { t: 'MEM CHECK: 65536 KB ............ OK', d: 350 }, + { t: 'DETECTING COOLANT ............. LUKEWARM COFFEE FOUND', d: 160 }, + { t: 'LOADING /dev/cinema ........... OK', d: 140 }, + { t: 'LOADING sunglasses.sys ........ OK (worn indoors)', d: 160 }, + { t: 'MOUNTING IMAGINATION .......... OK', d: 140 }, + { t: 'REALITY DRIVER ................ NOT FOUND', d: 300 }, + { t: ' > this is correct. nothing here touches anything real.', d: 260 }, + { t: 'STARTING WINDOW MANAGER: neon.exe', d: 200 }, + { t: 'ALL SYSTEMS THEATRICAL.', d: 320 }, +]; + +export const HANDLE_FIRST = [ + 'ACID', 'ZERO', 'NEON', 'CRASH', 'GHOST', 'PHANTOM', 'STATIC', 'BINARY', + 'CHROME', 'VOID', 'RAZOR', 'ECHO', 'CIPHER', 'GLITCH', 'TURBO', 'MIDNIGHT', +]; + +export const HANDLE_LAST = [ + 'BURN', 'COOL', 'OVERRIDE', 'PUFFIN', 'VIPER', 'ORACLE', 'SPIDER', 'FALCON', + 'DAEMON', 'RONIN', 'SPECTRE', 'NOVA', 'BYTE', 'WALRUS', 'MIRAGE', 'JAGUAR', +]; + +export const MISSIONS = [ + { + id: 'gibson', + name: 'THE GIBSON', + tagline: 'The most dramatic supercomputer ever filmed.', + brief: 'A skyscraper-sized mainframe with its own light show. Get in, leave a tasteful screensaver, get out.', + host: 'gibson.ellingson.fict', + ip: '10.66.66.7', + city: 'NEW YORK', + threat: 5, + password: 'GOD', + passwordJoke: 'The four most common passwords are: love, sex, secret… and this one.', + files: [ + 'garbage_file.dat', 'da_vinci_virus.src', 'payroll_ledger.fict', + 'cool_screensaver_v2.scr', 'elevator_music.mod', 'blueprints_atrium.cad', + ], + victory: 'YOU ARE RATED: ELITE.', + victorySub: 'The Gibson now boots into synthwave visualizers. Security has been notified and is honestly impressed.', + }, + { + id: 'disco', + name: 'MOONLIGHT-7', + tagline: 'An orbital disco satellite, tragically muted since 1986.', + brief: 'Somebody disabled the groove array. Re-enable it and give the night sky back its mirror ball.', + host: 'moonlight7.orbital.fict', + ip: '10.19.86.7', + city: 'REYKJAVIK', + threat: 3, + password: 'BOOGIE', + passwordJoke: 'Engineering note from 1986: "password is what the satellite does."', + files: [ + 'groove_array.cfg', 'mirrorball.rot', 'funk_reserves.tar', + 'bee_gees_failsafe.wav', 'orbit_sparkle.map', 'last_dance.bak', + ], + victory: 'GROOVE RESTORED.', + victorySub: 'MOONLIGHT-7 is spinning again. Cloud cover tonight: 20% more funky.', + }, + { + id: 'vault', + name: 'OBSIDIAN VAULT', + tagline: 'Where the Bank of Villainy keeps its bonus pool.', + brief: 'Redirect the executive bonus pool of a fictional evil bank to an extremely real-sounding llama sanctuary.', + host: 'vault.villainy.fict', + ip: '10.0.13.13', + city: 'ZURICH', + threat: 4, + password: 'SYNERGY', + passwordJoke: 'Their CISO used the word from every all-hands. Every single one.', + files: [ + 'bonus_pool.ledger', 'yacht_invoices.zip', 'evil_minutes_q3.doc', + 'shell_companies.csv', 'volcano_lair_hoa_fees.pdf', 'llama_routing.cfg', + ], + victory: 'BONUSES REROUTED.', + victorySub: 'The llama sanctuary just became the best-funded petting zoo in fictional Europe.', + }, + { + id: 'bigbrain', + name: 'PROJECT BIGBRAIN', + tagline: 'The Global Genius Database. Yes, there is a leaderboard.', + brief: 'A shadowy registry ranks everyone by alleged IQ. Set every single entry to 160 and watch society improve.', + host: 'bigbrain.shadowy.fict', + ip: '10.16.0.160', + city: 'TOKYO', + threat: 4, + password: 'SMART1', + passwordJoke: 'A genius database protected by a password with a "1" at the end. Poetry.', + files: [ + 'iq_master_table.db', 'leaderboard_cache.idx', 'galaxy_brain.png', + 'mensa_diss_track.mp3', 'humility_patch.diff', 'einstein_entry.row', + ], + victory: 'EVERYONE IS A GENIUS NOW.', + victorySub: 'All 8 billion entries set to 160. The leaderboard is a flat line. Perfect equality achieved.', + }, +]; + +// The hackertyper corpus. Mash any key and this pours out, one chunk at a +// time. Written to read convincingly at a glance and reward anyone who +// actually stops to read it. +export const CODE = `#include +#include +#define MAINFRAME_VIBES 0xC0FFEE +#define FIREWALL_POLITENESS 0 /* we ask nicely exactly zero times */ + +static int breach_counter = 0; /* fictional breaches only */ + +int init_uplink(struct node *gateway) { + if (gateway->is_real) { + return ABORT_IMMEDIATELY; /* house rule: fiction only */ + } + handshake(gateway, PROTOCOL_DRAMATIC); + set_latency(gateway, CINEMATIC_SLOW); + return VIBES_OK; +} + +void spoof_identity(session_t *s) { + s->name = "definitely_a_printer"; + s->location = randomize_city(s->location); + s->trace_resistance += SUNGLASSES_BONUS; +} + +for (int layer = 1; layer <= 7; layer++) { + firewall_t *fw = get_firewall(layer); + if (!try_front_door(fw)) { + try_air_vent(fw); /* movies say this always works */ + } + log_dramatically("LAYER %d DOWN", layer); +} + +def crack_password(target): + wordlist = ["love", "sex", "secret", "god"] # the classics + for guess in wordlist + load("every_word_ever.txt"): + if target.try(guess): + announce("I'M IN", volume=MAX) + return guess + return brute_force(style="one glowing character at a time") + +class NeonTunnel(ProxyChain): + hops = 7 # one per continent, for symmetry + encryption = "military grade, cinema spec" + def route(self, packet): + packet.bounce(times=self.hops) + packet.harmed = False # company policy + return packet + +$ nmap -sV --top-ports 1000 gibson.ellingson.fict +PORT STATE SERVICE +22/tcp open ssh (politely) +80/tcp open http +443/tcp open https +1337/tcp open elite-mode +9999/tcp open plot-device + +$ ssh root@mainframe --auth dramatic-typing +Warning: identity 'definitely_a_printer' accepted without question. +root@mainframe:~# echo $COOLNESS +maximum + +while true; do + inject_payload --type confetti --safety on + rotate_proxies --continents all + sleep 0.$RANDOM # tension +done + +SELECT secrets, dramatic_reveals +FROM mainframe.core +WHERE plot_armor = TRUE +ORDER BY suspense DESC +LIMIT 1; -- save some mystery for the sequel + +memcpy(dest, payload, sizeof(payload)); /* payload is 100% glitter */ +xor_decrypt(buffer, key=0x42); /* the answer, obviously */ +mov eax, 0xDEADBEEF +jmp short cinematic_pause +cinematic_pause: + nop + nop ; let the audience catch up + nop + +async function exfiltrate(files) { + for (const f of files) { + await download(f, { speed: "exactly as fast as the plot needs" }); + progressBar.fill(Math.random() * suspense); + } + return "0 real bytes moved"; +} + +kernel: trace_evasion module loaded (method: being very confident) +kernel: detected operator typing at heroic speed +kernel: granting +10 access per keystroke as per Hollywood convention + +# TODO: ask forgiveness, not permissions +# TODO: stop naming variables after 90s movies (after this one) +# NOTE: if you can read this, you are the hacker now + +override_mainframe(target, { + authority: "none whatsoever", + confidence: 11, + soundtrack: "pulsing synth", +}); + +if (trace.level > TRACE_CRITICAL) { + panic_quietly(); + reroute(through="a fish tank in Reykjavik"); + trace.level = comfortable(); +} + +fn bypass_ice(ice: &BlackIce) -> Result { + match ice.mood() { + Mood::Aggressive => Ok(type_faster()), + Mood::Curious => Ok(distract_with_jazz()), + _ => Ok(Access::Granted), // it usually is + } +} + +uplink: 7 proxies chained. latency: artistic. +uplink: encryption nested 4 deep. readability: zero. drama: high. +core: the mainframe respects you now.`.split('\n'); + +// Lines the system prints between your keystrokes, keyed by stage. +export const CHATTER = { + inject: [ + 'injecting payload… the mainframe suspects nothing', + 'privilege escalating: guest → user → admin → protagonist', + 'memory carved. payload seated. drama rising.', + 'subsystem A trusts us now. subsystem B is jealous.', + 'your typing speed has been classified as "cinematic"', + 'kernel whispers: keep going, this is working', + ], + firewall: [ + 'BLACK ICE COUNTERMEASURES ACTIVE — TYPE TO HOLD THE LINE', + 'trace spike detected — outrun it with raw keystrokes', + 'firewall is fighting back. it has seen the same movies you have.', + 'rerouting through a fish tank in Reykjavik…', + ], + exfil: [ + 'siphoning files at plot-convenient speeds', + 'compressing loot… adding tasteful ascii art to the archive', + 'wiping fingerprints off the metaphors', + 'leaving a polite note for the sysadmin', + ], +}; + +export const RANKS = [ + { minKps: 0.0, name: 'SCRIPT KIDDIE', note: 'You watched. The keyboard did not fear you.' }, + { minKps: 1.5, name: 'KEYBOARD COWBOY', note: 'Respectable mashing. The mainframe noticed you.' }, + { minKps: 3.0, name: 'CONSOLE SAMURAI', note: 'Clean, fast, dramatic. Hollywood would cast you.' }, + { minKps: 5.0, name: 'CYBER PHANTOM', note: 'The trace never stood a chance.' }, + { minKps: 7.0, name: 'ZERO COOL CERTIFIED', note: 'Hack the planet. Hack the planet!' }, +]; + +export const PROXY_CITIES = [ + { name: 'REYKJAVIK', lat: 64.1, lng: -21.9 }, + { name: 'NEW YORK', lat: 40.7, lng: -74.0 }, + { name: 'SAO PAULO', lat: -23.5, lng: -46.6 }, + { name: 'LAGOS', lat: 6.5, lng: 3.4 }, + { name: 'ZURICH', lat: 47.4, lng: 8.5 }, + { name: 'MOSCOW', lat: 55.7, lng: 37.6 }, + { name: 'MUMBAI', lat: 19.1, lng: 72.9 }, + { name: 'SINGAPORE', lat: 1.3, lng: 103.8 }, + { name: 'TOKYO', lat: 35.7, lng: 139.7 }, + { name: 'SYDNEY', lat: -33.9, lng: 151.2 }, + { name: 'CAIRO', lat: 30.0, lng: 31.2 }, + { name: 'TORONTO', lat: 43.7, lng: -79.4 }, +]; + +export const TRACED_EXCUSES = [ + 'They followed the seventh proxy. The fish tank gave you up.', + 'You typed with confidence but not with speed.', + 'The trace team brought their own montage.', + 'Someone answered the decoy phone. Rookie mistake.', +]; diff --git a/static/js/experience.js b/static/js/experience.js deleted file mode 100644 index c88201a..0000000 --- a/static/js/experience.js +++ /dev/null @@ -1,177 +0,0 @@ -const ACHIEVEMENTS = { - start: ['MAINFRAME VIBES ACQUIRED', 'You pushed the big cinematic button. Bold.'], - screenshot: ['SCREENSHOT BAIT READY', 'Animations paused just enough for social proof.'], - boss: ['MANAGER PROXIMITY DETECTED', 'Quarterly infrastructure hygiene camouflage enabled.'], - share: ['SHARE LINK COPIED', 'A harmless simulator link is ready for the group chat.'], - theme: ['AESTHETIC SWITCHER', 'The hacker movie now has lighting direction.'], - disclaimer: ['I READ THE DISCLAIMER', 'Security culture begins with not pretending toys are weapons.'], -}; - -function slugCodename(value) { - return (value || 'NO-PACKETS-HARMED') - .toUpperCase() - .replace(/[^A-Z0-9 -]/g, '') - .replace(/\s+/g, '-') - .slice(0, 28) || 'NO-PACKETS-HARMED'; -} - -export function setupExperience({ onStart, onWow }) { - const body = document.body; - const hackBtn = document.getElementById('hack-btn'); - const copyShareBtn = document.getElementById('copy-share'); - const screenshotBtn = document.getElementById('screenshot-mode'); - const bossBtn = document.getElementById('boss-mode'); - const exitBossBtn = document.getElementById('exit-boss'); - const aboutBtn = document.getElementById('about-toggle'); - const aboutModal = document.getElementById('about-modal'); - const codenameInput = document.getElementById('codename-input'); - const shareOverlay = document.getElementById('share-overlay'); - const shareUrl = document.getElementById('share-url'); - const shareCopyStatus = document.getElementById('share-copy-status'); - const bossScreen = document.getElementById('boss-screen'); - const achievementStack = document.getElementById('achievement-stack'); - const themeChips = [...document.querySelectorAll('[data-theme-choice]')]; - - const params = new URLSearchParams(window.location.search); - const initialTheme = params.get('mode') || 'vhs'; - const initialCodename = slugCodename(params.get('codename') || codenameInput.value); - - function toast(key) { - const [title, copy] = ACHIEVEMENTS[key] || ACHIEVEMENTS.start; - const item = document.createElement('div'); - item.className = 'achievement'; - item.innerHTML = `ACHIEVEMENT UNLOCKED${title}${copy}`; - achievementStack.appendChild(item); - setTimeout(() => item.classList.add('visible'), 20); - setTimeout(() => { - item.classList.remove('visible'); - setTimeout(() => item.remove(), 350); - }, 4200); - } - - function applyTheme(theme) { - body.dataset.theme = theme; - themeChips.forEach(chip => { - const isActive = chip.dataset.themeChoice === theme; - chip.classList.toggle('active', isActive); - chip.setAttribute('aria-pressed', String(isActive)); - }); - } - - function isTypingTarget(target) { - return target?.matches?.('input, textarea, select, [contenteditable="true"]'); - } - - function updateShareUrl() { - const url = new URL(window.location.href); - url.searchParams.set('mode', body.dataset.theme || 'vhs'); - url.searchParams.set('codename', slugCodename(codenameInput.value).toLowerCase()); - history.replaceState(null, '', url); - if (shareUrl) shareUrl.textContent = url.href; - return url.href; - } - - async function copyShareLink({ showToast = true } = {}) { - const url = updateShareUrl(); - let message = 'Share link ready. Copy it from screenshot mode if browser copy is blocked.'; - if (navigator.clipboard) { - try { - await navigator.clipboard.writeText(url); - message = 'Share link copied. Toy interface only — no real targets.'; - } catch (_) { - message = 'Share link ready; browser copy permission was blocked.'; - } - } - if (shareCopyStatus) shareCopyStatus.textContent = message; - if (copyShareBtn) copyShareBtn.textContent = message.includes('copied') ? 'Link copied' : 'Copy share link'; - if (showToast) toast('share'); - if (copyShareBtn) setTimeout(() => { copyShareBtn.textContent = 'Copy share link'; }, 1800); - return message; - } - - applyTheme(initialTheme); - codenameInput.value = initialCodename; - updateShareUrl(); - - codenameInput.addEventListener('input', () => { - codenameInput.value = slugCodename(codenameInput.value); - updateShareUrl(); - }); - - themeChips.forEach(chip => chip.addEventListener('click', () => { - applyTheme(chip.dataset.themeChoice); - updateShareUrl(); - toast('theme'); - })); - - hackBtn.addEventListener('click', () => { - toast('start'); - onStart({ codename: slugCodename(codenameInput.value), theme: body.dataset.theme || 'vhs' }); - }); - - copyShareBtn.addEventListener('click', () => copyShareLink()); - - screenshotBtn.addEventListener('click', async () => { - body.classList.toggle('screenshot-ready'); - const isScreenshotReady = body.classList.contains('screenshot-ready'); - shareOverlay.hidden = !isScreenshotReady; - screenshotBtn.textContent = isScreenshotReady ? 'Exit screenshot mode' : 'Screenshot mode'; - screenshotBtn.setAttribute('aria-pressed', String(isScreenshotReady)); - toast('screenshot'); - updateShareUrl(); - if (shareCopyStatus) { - shareCopyStatus.textContent = isScreenshotReady - ? 'Share link ready. Copy permission depends on your browser.' - : 'Screenshot mode freezes the frame and copies the share link when allowed.'; - } - if (isScreenshotReady) await copyShareLink({ showToast: false }); - }); - - bossBtn.addEventListener('click', () => { - bossScreen.hidden = false; - bossBtn.setAttribute('aria-pressed', 'true'); - toast('boss'); - }); - - exitBossBtn.addEventListener('click', () => { - bossScreen.hidden = true; - bossBtn.setAttribute('aria-pressed', 'false'); - }); - - aboutBtn.addEventListener('click', () => { - if (aboutModal?.showModal) aboutModal.showModal(); - else aboutModal.setAttribute('open', ''); - toast('disclaimer'); - }); - - document.addEventListener('keydown', (event) => { - if (isTypingTarget(event.target)) { - if (event.key === 'Enter') hackBtn.click(); - if (event.key !== 'Escape') return; - } - if (event.key.toLowerCase() === 'b') { - bossScreen.hidden = !bossScreen.hidden; - bossBtn.setAttribute('aria-pressed', String(!bossScreen.hidden)); - } - if (event.key.toLowerCase() === 'c') copyShareLink(); - if (event.key.toLowerCase() === 's') screenshotBtn.click(); - if (event.key.toLowerCase() === 'w') { - onWow?.(); - toast('start'); - } - if (event.key === 'Escape') { - bossScreen.hidden = true; - bossBtn.setAttribute('aria-pressed', 'false'); - if (body.classList.contains('screenshot-ready')) screenshotBtn.click(); - } - }); - - return { - getCodename: () => slugCodename(codenameInput.value), - setRunning(isRunning) { - body.classList.toggle('simulation-running', isRunning); - hackBtn.disabled = isRunning; - hackBtn.classList.toggle('running', isRunning); - }, - }; -} diff --git a/static/js/fx.js b/static/js/fx.js new file mode 100644 index 0000000..c2b3665 --- /dev/null +++ b/static/js/fx.js @@ -0,0 +1,108 @@ +// Visual effects: matrix rain background, screen flashes, text scramble. + +const REDUCED = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +export class MatrixRain { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.chars = 'アカサタナハマヤラワ0123456789ABCDEF$#%&@'; + this.alarm = false; + this.running = false; + this._resize = () => this.resize(); + window.addEventListener('resize', this._resize); + this.resize(); + } + + resize() { + this.canvas.width = window.innerWidth; + this.canvas.height = window.innerHeight; + this.fontSize = Math.max(12, Math.round(window.innerWidth / 110)); + this.cols = Math.ceil(this.canvas.width / this.fontSize); + this.drops = Array.from({ length: this.cols }, () => + Math.random() * -this.canvas.height / this.fontSize); + } + + setAlarm(on) { + this.alarm = on; + } + + start() { + if (this.running || REDUCED) return; + this.running = true; + let last = 0; + const loop = (t) => { + if (!this.running) return; + if (t - last > 50) { + this.step(); + last = t; + } + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + } + + stop() { + this.running = false; + } + + step() { + const { ctx, canvas, fontSize } = this; + ctx.fillStyle = 'rgba(2, 8, 4, 0.12)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.font = `${fontSize}px monospace`; + ctx.fillStyle = this.alarm ? 'rgba(255, 45, 85, 0.5)' : 'rgba(57, 255, 20, 0.5)'; + for (let i = 0; i < this.drops.length; i++) { + const ch = this.chars[(Math.random() * this.chars.length) | 0]; + ctx.fillText(ch, i * fontSize, this.drops[i] * fontSize); + if (this.drops[i] * fontSize > canvas.height && Math.random() > 0.975) { + this.drops[i] = 0; + } + this.drops[i] += 1; + } + } +} + +// Full-screen color flash (firewall hits, access granted). +export function flash(color = 'rgba(57,255,20,0.25)', ms = 180) { + if (REDUCED) return; + const el = document.getElementById('flash'); + el.style.transition = 'none'; + el.style.background = color; + el.style.opacity = '1'; + requestAnimationFrame(() => { + el.style.transition = `opacity ${ms}ms ease-out`; + el.style.opacity = '0'; + }); +} + +export function shake(el = document.body, ms = 350) { + if (REDUCED) return; + el.classList.remove('fx-shake'); + void el.offsetWidth; // restart animation + el.classList.add('fx-shake'); + setTimeout(() => el.classList.remove('fx-shake'), ms); +} + +// Scramble-in text reveal for big dramatic headings. +export function scrambleIn(el, text, ms = 900) { + if (REDUCED) { + el.textContent = text; + return; + } + const glyphs = '!<>-_\\/[]{}—=+*^?#$%&'; + const start = performance.now(); + const tick = (t) => { + const p = Math.min(1, (t - start) / ms); + const settled = Math.floor(text.length * p); + let out = text.slice(0, settled); + for (let i = settled; i < text.length; i++) { + out += text[i] === ' ' ? ' ' : glyphs[(Math.random() * glyphs.length) | 0]; + } + el.textContent = out; + if (p < 1) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +} + +export const reducedMotion = REDUCED; diff --git a/static/js/hack.js b/static/js/hack.js deleted file mode 100644 index da76701..0000000 --- a/static/js/hack.js +++ /dev/null @@ -1,10 +0,0 @@ -// Legacy module intentionally neutralised. -// The current Hack the Planet experience lives in /js/main.js as a harmless -// cyberpunk arcade ritual: no real targets, no packets, just movie logic. -export function createHackExperience() { - return { - start() { - return 'Legacy experience retired. Boot the arcade cabinet instead.'; - }, - }; -} diff --git a/static/js/hack3d.js b/static/js/hack3d.js deleted file mode 100644 index 2257e65..0000000 --- a/static/js/hack3d.js +++ /dev/null @@ -1,10 +0,0 @@ -// Legacy 3D module intentionally neutralised. -// Kept as a harmless compatibility shim for old cache paths. -export function createHack3D() { - return { - start() { - return 'Legacy 3D theatre retired. No real systems touched.'; - }, - stop() {}, - }; -} diff --git a/static/js/hexdump.js b/static/js/hexdump.js deleted file mode 100644 index d8c8153..0000000 --- a/static/js/hexdump.js +++ /dev/null @@ -1,27 +0,0 @@ -const MAX_LINES = 200; - -export function initHexDump(container) { - let lineCount = 0; - - return function onHex(data) { - const line = document.createElement('div'); - line.className = 'hex-line'; - line.innerHTML = - '' + data.offset + ' ' + - '' + data.hex + '' + - '' + escapeHtml(data.ascii) + ''; - container.appendChild(line); - lineCount++; - - if (lineCount > MAX_LINES) { - container.removeChild(container.firstChild); - lineCount--; - } - - container.scrollTop = container.scrollHeight; - }; -} - -function escapeHtml(s) { - return s.replace(/&/g, '&').replace(//g, '>'); -} diff --git a/static/js/main.js b/static/js/main.js index 8bba9ea..f1d46e5 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1,422 +1,412 @@ -(() => { - 'use strict'; - - const $ = (selector) => document.querySelector(selector); - const $$ = (selector) => Array.from(document.querySelectorAll(selector)); - - const state = { - screen: 'boot', - missionKey: 'hack', - step: 0, - running: false, - logic: 0, - transcript: [], - operator: '', - particles: [], - t: 0, - }; - - const missions = { - hack: { - title: 'Hack the Planet', - code: 'HTP-01', - brief: 'Overthrow a prop mainframe with dramatic lighting and suspiciously fast typing.', - operatorPool: ['GHOST PUFFIN', 'NEON SKALD', 'NULL VIKING', 'CAPTAIN 2600', 'SUDO RAVEN'], - buttons: ['Acquire vibes', 'Route through movie logic', 'Deploy toy trick', 'Overclock mainframe', 'Hack the planet'], - steps: [ - 'Acquire mainframe vibes from a prop satellite.', - 'Route through movie logic and three unnecessary maps.', - 'Deploy a toy trick against the cinematic layer.', - 'Overclock the fake mainframe until typography shakes.', - 'Print trophy screen and spare reality from involvement.', - ], - readouts: [ - 'Mainframe vibes acquired.', - 'Movie logic accepts the premise.', - 'Toy trick failed successfully.', - 'Planet vulnerable to dramatic lighting.', - 'Planet hacked in fiction only.', - ], - method: '37% movie logic, 42% YAML, 21% Kastro yellow', - verdict: 'Stylish, harmless, aggressively non-compliant with reality.', - }, - save: { - title: 'Save the Planet', - code: 'HTP-02', - brief: 'Patch the neon infrastructure before the montage becomes management policy.', - operatorPool: ['PATCH WIZARD', 'CONSENT BADGER', 'CAPTAIN SCOPE', 'LOG HERDER', 'CTRL ALT SHEEP'], - buttons: ['Open change window', 'Patch vibes', 'Rotate toy secrets', 'Document everything', 'Save the planet'], - steps: [ - 'Open an emergency change window in the imagination zone.', - 'Patch vulnerable vibes before they become conference slides.', - 'Rotate toy secrets that were never secrets to begin with.', - 'Write a remediation note with suspiciously good margins.', - 'Close the incident with zero real systems touched.', - ], - readouts: [ - 'Change window approved by movie logic.', - 'Vibes patched. Neon stable.', - 'Toy secrets rotated into confetti.', - 'Boring logs save the day.', - 'Planet saved in fiction only.', - ], - method: '51% patch notes, 29% consent, 20% dramatic restraint', - verdict: 'Heroic, documented, aggressively harmless.', - }, - confuse: { - title: 'Confuse the Planet', - code: 'HTP-03', - brief: 'Deploy jazz, rubber ducks, malformed YAML, and one legally distinct trench coat.', - operatorPool: ['YAML GOBLIN', 'DUCK IN THE SHELL', 'JAZZ ROOT', 'BORK ORACLE', 'TRÖLLWARE INTERN'], - buttons: ['Summon duck', 'Inject jazz', 'Malformed YAML', 'Ask the trench coat', 'Confuse planet'], - steps: [ - 'Summon advisory rubber duck and wait for it to blink first.', - 'Inject modal jazz into the soundtrack of the fake terminal.', - 'Malformed YAML achieves sentience, then apologises.', - 'Ask the trench coat if this is cyber enough.', - 'Confuse the planet until it voluntarily enters safe mode.', - ], - readouts: [ - 'Duck has opinions.', - 'Jazz accepted. Time signature unstable.', - 'YAML indentation threatening discourse.', - 'Trench coat says: probably.', - 'Planet confused in fiction only.', - ], - method: '33% ducks, 33% jazz, 33% YAML, 1% governance', - verdict: 'Absurd, reversible, aesthetically suspicious.', - }, - }; - - const els = { - bootPanel: $('#boot-panel'), - bootButton: $('#boot-button'), - missionSelect: $('#mission-select'), - stage: $('#stage'), - report: $('#report'), - ritualButton: $('#ritual-button'), - stageTitle: $('#stage-title'), - stageBrief: $('#stage-brief'), - missionCode: $('#mission-code'), - stepList: $('#step-list'), - logicMeter: $('#logic-meter'), - logicValue: $('#logic-value'), - readout: $('#planet-readout'), - terminal: $('#terminal'), - reportOperator: $('#report-operator'), - reportMission: $('#report-mission'), - reportMethod: $('#report-method'), - reportVerdict: $('#report-verdict'), - reportTitle: $('#report-title'), - copyReport: $('#copy-report'), - copyLog: $('#copy-log'), - newRun: $('#new-run'), - resetRun: $('#reset-run'), - toastStack: $('#toast-stack'), - clock: $('#clock'), - }; - - function show(screen) { - state.screen = screen; - els.bootPanel.hidden = screen !== 'boot'; - els.missionSelect.hidden = screen !== 'missions'; - els.stage.hidden = screen !== 'stage'; - els.report.hidden = screen !== 'report'; - requestAnimationFrame(() => window.scrollTo({ top: 0, behavior: 'smooth' })); +// Hack the Planet — orchestrator. +// boot → login → mission select → the run → access granted → debrief. + +import { + BOOT_LINES, HANDLE_FIRST, HANDLE_LAST, MISSIONS, CHATTER, RANKS, + PROXY_CITIES, TRACED_EXCUSES, +} from './data.js'; +import { Terminal, Cracker, Exfil } from './panels.js'; +import { WorldMap } from './worldmap.js'; +import { MatrixRain, flash, shake, scrambleIn, reducedMotion } from './fx.js'; +import { synth } from './audio.js'; + +const $ = (sel) => document.querySelector(sel); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const pick = (arr) => arr[(Math.random() * arr.length) | 0]; + +const state = { + screen: 'boot', + handle: 'GHOST PUFFIN', + mission: null, + stage: 'idle', + keys: 0, + access: 0, + trace: 0, + tracePeak: 0, + startTime: 0, + runToken: 0, // bumped to cancel a run's async loops +}; + +const rain = new MatrixRain($('#rain')); +const map = new WorldMap($('#map-canvas')); +const term = new Terminal($('#terminal')); +const cracker = new Cracker($('#crack-display')); +const exfil = new Exfil($('#exfil-list')); + +// ---------------------------------------------------------------- screens + +function show(name) { + state.screen = name; + document.querySelectorAll('.screen').forEach((s) => { + s.classList.toggle('active', s.id === `screen-${name}`); + }); + if (name === 'run') { + map.resize(); + map.start(); + } else { + map.stop(); + } +} + +// ------------------------------------------------------------------ boot + +async function boot() { + show('boot'); + rain.start(); + const log = $('#boot-log'); + log.innerHTML = ''; + let skipped = false; + const skip = () => { skipped = true; }; + window.addEventListener('keydown', skip, { once: true }); + window.addEventListener('pointerdown', skip, { once: true }); + + for (const line of BOOT_LINES) { + const div = document.createElement('div'); + div.textContent = line.t; + log.appendChild(div); + synth.beep(); + if (!skipped) await sleep(reducedMotion ? 40 : line.d); + } + window.removeEventListener('keydown', skip); + window.removeEventListener('pointerdown', skip); + await sleep(skipped ? 150 : 600); + login(); +} + +// ----------------------------------------------------------------- login + +function randomHandle() { + return `${pick(HANDLE_FIRST)} ${pick(HANDLE_LAST)}`; +} + +function login() { + show('login'); + const input = $('#handle-input'); + input.value = ''; + input.placeholder = randomHandle(); + setTimeout(() => input.focus(), 50); +} + +function submitHandle() { + const input = $('#handle-input'); + const raw = (input.value || input.placeholder).trim().toUpperCase(); + state.handle = raw.slice(0, 24) || 'GHOST PUFFIN'; + synth.beep(); + missions(); +} + +// -------------------------------------------------------------- missions + +function missions() { + show('missions'); + $('#missions-greeting').textContent = `WELCOME, ${state.handle}.`; + const grid = $('#mission-grid'); + if (grid.childElementCount) return; + for (const m of MISSIONS) { + const card = document.createElement('button'); + card.className = 'mission-card'; + card.type = 'button'; + card.innerHTML = + `${'▰'.repeat(m.threat)}${'▱'.repeat(5 - m.threat)} THREAT` + + `${m.name}` + + `${m.tagline}` + + `

${m.brief}

` + + `${m.host} · ${m.ip}`; + card.addEventListener('click', () => startRun(m)); + grid.appendChild(card); + } +} + +// ----------------------------------------------------------------- input + +// Any key (or tap on the mash button) is a "hack keystroke" during the run. +function onHackKey(e) { + if (state.screen !== 'run') return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key === 'Escape') return; + if (e.key && e.key.length > 1 && e.key !== 'Enter' && e.key !== 'Backspace' && e.key !== 'Tab') return; + if (e.key === ' ' || e.key === 'Tab') e.preventDefault(); + registerKeystroke(); +} + +function registerKeystroke() { + const s = state.stage; + if (!['inject', 'firewall', 'crack', 'exfil'].includes(s)) return; + state.keys += 1; + synth.key(); + term.emitCode(3); + + if (s === 'inject') { + setAccess(state.access + 0.8); + if (state.keys % 14 === 0) term.line(`>> ${pick(CHATTER.inject)}`); + } else if (s === 'firewall') { + setTrace(state.trace - 2.4); + setAccess(state.access + 0.12); + } else if (s === 'crack') { + setAccess(Math.min(80, state.access + 0.1)); + } else if (s === 'exfil') { + exfil.boost(); + setAccess(Math.min(99, state.access + 0.25)); + if (state.keys % 18 === 0) term.line(`>> ${pick(CHATTER.exfil)}`); + } +} + +window.addEventListener('keydown', onHackKey); +$('#mash').addEventListener('pointerdown', (e) => { + e.preventDefault(); + registerKeystroke(); +}); + +// ---------------------------------------------------------------- meters + +function setAccess(v) { + state.access = Math.max(0, Math.min(100, v)); + $('#access-bar').style.width = `${state.access}%`; + $('#access-pct').textContent = `${Math.floor(state.access)}%`; +} + +function setTrace(v) { + state.trace = Math.max(0, Math.min(100, v)); + state.tracePeak = Math.max(state.tracePeak, state.trace); + $('#trace-bar').style.width = `${state.trace}%`; + $('#trace-pct').textContent = `${Math.floor(state.trace)}%`; +} + +function setStage(stage, label, hint) { + state.stage = stage; + $('#stage-label').textContent = label; + $('#hint').textContent = hint; +} + +function setAlarm(on) { + document.body.classList.toggle('alarm', on); + map.setAlarm(on); + rain.setAlarm(on); +} + +// ------------------------------------------------------------------ run + +function buildRoute(mission) { + const target = PROXY_CITIES.find((c) => c.name === mission.city) || + { name: mission.city, lat: 47.4, lng: 8.5 }; + const hops = PROXY_CITIES + .filter((c) => c.name !== target.name) + .sort(() => Math.random() - 0.5) + .slice(0, 5); + return [...hops, target]; +} + +async function startRun(mission) { + state.mission = mission; + state.keys = 0; + state.tracePeak = 0; + state.startTime = performance.now(); + const token = ++state.runToken; + + show('run'); + setAlarm(false); + setAccess(0); + setTrace(0); + term.clear(); + cracker.idle(); + exfil.load(mission.files); + $('#run-target').textContent = `${mission.name} · ${mission.host} · ${mission.ip}`; + $('#run-operator').textContent = `OPERATOR: ${state.handle}`; + + const route = buildRoute(mission); + map.setRoute(route); + + // STAGE 1 — connect: the proxy chain assembles itself for drama. + setStage('connect', 'STAGE 1/5 · UPLINK', 'establishing proxy chain…'); + await term.typeLine(`$ uplink --target ${mission.host} (${mission.ip}) --drama max`, 'cmd'); + for (let i = 0; i < route.length - 1; i++) { + if (token !== state.runToken) return; + await sleep(reducedMotion ? 80 : 650); + map.revealNextHop(); + synth.hop(); + term.line(` hop ${i + 1}/${route.length - 1} ➜ ${route[i + 1].name} [encrypted, fictional]`); + } + await sleep(400); + if (token !== state.runToken) return; + term.line('>> PROXY CHAIN ESTABLISHED. You are everywhere and nowhere.', 'good'); + + // STAGE 2 — inject: the keyboard becomes the weapon. + setStage('inject', 'STAGE 2/5 · INJECT', 'TYPE ANYTHING — any keys work. seriously, mash.'); + term.line('>> START TYPING TO INJECT CODE. The mainframe cannot tell the difference.', 'good'); + while (state.access < 45) { + if (token !== state.runToken) return; + await sleep(120); + } + + // STAGE 3 — firewall counterattack: survive the trace. + const survived = await firewallBattle(token); + if (token !== state.runToken) return; + if (!survived) return traced(); + + // STAGE 4 — crack the password. + setStage('crack', 'STAGE 3/5 · CRACK', 'brute-forcing… keep typing for style points'); + term.line('$ crack --wordlist classics.txt --style "one glowing character at a time"', 'cmd'); + await cracker.run(mission.password, reducedMotion ? 800 : 5200, () => synth.crack()); + if (token !== state.runToken) return; + flash('rgba(57,255,20,0.18)'); + term.line(`>> PASSWORD ACCEPTED: "${mission.password}"`, 'good'); + term.line(` ${mission.passwordJoke}`); + await sleep(900); + + // STAGE 5 — exfil. + setStage('exfil', 'STAGE 4/5 · EXFIL', 'keep typing — every keystroke pulls files faster'); + term.line('$ exfil --everything --gently', 'cmd'); + await new Promise((resolve) => exfil.start(resolve)); + if (token !== state.runToken) return; + setAccess(100); + granted(); +} + +async function firewallBattle(token) { + setStage('firewall', 'STAGE !/5 · FIREWALL', 'COUNTERATTACK! TYPE FASTER TO BEAT THE TRACE!'); + setAlarm(true); + synth.alarm(); + flash('rgba(255,45,85,0.3)'); + shake(); + term.line(`!! ${pick(CHATTER.firewall)}`, 'bad'); + + const duration = reducedMotion ? 3000 : 7500; + const start = performance.now(); + let lastAlarm = 0; + while (performance.now() - start < duration) { + if (token !== state.runToken) return false; + // Idle hands get traced in ~5.5s; steady mashing holds the line. + setTrace(state.trace + 1.75); + if (state.trace >= 100) { + setAlarm(false); + return false; } - - function toast(message) { - const item = document.createElement('div'); - item.className = 'toast'; - item.textContent = message; - els.toastStack.appendChild(item); - setTimeout(() => item.remove(), 4200); - } - - function log(text, className = '') { - const line = document.createElement('div'); - line.className = `term-line ${className}`.trim(); - line.textContent = text; - els.terminal.appendChild(line); - els.terminal.scrollTop = els.terminal.scrollHeight; - state.transcript.push(text); - } - - function pick(array) { return array[Math.floor(Math.random() * array.length)]; } - - function currentMission() { return missions[state.missionKey]; } - - function setLogic(value) { - state.logic = Math.max(0, Math.min(100, value)); - els.logicMeter.style.width = `${state.logic}%`; - els.logicValue.textContent = `${Math.round(state.logic)}%`; - } - - function renderSteps() { - const mission = currentMission(); - els.stepList.innerHTML = mission.steps.map((step, index) => { - const status = index < state.step ? 'done' : index === state.step ? 'active' : ''; - return `
  • ${step}
  • `; - }).join(''); - } - - function setButtonText() { - const mission = currentMission(); - const text = mission.buttons[Math.min(state.step, mission.buttons.length - 1)]; - els.ritualButton.querySelector('span').textContent = text; - } - - function startMission(key) { - state.missionKey = key; - state.step = 0; - state.logic = 0; - state.running = false; - state.transcript = []; - const mission = currentMission(); - state.operator = pick(mission.operatorPool); - - els.stageTitle.textContent = mission.title; - els.stageBrief.textContent = mission.brief; - els.missionCode.textContent = mission.code; - els.readout.textContent = 'Awaiting operator ritual.'; - els.terminal.innerHTML = ''; - setLogic(0); - renderSteps(); - setButtonText(); - show('stage'); - log(`KASTRO LABS // ${mission.code} // REALITY LOCK GREEN`, 'warn'); - log(`operator = ${state.operator}`, 'bone'); - log('network_io = 0; packets_harmed = 0; crimes = 0', 'muted'); - log(`mission = ${mission.title}`); - toast(`${mission.title} loaded. Big yellow button armed.`); - } - - function performStep() { - if (state.running) return; - const mission = currentMission(); - if (state.step >= mission.steps.length) return finishRun(); - - state.running = true; - document.body.classList.add('simulation-running'); - els.ritualButton.disabled = true; - const stepIndex = state.step; - const command = mission.buttons[stepIndex].toLowerCase().replaceAll(' ', '-'); - log(`> ${command}`, 'warn'); - els.readout.textContent = mission.readouts[stepIndex]; - toast(mission.readouts[stepIndex]); - - const bursts = [ - 'rendering local pixels; no sockets opened', - 'cinematic layer responding with unnecessary confidence', - 'dramatic typing synthesised in memory', - 'reality lock remains green', - ]; - bursts.forEach((line, index) => { - setTimeout(() => log(` ${line}`, index === 3 ? 'muted' : ''), 170 + index * 180); - }); - - setTimeout(() => { - state.step += 1; - setLogic((state.step / mission.steps.length) * 100); - renderSteps(); - state.running = false; - document.body.classList.remove('simulation-running'); - els.ritualButton.disabled = false; - if (state.step >= mission.steps.length) { - setButtonText(); - finishRun(); - } else { - setButtonText(); - } - }, 930); - } - - function finishRun() { - const mission = currentMission(); - state.step = mission.steps.length; - setLogic(100); - renderSteps(); - els.readout.textContent = `${mission.title}: complete. Reality untouched.`; - log('> print-hacker-movie-report', 'warn'); - log('RESULT: planet dramatically handled in fiction only', 'bone'); - log('DAMAGE: 0 packets harmed', 'muted'); - els.reportOperator.textContent = state.operator; - els.reportMission.textContent = mission.title; - els.reportMethod.textContent = mission.method; - els.reportVerdict.textContent = mission.verdict; - els.reportTitle.textContent = `${mission.title} complete. Reality untouched.`; - els.toastStack.innerHTML = ''; - setTimeout(() => show('report'), 760); - } - - function resetAll() { - state.step = 0; - state.logic = 0; - state.running = false; - state.transcript = []; - show('boot'); - toast('Cabinet reset. Reality still safe. Obviously.'); + const now = performance.now(); + if (now - lastAlarm > 1300) { + synth.alarm(); + term.line(`!! ${pick(CHATTER.firewall)}`, 'bad'); + lastAlarm = now; } - - function reportText() { - const mission = currentMission(); - return [ - '⚡ Kastro Labs // Hacker Movie Report ⚡', - `Operator: ${state.operator}`, - `Mission: ${mission.title}`, - `Method: ${mission.method}`, - 'Damage: 0 packets harmed · 0 real targets · 0 crimes', - `Verdict: ${mission.verdict}`, - 'Note: This was a harmless browser toy and screenshot ritual. No real targets, packets, secrets, or systems were touched.', - ].join('\n'); - } - - async function copyText(text, success) { - try { - await navigator.clipboard.writeText(text); - toast(success); - } catch (err) { - const ta = document.createElement('textarea'); - ta.value = text; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - ta.remove(); - toast(success); - } - } - - function sizeCanvas(canvas) { - const rect = canvas.getBoundingClientRect(); - const dpr = Math.min(2, window.devicePixelRatio || 1); - canvas.width = Math.max(1, Math.floor(rect.width * dpr)); - canvas.height = Math.max(1, Math.floor(rect.height * dpr)); - const ctx = canvas.getContext('2d'); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - return { ctx, width: rect.width, height: rect.height }; - } - - function seedParticles() { - state.particles = Array.from({ length: 140 }, () => ({ - x: Math.random(), - y: Math.random(), - s: .4 + Math.random() * 2.3, - v: .18 + Math.random() * .88, - h: Math.random(), - })); - } - - function drawAtmosphere() { - const canvas = $('#atmosphere'); - const { ctx, width, height } = sizeCanvas(canvas); - ctx.clearRect(0, 0, width, height); - ctx.fillStyle = 'rgba(0,0,0,.08)'; - ctx.fillRect(0, 0, width, height); - ctx.font = '13px ui-monospace, monospace'; - state.particles.forEach((p, index) => { - p.y += p.v / Math.max(500, height); - if (p.y > 1.05) { p.y = -.05; p.x = Math.random(); } - const glyph = index % 17 === 0 ? 'HTP' : index % 11 === 0 ? '0' : index % 7 === 0 ? '1' : '·'; - ctx.fillStyle = index % 9 === 0 ? 'rgba(255,212,0,.45)' : 'rgba(124,255,139,.25)'; - ctx.fillText(glyph, p.x * width, p.y * height); - }); - requestAnimationFrame(drawAtmosphere); - } - - function drawPlanet() { - const canvas = $('#planet-canvas'); - const { ctx, width, height } = sizeCanvas(canvas); - state.t += .018; - ctx.clearRect(0, 0, width, height); - - const cx = width / 2; - const cy = height / 2; - const r = Math.min(width, height) * .235; - const mission = currentMission(); - const pulse = 1 + Math.sin(state.t * 4) * .025 + state.logic / 1000; - - ctx.fillStyle = '#050505'; - ctx.fillRect(0, 0, width, height); - - ctx.strokeStyle = 'rgba(255,212,0,.18)'; - ctx.lineWidth = 1; - for (let x = -40; x < width + 40; x += 42) { - ctx.beginPath(); ctx.moveTo(x + Math.sin(state.t + x) * 8, 0); ctx.lineTo(x - 60, height); ctx.stroke(); - } - for (let y = 28; y < height; y += 42) { - ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y + Math.sin(state.t + y) * 8); ctx.stroke(); - } - - ctx.save(); - ctx.translate(cx, cy); - ctx.scale(pulse, pulse); - ctx.strokeStyle = 'rgba(247,240,216,.28)'; - ctx.lineWidth = 2; - for (let i = -4; i <= 4; i += 1) { - ctx.beginPath(); - ctx.ellipse(0, 0, r, Math.max(3, Math.abs(r * i / 4)), state.t * .65, 0, Math.PI * 2); - ctx.stroke(); - } - for (let i = 0; i < 9; i += 1) { - const a = state.t * .7 + i * Math.PI / 9; - ctx.beginPath(); - ctx.ellipse(0, 0, Math.abs(Math.cos(a)) * r, r, 0, 0, Math.PI * 2); - ctx.stroke(); - } - ctx.strokeStyle = '#ffd400'; - ctx.shadowColor = '#ffd400'; - ctx.shadowBlur = 20; - ctx.lineWidth = 3; - const arcs = 4 + Math.floor(state.logic / 18); - for (let i = 0; i < arcs; i += 1) { - const a = state.t * (1.2 + i * .03) + i * .86; - const x1 = Math.cos(a) * r * .92; - const y1 = Math.sin(a * 1.4) * r * .52; - const x2 = Math.cos(a + 1.4) * r * .92; - const y2 = Math.sin((a + 1.4) * 1.4) * r * .52; - ctx.beginPath(); - ctx.moveTo(x1, y1); - ctx.quadraticCurveTo(Math.sin(a) * r * .35, -r * 1.15, x2, y2); - ctx.stroke(); - } - ctx.shadowBlur = 0; - ctx.fillStyle = state.logic >= 100 ? '#7cff8b' : '#ffd400'; - ctx.beginPath(); - ctx.arc(0, 0, Math.max(8, r * .08 + state.logic * .11), 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - - ctx.fillStyle = '#ffd400'; - ctx.font = '900 12px ui-monospace, monospace'; - ctx.fillText(`${mission.code} // ${mission.title.toUpperCase()}`, 18, height - 74); - ctx.fillStyle = 'rgba(247,240,216,.72)'; - ctx.fillText('REAL TARGETS: 0', 18, height - 52); - ctx.fillText('PACKETS HARMED: 0', 18, height - 32); - requestAnimationFrame(drawPlanet); - } - - function tickClock() { - els.clock.textContent = `${new Date().toLocaleTimeString('en-GB', { timeZone: 'UTC', hour12: false })} UTC`; - } - - function initEvents() { - els.bootButton.addEventListener('click', () => { show('missions'); toast('Toy mainframe booted. Pick your fiction.'); }); - $$('.mission-card').forEach((card) => card.addEventListener('click', () => startMission(card.dataset.mission))); - els.ritualButton.addEventListener('click', performStep); - els.copyReport.addEventListener('click', () => copyText(reportText(), 'Report copied. Use irresponsibly only in fiction.')); - els.copyLog.addEventListener('click', () => copyText(state.transcript.join('\n'), 'Operator transcript copied.')); - els.newRun.addEventListener('click', () => { show('missions'); toast('Run archived in imagination. Choose again.'); }); - els.resetRun.addEventListener('click', resetAll); - } - - function init() { - seedParticles(); - initEvents(); - tickClock(); - setInterval(tickClock, 1000); - drawAtmosphere(); - drawPlanet(); - } - - init(); + await sleep(95); + } + setAlarm(false); + flash('rgba(57,255,20,0.2)'); + term.line('>> FIREWALL DOWN. The black ice melts politely.', 'good'); + // Residual heat bleeds off. + const decay = setInterval(() => { + setTrace(state.trace - 4); + if (state.trace <= 0) clearInterval(decay); + }, 100); + return true; +} + +// --------------------------------------------------------------- endings + +async function granted() { + setStage('granted', 'STAGE 5/5 · IN', ''); + synth.granted(); + flash('rgba(57,255,20,0.45)', 500); + show('splash'); + scrambleIn($('#splash-title'), 'ACCESS GRANTED'); + $('#splash-sub').textContent = state.mission.victory; + await sleep(reducedMotion ? 1200 : 3200); + debrief(); +} + +function traced() { + state.stage = 'traced'; + exfil.stop(); + cracker.stop(); + synth.traced(); + flash('rgba(255,45,85,0.5)', 600); + shake(); + show('traced'); + $('#traced-excuse').textContent = pick(TRACED_EXCUSES); +} + +function debrief() { + const secs = (performance.now() - state.startTime) / 1000; + const kps = state.keys / Math.max(1, secs); + const rank = [...RANKS].reverse().find((r) => kps >= r.minKps) || RANKS[0]; + const m = state.mission; + + show('debrief'); + $('#r-operator').textContent = state.handle; + $('#r-target').textContent = `${m.name} (${m.host})`; + $('#r-time').textContent = `${secs.toFixed(1)}s`; + $('#r-keys').textContent = `${state.keys} (${kps.toFixed(1)}/sec)`; + $('#r-trace').textContent = `${Math.floor(state.tracePeak)}% — ${state.tracePeak >= 85 ? 'uncomfortably close' : state.tracePeak >= 50 ? 'spicy' : 'they never got close'}`; + $('#r-rank').textContent = rank.name; + $('#r-ranknote').textContent = rank.note; + $('#r-victory').textContent = m.victorySub; +} + +function reportText() { + const m = state.mission; + return [ + '=== HACK THE PLANET · MISSION REPORT ===', + `operator ......... ${state.handle}`, + `target ........... ${m.name} (${m.host}) [fictional]`, + `result ........... ${m.victory}`, + `keystrokes ....... ${$('#r-keys').textContent}`, + `trace peak ....... ${$('#r-trace').textContent}`, + `rank ............. ${$('#r-rank').textContent}`, + 'packets harmed ... 0', + '', + 'simulated at https://htp.kastro.is — a harmless toy by Kastro Labs', + ].join('\n'); +} + +// ------------------------------------------------------------- boss mode + +function toggleBoss() { + document.body.classList.toggle('boss'); +} + +// --------------------------------------------------------------- wiring + +$('#btn-login').addEventListener('click', submitHandle); +$('#handle-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') submitHandle(); +}); +$('#btn-random').addEventListener('click', () => { + $('#handle-input').value = randomHandle(); + synth.beep(); +}); + +$('#btn-retry').addEventListener('click', () => startRun(state.mission)); +$('#btn-abort').addEventListener('click', () => { state.runToken++; missions(); }); +$('#btn-replay').addEventListener('click', () => startRun(state.mission)); +$('#btn-newtarget').addEventListener('click', () => { state.runToken++; missions(); }); +$('#btn-copy').addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(reportText()); + $('#btn-copy').textContent = 'COPIED ✓'; + setTimeout(() => { $('#btn-copy').textContent = 'COPY REPORT'; }, 1600); + } catch { + $('#btn-copy').textContent = 'CLIPBOARD BLOCKED'; + } +}); + +$('#btn-sound').addEventListener('click', () => { + const on = synth.toggle(); + $('#btn-sound').textContent = on ? 'SOUND: ON' : 'SOUND: OFF'; + if (on) synth.beep(); +}); + +window.addEventListener('keydown', (e) => { + if (e.key === 'Escape') toggleBoss(); +}); +$('#boss').addEventListener('click', toggleBoss); + +setInterval(() => { + const now = new Date(); + $('#clock').textContent = `${now.toISOString().slice(11, 19)} UTC`; +}, 1000); + +// ------------------------------------------------------------------ go + +(async function init() { + await map.load().catch(() => {}); // map is decorative; never block the show + boot(); })(); diff --git a/static/js/matrix.js b/static/js/matrix.js deleted file mode 100644 index f1e2b46..0000000 --- a/static/js/matrix.js +++ /dev/null @@ -1,85 +0,0 @@ -const chars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEFabcdef{}[]<>=/\\|;:\'\"!@#$%^&*()'; - -export function initMatrix(canvas) { - const ctx = canvas.getContext('2d'); - let columns = []; - let w = 0, h = 0; - const fontSize = 13; - const speed = 1.3; - - function resize() { - const rect = canvas.parentElement.getBoundingClientRect(); - w = rect.width; - h = rect.height; - canvas.width = w; - canvas.height = h; - const colCount = Math.floor(w / fontSize); - while (columns.length < colCount) { - columns.push({ - y: Math.random() * h / fontSize | 0, - speed: 0.5 + Math.random() * 1.0, - chars: [], - }); - } - columns.length = colCount; - } - - resize(); - new ResizeObserver(resize).observe(canvas.parentElement); - - function draw() { - // fade with slight green tint for richer trails - ctx.fillStyle = 'rgba(6, 10, 15, 0.06)'; - ctx.fillRect(0, 0, w, h); - - for (let i = 0; i < columns.length; i++) { - const col = columns[i]; - const ch = chars[Math.random() * chars.length | 0]; - const x = i * fontSize; - const y = col.y * fontSize; - - // white-hot head character - ctx.font = `bold ${fontSize}px monospace`; - ctx.fillStyle = '#ffffff'; - ctx.shadowColor = '#00ff41'; - ctx.shadowBlur = 15; - ctx.fillText(ch, x, y); - - // bright green second character - ctx.font = `${fontSize}px monospace`; - ctx.shadowBlur = 8; - ctx.fillStyle = '#00ff41'; - if (col.y > 1) { - const ch2 = chars[Math.random() * chars.length | 0]; - ctx.fillText(ch2, x, y - fontSize); - } - - // medium green trail characters - ctx.shadowBlur = 3; - ctx.fillStyle = '#00cc33'; - if (col.y > 2) { - const ch3 = chars[Math.random() * chars.length | 0]; - ctx.fillText(ch3, x, y - fontSize * 2); - } - - ctx.shadowBlur = 0; - - // randomly switch some trailing chars to dim - if (Math.random() > 0.95 && col.y > 3) { - ctx.fillStyle = 'rgba(0, 80, 20, 0.5)'; - const ch4 = chars[Math.random() * chars.length | 0]; - ctx.fillText(ch4, x, y - fontSize * (3 + Math.random() * 5 | 0)); - } - - col.y += col.speed * speed; - - if (col.y * fontSize > h + fontSize * 10 && Math.random() > 0.98) { - col.y = -Math.random() * 20 | 0; - col.speed = 0.5 + Math.random() * 1.0; - } - } - requestAnimationFrame(draw); - } - - draw(); -} diff --git a/static/js/network.js b/static/js/network.js deleted file mode 100644 index de34000..0000000 --- a/static/js/network.js +++ /dev/null @@ -1,226 +0,0 @@ -const NODE_DEFS = [ - { id: 'node-0', name: 'KASTRO-LABS' }, - { id: 'node-1', name: 'PLOT-FIREWALL' }, - { id: 'node-2', name: 'COFFEE-RELAY' }, - { id: 'node-3', name: 'VHS-TRACKING' }, - { id: 'node-4', name: 'SATELLITE-VIBES' }, - { id: 'node-5', name: 'MAINFRAME-AURA' }, - { id: 'node-6', name: 'BOSS-MODE' }, - { id: 'node-7', name: 'NO-PACKETS-HARMED' }, - { id: 'node-8', name: 'DRAMATIC-TERMINAL' }, - { id: 'node-9', name: 'SCREENSHOT-BAIT' }, -]; - -const EDGES = [ - [0,1],[1,2],[2,3],[3,4],[4,5],[5,6],[6,7],[7,8],[8,9],[9,0], - [0,5],[1,6],[2,7],[3,8],[4,9] -]; - -export function initNetwork(canvas) { - const ctx = canvas.getContext('2d'); - let w = 0, h = 0; - const nodes = []; - const pulses = {}; - const packets = []; - let frame = 0; - let ringAngle = 0; - - function resize() { - const rect = canvas.parentElement.getBoundingClientRect(); - const header = canvas.parentElement.querySelector('.panel-header'); - const headerH = header ? header.offsetHeight : 0; - w = rect.width; - h = rect.height - headerH; - canvas.width = w; - canvas.height = h; - canvas.style.marginTop = headerH + 'px'; - layoutNodes(); - } - - function layoutNodes() { - const cx = w / 2; - const cy = h / 2; - const r = Math.min(w, h) * 0.32; - for (let i = 0; i < NODE_DEFS.length; i++) { - const angle = (i / NODE_DEFS.length) * Math.PI * 2 - Math.PI / 2; - nodes[i] = { - ...NODE_DEFS[i], - x: cx + Math.cos(angle) * r, - y: cy + Math.sin(angle) * r, - }; - } - } - - resize(); - new ResizeObserver(resize).observe(canvas.parentElement); - - setInterval(() => { - if (nodes.length === 0) return; - const edge = EDGES[Math.random() * EDGES.length | 0]; - const reverse = Math.random() > 0.5; - packets.push({ - from: reverse ? edge[1] : edge[0], - to: reverse ? edge[0] : edge[1], - progress: 0, - speed: 0.008 + Math.random() * 0.015, - }); - if (packets.length > 25) packets.shift(); - }, 400); - - function draw() { - frame++; - ringAngle += 0.003; - ctx.clearRect(0, 0, w, h); - if (nodes.length === 0) { requestAnimationFrame(draw); return; } - - const cx = w / 2; - const cy = h / 2; - - // rotating concentric rings - ctx.save(); - ctx.translate(cx, cy); - for (let r = 1; r <= 3; r++) { - const radius = Math.min(w, h) * (0.12 + r * 0.12); - ctx.strokeStyle = `rgba(0, 255, 65, ${0.04 + r * 0.01})`; - ctx.lineWidth = 0.5; - ctx.setLineDash([4, 8]); - ctx.beginPath(); - ctx.arc(0, 0, radius, ringAngle * (r % 2 === 0 ? 1 : -1), ringAngle * (r % 2 === 0 ? 1 : -1) + Math.PI * 1.8); - ctx.stroke(); - ctx.setLineDash([]); - - // tick marks - for (let t = 0; t < 12; t++) { - const a = ringAngle * (r % 2 === 0 ? 1 : -1) + (t / 12) * Math.PI * 2; - ctx.strokeStyle = `rgba(0, 255, 65, 0.08)`; - ctx.beginPath(); - ctx.moveTo(Math.cos(a) * (radius - 3), Math.sin(a) * (radius - 3)); - ctx.lineTo(Math.cos(a) * (radius + 3), Math.sin(a) * (radius + 3)); - ctx.stroke(); - } - } - ctx.restore(); - - // draw edges with gradient - for (const [a, b] of EDGES) { - const n1 = nodes[a], n2 = nodes[b]; - const isInner = Math.abs(a - b) > 1 && !(a === 0 && b === 9) && !(a === 9 && b === 0); - const alpha = isInner ? 0.08 : 0.12; - - ctx.strokeStyle = `rgba(0, 255, 65, ${alpha})`; - ctx.lineWidth = isInner ? 0.5 : 1; - ctx.beginPath(); - ctx.moveTo(n1.x, n1.y); - ctx.lineTo(n2.x, n2.y); - ctx.stroke(); - } - - // draw packets with trails - for (let i = packets.length - 1; i >= 0; i--) { - const p = packets[i]; - p.progress += p.speed; - if (p.progress >= 1) { - packets.splice(i, 1); - continue; - } - const n1 = nodes[p.from]; - const n2 = nodes[p.to]; - - // trail - for (let t = 0; t < 4; t++) { - const tt = Math.max(0, p.progress - t * 0.03); - const x = n1.x + (n2.x - n1.x) * tt; - const y = n1.y + (n2.y - n1.y) * tt; - const alpha = (1 - t / 4) * 0.8; - ctx.fillStyle = t === 0 ? '#00e5ff' : `rgba(0, 229, 255, ${alpha})`; - if (t === 0) { - ctx.shadowColor = '#00e5ff'; - ctx.shadowBlur = 10; - } - ctx.beginPath(); - ctx.arc(x, y, t === 0 ? 2.5 : 1.5, 0, Math.PI * 2); - ctx.fill(); - ctx.shadowBlur = 0; - } - - // highlight edge as packet travels - ctx.strokeStyle = 'rgba(0, 229, 255, 0.08)'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(n1.x, n1.y); - ctx.lineTo(n2.x, n2.y); - ctx.stroke(); - } - - // draw nodes - for (const node of nodes) { - const pulse = pulses[node.id]; - let radius = 5; - let glow = 0; - let color = 'rgba(0, 255, 65, 0.7)'; - - if (pulse && pulse.intensity > 0) { - radius = 5 + pulse.intensity * 10; - glow = pulse.intensity * 20; - color = `rgba(255, 176, 0, ${0.7 + pulse.intensity * 0.3})`; - pulse.intensity *= 0.97; - if (pulse.intensity < 0.01) pulse.intensity = 0; - } - - // outer ring with rotation - const nodeAngle = frame * 0.02; - ctx.strokeStyle = `rgba(0, 255, 65, 0.2)`; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.arc(node.x, node.y, radius + 6, nodeAngle, nodeAngle + Math.PI * 1.5); - ctx.stroke(); - - // second ring - ctx.beginPath(); - ctx.arc(node.x, node.y, radius + 10, -nodeAngle, -nodeAngle + Math.PI); - ctx.stroke(); - - // glow - if (glow > 0) { - ctx.shadowColor = '#ffb000'; - ctx.shadowBlur = glow; - // pulse ring - const pulseRing = radius + 15 * pulse.intensity; - ctx.strokeStyle = `rgba(255, 176, 0, ${pulse.intensity * 0.3})`; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(node.x, node.y, pulseRing, 0, Math.PI * 2); - ctx.stroke(); - } - - // core - ctx.fillStyle = color; - ctx.shadowColor = glow > 0 ? '#ffb000' : '#00ff41'; - ctx.shadowBlur = glow > 0 ? glow : 6; - ctx.beginPath(); - ctx.arc(node.x, node.y, radius, 0, Math.PI * 2); - ctx.fill(); - ctx.shadowBlur = 0; - - // label - ctx.fillStyle = 'rgba(0, 255, 65, 0.4)'; - ctx.font = '7px monospace'; - ctx.textAlign = 'center'; - ctx.fillText(node.name, node.x, node.y + radius + 16); - } - - // center hub indicator - ctx.fillStyle = `rgba(0, 255, 65, ${0.1 + Math.sin(frame * 0.03) * 0.05})`; - ctx.beginPath(); - ctx.arc(cx, cy, 4, 0, Math.PI * 2); - ctx.fill(); - - requestAnimationFrame(draw); - } - - draw(); - - return function onNodePulse(data) { - pulses[data.id] = { intensity: 1.0 }; - }; -} diff --git a/static/js/panels.js b/static/js/panels.js new file mode 100644 index 0000000..1437936 --- /dev/null +++ b/static/js/panels.js @@ -0,0 +1,184 @@ +// Run-screen panels: the terminal (hackertyper engine), the password +// cracker, and the exfil file list. + +import { CODE } from './data.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export class Terminal { + constructor(el) { + this.el = el; + this.codeLine = 0; + this.codeCol = 0; + this.current = null; // element receiving hackertyper output + } + + clear() { + this.el.innerHTML = ''; + this.current = null; + } + + trim() { + while (this.el.children.length > 220) this.el.removeChild(this.el.firstChild); + } + + scroll() { + this.el.scrollTop = this.el.scrollHeight; + } + + line(text, cls = 'sys') { + const div = document.createElement('div'); + div.className = `tl ${cls}`; + div.textContent = text; + this.el.appendChild(div); + this.current = null; + this.trim(); + this.scroll(); + return div; + } + + async typeLine(text, cls = 'sys', cps = 80) { + const div = this.line('', cls); + for (const ch of text) { + div.textContent += ch; + this.scroll(); + await sleep(1000 / cps); + } + return div; + } + + // The magic: every call pours the next few characters of the corpus + // into the terminal, so mashing any keys "writes" convincing code. + emitCode(chars = 3) { + for (let i = 0; i < chars; i++) { + if (!this.current) { + this.current = document.createElement('div'); + this.current.className = 'tl code'; + this.current.textContent = ''; + this.el.appendChild(this.current); + this.trim(); + } + const line = CODE[this.codeLine % CODE.length]; + if (this.codeCol >= line.length) { + this.codeLine += 1; + this.codeCol = 0; + this.current = null; + // Blank corpus lines become visual breathing room for free. + continue; + } + this.current.textContent += line[this.codeCol]; + this.codeCol += 1; + } + this.scroll(); + } +} + +export class Cracker { + constructor(el) { + this.el = el; + this.timer = null; + } + + idle(text = 'AWAITING TARGET') { + this.stop(); + this.el.innerHTML = `
    ${text}
    `; + } + + stop() { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } + + // Cycle glyphs and lock the password in one character at a time. + run(password, ms = 5200, onLock = () => {}) { + this.stop(); + const glyphs = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&'; + this.el.innerHTML = password + .split('') + .map(() => '·') + .join(''); + const slots = [...this.el.querySelectorAll('.crack-slot')]; + let locked = 0; + const lockEvery = ms / password.length; + let lastLock = performance.now(); + + return new Promise((resolve) => { + this.timer = setInterval(() => { + const now = performance.now(); + for (let i = locked; i < slots.length; i++) { + slots[i].textContent = glyphs[(Math.random() * glyphs.length) | 0]; + } + if (now - lastLock >= lockEvery && locked < slots.length) { + slots[locked].textContent = password[locked]; + slots[locked].classList.add('locked'); + locked += 1; + lastLock = now; + onLock(locked, slots.length); + } + if (locked >= slots.length) { + this.stop(); + resolve(); + } + }, 45); + }); + } +} + +export class Exfil { + constructor(el) { + this.el = el; + this.rows = []; + this.active = 0; + this.running = false; + } + + load(files) { + this.el.innerHTML = ''; + this.active = 0; + this.rows = files.map((name) => { + const row = document.createElement('div'); + row.className = 'exfil-row'; + row.innerHTML = `${name}` + + '--'; + this.el.appendChild(row); + return { row, name, pct: 0, bar: row.querySelector('i'), label: row.querySelector('.exfil-pct') }; + }); + } + + // Files trickle on their own; keystrokes shove them forward hard. + start(onDone) { + this.running = true; + const tick = () => { + if (!this.running) return; + this.advance(2.4); + if (this.active >= this.rows.length) { + this.running = false; + onDone(); + return; + } + setTimeout(tick, 70); + }; + tick(); + } + + boost() { + if (this.running) this.advance(6 + Math.random() * 4); + } + + advance(amount) { + const f = this.rows[this.active]; + if (!f) return; + f.pct = Math.min(100, f.pct + amount); + f.bar.style.width = `${f.pct}%`; + f.label.textContent = `${Math.floor(f.pct)}%`; + if (f.pct >= 100) { + f.label.textContent = 'OK'; + f.row.classList.add('done'); + this.active += 1; + } + } + + stop() { + this.running = false; + } +} diff --git a/static/js/progress.js b/static/js/progress.js deleted file mode 100644 index 8672cf3..0000000 --- a/static/js/progress.js +++ /dev/null @@ -1,32 +0,0 @@ -export function initProgress(container) { - const bars = {}; - - return function onProgress(data) { - let item = bars[data.id]; - - if (!item) { - item = document.createElement('div'); - item.className = 'progress-item'; - item.innerHTML = - '
    ' + - '' + - '' + - '
    ' + - '
    '; - container.appendChild(item); - bars[data.id] = item; - } - - item.querySelector('.progress-name').textContent = data.label; - item.querySelector('.progress-pct').textContent = Math.floor(data.percent) + '%'; - item.querySelector('.progress-fill').style.width = Math.min(data.percent, 100) + '%'; - - if (data.done) { - item.classList.add('done'); - item.querySelector('.progress-pct').textContent = 'COMPLETE'; - setTimeout(() => { - item.classList.remove('done'); - }, 1000); - } - }; -} diff --git a/static/js/terminal.js b/static/js/terminal.js deleted file mode 100644 index 6dd7fa2..0000000 --- a/static/js/terminal.js +++ /dev/null @@ -1,50 +0,0 @@ -const MAX_LINES = 60; -const TYPE_SPEED = 25; // ms per character - -export function initTerminal(container) { - let lineCount = 0; - const queue = []; - let typing = false; - - // persistent cursor element - const cursor = document.createElement('span'); - cursor.className = 'cursor'; - - function processQueue() { - if (typing || queue.length === 0) return; - typing = true; - - const { text, style } = queue.shift(); - const line = document.createElement('div'); - line.className = 'term-line ' + style; - container.appendChild(line); - line.appendChild(cursor); - lineCount++; - - // trim old lines - while (lineCount > MAX_LINES) { - container.removeChild(container.firstChild); - lineCount--; - } - - let i = 0; - function typeChar() { - if (i < text.length) { - line.insertBefore(document.createTextNode(text[i]), cursor); - i++; - container.scrollTop = container.scrollHeight; - setTimeout(typeChar, TYPE_SPEED); - } else { - typing = false; - container.scrollTop = container.scrollHeight; - processQueue(); - } - } - typeChar(); - } - - return function onTerminal(data) { - queue.push(data); - processQueue(); - }; -} diff --git a/static/js/worldmap.js b/static/js/worldmap.js index 8b4c1f6..1a5fa8b 100644 --- a/static/js/worldmap.js +++ b/static/js/worldmap.js @@ -1,246 +1,229 @@ -// World map using real TopoJSON geographic data -// Includes a minimal inline TopoJSON decoder (no dependencies) - -export function initWorldMap(canvas) { - const ctx = canvas.getContext('2d'); - let w = 0, h = 0; - const connections = []; - const cityDots = []; - let frame = 0; - let landPolygons = []; // will be filled from TopoJSON - - function resize() { - const rect = canvas.parentElement.getBoundingClientRect(); - const header = canvas.parentElement.querySelector('.panel-header'); - const headerH = header ? header.offsetHeight : 0; - w = rect.width; - h = rect.height - headerH; - canvas.width = w; - canvas.height = h; - canvas.style.marginTop = headerH + 'px'; +// Canvas world map with animated proxy-chain arcs. +// Decodes the bundled TopoJSON (land-110m) by hand — no libraries. + +function decodeTopology(topo) { + const { scale, translate } = topo.transform; + const arcs = topo.arcs.map((arc) => { + let x = 0; + let y = 0; + return arc.map(([dx, dy]) => { + x += dx; + y += dy; + return [x * scale[0] + translate[0], y * scale[1] + translate[1]]; + }); + }); + + const rings = []; + for (const geom of topo.objects.land.geometries) { + const polys = geom.type === 'Polygon' ? [geom.arcs] : geom.arcs; + for (const poly of polys) { + for (const ringArcs of poly) { + const ring = []; + for (const idx of ringArcs) { + const arc = idx >= 0 ? arcs[idx] : arcs[~idx].slice().reverse(); + // Consecutive arcs share an endpoint; skip the duplicate. + ring.push(...(ring.length ? arc.slice(1) : arc)); + } + rings.push(ring); + } } + } + return rings; +} - resize(); - new ResizeObserver(resize).observe(canvas.parentElement); - - function toXY(lng, lat) { - return [((lng + 180) / 360) * w, ((90 - lat) / 180) * h]; +export class WorldMap { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.rings = null; + this.base = null; // offscreen land render + this.route = []; + this.revealed = 0; // how many hops are lit + this.revealStart = 0; // timestamp the latest hop started drawing + this.alarm = false; + this.running = false; + this._resize = () => this.resize(); + window.addEventListener('resize', this._resize); + } + + async load() { + const res = await fetch('/data/land-110m.json'); + this.rings = decodeTopology(await res.json()); + this.resize(); + } + + // Equirectangular projection into the canvas, slightly cropped at the + // poles where there is nothing but drama anyway. + project(lng, lat) { + const w = this.canvas.width; + const h = this.canvas.height; + return [((lng + 180) / 360) * w, ((76 - lat) / 152) * h]; + } + + resize() { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const rect = this.canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + this.canvas.width = Math.round(rect.width * dpr); + this.canvas.height = Math.round(rect.height * dpr); + this.renderBase(); + } + + renderBase() { + if (!this.rings) return; + const { width: w, height: h } = this.canvas; + this.base = document.createElement('canvas'); + this.base.width = w; + this.base.height = h; + const ctx = this.base.getContext('2d'); + + // Graticule + ctx.strokeStyle = 'rgba(57, 255, 20, 0.07)'; + ctx.lineWidth = 1; + for (let lng = -180; lng <= 180; lng += 30) { + const [x] = this.project(lng, 0); + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); } - - // --- Minimal TopoJSON decoder --- - function decodeTopojson(topo) { - const transform = topo.transform; - const arcs = topo.arcs.map(arc => { - let x = 0, y = 0; - return arc.map(p => { - x += p[0]; y += p[1]; - if (transform) { - return [ - x * transform.scale[0] + transform.translate[0], - x * transform.scale[1] + transform.translate[1] - ]; - } - return [x, y]; - }); - }); - - // properly decode with transform - if (transform) { - const sx = transform.scale[0], sy = transform.scale[1]; - const tx = transform.translate[0], ty = transform.translate[1]; - topo.arcs.forEach((arc, ai) => { - let x = 0, y = 0; - arcs[ai] = arc.map(p => { - x += p[0]; y += p[1]; - return [x * sx + tx, y * sy + ty]; - }); - }); - } - - function resolveArc(idx) { - if (idx >= 0) return arcs[idx]; - return [...arcs[~idx]].reverse(); - } - - function resolveRing(indices) { - let coords = []; - for (const idx of indices) { - const arc = resolveArc(idx); - // skip first point of subsequent arcs (shared with previous arc's last point) - coords = coords.concat(coords.length === 0 ? arc : arc.slice(1)); - } - return coords; - } - - const polygons = []; - const geom = topo.objects.land; - for (const g of geom.geometries) { - if (g.type === 'Polygon') { - for (const ring of g.arcs) { - polygons.push(resolveRing(ring)); - } - } else if (g.type === 'MultiPolygon') { - for (const poly of g.arcs) { - for (const ring of poly) { - polygons.push(resolveRing(ring)); - } - } - } - } - return polygons; + for (let lat = -60; lat <= 60; lat += 30) { + const [, y] = this.project(0, lat); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); } - // Load map data - fetch('/data/land-110m.json') - .then(r => r.json()) - .then(topo => { - landPolygons = decodeTopojson(topo); - }) - .catch(e => console.error('Failed to load map data:', e)); - - function drawMap() { - frame++; - ctx.clearRect(0, 0, w, h); - - // grid - ctx.strokeStyle = 'rgba(0,255,65,0.04)'; - ctx.lineWidth = 0.5; - for (let i = 0; i <= 18; i++) { - const x = (i / 18) * w; - ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); - } - for (let i = 0; i <= 9; i++) { - const y = (i / 9) * h; - ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); - } - - // equator - ctx.setLineDash([4, 8]); - ctx.strokeStyle = 'rgba(0,255,65,0.06)'; - ctx.lineWidth = 1; - ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke(); - ctx.setLineDash([]); - - // draw land polygons from TopoJSON - if (landPolygons.length > 0) { - // fill - ctx.fillStyle = 'rgba(0,255,65,0.04)'; - for (const poly of landPolygons) { - ctx.beginPath(); - for (let i = 0; i < poly.length; i++) { - const [x, y] = toXY(poly[i][0], poly[i][1]); - if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - } - ctx.closePath(); - ctx.fill(); - } - - // glow edge - ctx.strokeStyle = 'rgba(0,255,65,0.08)'; - ctx.lineWidth = 3; - for (const poly of landPolygons) { - ctx.beginPath(); - for (let i = 0; i < poly.length; i++) { - const [x, y] = toXY(poly[i][0], poly[i][1]); - if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - } - ctx.closePath(); - ctx.stroke(); - } - - // sharp edge - ctx.strokeStyle = 'rgba(0,255,65,0.3)'; - ctx.lineWidth = 0.8; - for (const poly of landPolygons) { - ctx.beginPath(); - for (let i = 0; i < poly.length; i++) { - const [x, y] = toXY(poly[i][0], poly[i][1]); - if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - } - ctx.closePath(); - ctx.stroke(); - } - } + // Landmasses + ctx.strokeStyle = 'rgba(57, 255, 20, 0.55)'; + ctx.fillStyle = 'rgba(57, 255, 20, 0.08)'; + ctx.lineWidth = Math.max(1, w / 900); + for (const ring of this.rings) { + ctx.beginPath(); + ring.forEach(([lng, lat], i) => { + const [x, y] = this.project(lng, lat); + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + }); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + } - // city dots - for (let i = cityDots.length - 1; i >= 0; i--) { - const cd = cityDots[i]; - cd.age++; - if (cd.age > 600) { cityDots.splice(i, 1); continue; } - const alpha = cd.age > 500 ? (600 - cd.age) / 100 : 1; - const r = 2.5 + Math.sin(frame * 0.05 + cd.phase) * 1; - - ctx.fillStyle = `rgba(0,229,255,${0.7 * alpha})`; - ctx.shadowColor = '#00e5ff'; - ctx.shadowBlur = 8; - ctx.beginPath(); ctx.arc(cd.x, cd.y, r, 0, Math.PI * 2); ctx.fill(); - ctx.shadowBlur = 0; - - const rr = r + 4 + Math.sin(frame * 0.03 + cd.phase) * 2; - ctx.strokeStyle = `rgba(0,229,255,${0.15 * alpha})`; - ctx.lineWidth = 0.5; - ctx.beginPath(); ctx.arc(cd.x, cd.y, rr, 0, Math.PI * 2); ctx.stroke(); - } + setRoute(cities) { + this.route = cities; + this.revealed = 0; + } - // connections - for (let i = connections.length - 1; i >= 0; i--) { - const c = connections[i]; - c.progress += 0.012; - if (c.progress > 1.8) { connections.splice(i, 1); continue; } - - const [x1, y1] = c.src; - const [x2, y2] = c.dst; - const dist = Math.hypot(x2 - x1, y2 - y1); - const midX = (x1 + x2) / 2; - const midY = (y1 + y2) / 2 - dist * 0.15; - const alpha = c.progress > 1 ? Math.max(0, 1 - (c.progress - 1) * 1.25) : Math.min(1, c.progress * 3); - - ctx.strokeStyle = `rgba(0,229,255,${alpha * 0.12})`; - ctx.lineWidth = 4; - ctx.shadowColor = '#00e5ff'; - ctx.shadowBlur = 8; - ctx.beginPath(); ctx.moveTo(x1, y1); ctx.quadraticCurveTo(midX, midY, x2, y2); ctx.stroke(); - - ctx.strokeStyle = `rgba(0,229,255,${alpha * 0.8})`; - ctx.lineWidth = 1.5; - ctx.beginPath(); ctx.moveTo(x1, y1); ctx.quadraticCurveTo(midX, midY, x2, y2); ctx.stroke(); - ctx.shadowBlur = 0; - - if (c.progress <= 1) { - const t = c.progress; - for (let tr = 0; tr < 5; tr++) { - const tt = Math.max(0, t - tr * 0.025); - const dx = (1 - tt) * (1 - tt) * x1 + 2 * (1 - tt) * tt * midX + tt * tt * x2; - const dy = (1 - tt) * (1 - tt) * y1 + 2 * (1 - tt) * tt * midY + tt * tt * y2; - ctx.fillStyle = tr === 0 ? '#ffffff' : `rgba(0,229,255,${(1 - tr / 5) * 0.5})`; - if (tr === 0) { ctx.shadowColor = '#00e5ff'; ctx.shadowBlur = 12; } - ctx.beginPath(); ctx.arc(dx, dy, tr === 0 ? 2.5 : 1.5, 0, Math.PI * 2); ctx.fill(); - ctx.shadowBlur = 0; - } - } - - if (alpha > 0.3) { - ctx.fillStyle = `rgba(0,229,255,${alpha * 0.5})`; - ctx.font = '8px monospace'; - ctx.fillText(c.srcName, x1 + 5, y1 - 5); - ctx.fillText(c.dstName, x2 + 5, y2 - 5); - } - } + revealNextHop() { + if (this.revealed < this.route.length - 1) { + this.revealed += 1; + this.revealStart = performance.now(); + } + } + + revealAll() { + this.revealed = Math.max(0, this.route.length - 1); + this.revealStart = 0; + } + + setAlarm(on) { + this.alarm = on; + } + + start() { + if (this.running) return; + this.running = true; + const loop = (t) => { + if (!this.running) return; + this.draw(t); + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + } + + stop() { + this.running = false; + } + + arcPath(ctx, a, b, progress) { + const [x1, y1] = this.project(a.lng, a.lat); + const [x2, y2] = this.project(b.lng, b.lat); + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2 - Math.hypot(x2 - x1, y2 - y1) * 0.25; + ctx.beginPath(); + ctx.moveTo(x1, y1); + if (progress >= 1) { + ctx.quadraticCurveTo(mx, my, x2, y2); + } else { + // Walk the quadratic bezier up to `progress`. + let px = x1; + let py = y1; + const steps = 24; + for (let i = 1; i <= steps * progress; i++) { + const t = i / steps; + px = (1 - t) ** 2 * x1 + 2 * (1 - t) * t * mx + t * t * x2; + py = (1 - t) ** 2 * y1 + 2 * (1 - t) * t * my + t * t * y2; + ctx.lineTo(px, py); + } + } + ctx.stroke(); + } + + draw(t) { + const ctx = this.ctx; + const { width: w, height: h } = this.canvas; + ctx.clearRect(0, 0, w, h); + if (this.base) ctx.drawImage(this.base, 0, 0); + if (this.alarm) { + ctx.fillStyle = 'rgba(255, 45, 85, 0.06)'; + ctx.fillRect(0, 0, w, h); + } - requestAnimationFrame(drawMap); + const accent = this.alarm ? 'rgba(255, 45, 85,' : 'rgba(255, 212, 0,'; + const fontPx = Math.max(9, Math.round(w / 70)); + ctx.font = `${fontPx}px "Share Tech Mono", monospace`; + + for (let i = 0; i < this.revealed; i++) { + const isLatest = i === this.revealed - 1; + const progress = isLatest && this.revealStart + ? Math.min(1, (t - this.revealStart) / 600) + : 1; + ctx.strokeStyle = `${accent} 0.85)`; + ctx.lineWidth = Math.max(1.2, w / 700); + ctx.shadowColor = `${accent} 1)`; + ctx.shadowBlur = 8; + this.arcPath(ctx, this.route[i], this.route[i + 1], progress); + ctx.shadowBlur = 0; } - drawMap(); + // Nodes + labels for every endpoint that is lit. + const pulse = 0.5 + 0.5 * Math.sin(t / 300); + for (let i = 0; i <= this.revealed && i < this.route.length; i++) { + const c = this.route[i]; + const [x, y] = this.project(c.lng, c.lat); + const isTarget = i === this.route.length - 1; + const r = isTarget ? 3 + pulse * 4 : 2.5; + ctx.fillStyle = isTarget ? `${accent} 0.95)` : 'rgba(57, 255, 20, 0.9)'; + ctx.beginPath(); + ctx.arc(x, y, r * (w / 600), 0, Math.PI * 2); + ctx.fill(); + if (isTarget && this.revealed >= this.route.length - 1) { + ctx.strokeStyle = `${accent} ${0.7 - pulse * 0.5})`; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(x, y, (6 + pulse * 10) * (w / 600), 0, Math.PI * 2); + ctx.stroke(); + } + ctx.fillStyle = isTarget ? `${accent} 0.9)` : 'rgba(57, 255, 20, 0.65)'; + const label = isTarget ? `▸ ${c.name}` : c.name; + ctx.fillText(label, Math.min(x + 6, w - 80), Math.max(y - 6, fontPx)); + } + } - return function onMapConnection(data) { - const src = toXY(data.srcLng, data.srcLat); - const dst = toXY(data.dstLng, data.dstLat); - connections.push({ src, dst, srcName: data.srcName, dstName: data.dstName, progress: 0 }); - for (const pt of [src, dst]) { - if (!cityDots.some(d => Math.hypot(d.x - pt[0], d.y - pt[1]) < 10)) { - cityDots.push({ x: pt[0], y: pt[1], age: 0, phase: Math.random() * Math.PI * 2 }); - } - } - if (connections.length > 20) connections.shift(); - }; + destroy() { + this.stop(); + window.removeEventListener('resize', this._resize); + } } diff --git a/websocket.go b/websocket.go deleted file mode 100644 index 2f4e4d0..0000000 --- a/websocket.go +++ /dev/null @@ -1,109 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "log" - "net/http" - "sync" - "time" - - "github.com/coder/websocket" -) - -type Client struct { - conn *websocket.Conn - send chan []byte -} - -type Hub struct { - clients map[*Client]struct{} - mu sync.RWMutex -} - -func newHub() *Hub { - return &Hub{ - clients: make(map[*Client]struct{}), - } -} - -func (h *Hub) register(c *Client) { - h.mu.Lock() - h.clients[c] = struct{}{} - h.mu.Unlock() -} - -func (h *Hub) unregister(c *Client) { - h.mu.Lock() - delete(h.clients, c) - close(c.send) - h.mu.Unlock() -} - -func (h *Hub) Broadcast(msg Message) { - data, err := json.Marshal(msg) - if err != nil { - log.Printf("marshal error: %v", err) - return - } - h.mu.RLock() - defer h.mu.RUnlock() - for c := range h.clients { - select { - case c.send <- data: - default: - // client too slow, drop message - } - } -} - -func handleWebSocket(hub *Hub) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - InsecureSkipVerify: true, - }) - if err != nil { - log.Printf("websocket accept error: %v", err) - return - } - - client := &Client{ - conn: conn, - send: make(chan []byte, 256), - } - hub.register(client) - defer hub.unregister(client) - - ctx, cancel := context.WithCancel(r.Context()) - defer cancel() - - // Read goroutine: drain incoming messages to prevent connection stall - go func() { - defer cancel() - for { - _, _, err := conn.Read(ctx) - if err != nil { - return - } - } - }() - - // Write loop - for { - select { - case msg, ok := <-client.send: - if !ok { - return - } - writeCtx, writeCancel := context.WithTimeout(ctx, 5*time.Second) - err := conn.Write(writeCtx, websocket.MessageText, msg) - writeCancel() - if err != nil { - return - } - case <-ctx.Done(): - return - } - } - } -}