Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
e3f474662e feat: add PWA manifest + service worker for offline + home screen install
- manifest.json with icons, theme colors, standalone display mode
- sw.js: cache-first service worker caching local assets and Three.js CDN
  modules, fonts; graceful offline fallback with cached index.html
- Offline banner (visible when navigator.onLine === false)
- iOS/Android home screen meta tags (apple-mobile-web-app-capable etc.)
- 192x192 and 512x512 PNG icons with nexus sigil design

Fixes #14
2026-03-23 21:23:15 -04:00
58 changed files with 1525 additions and 8788 deletions

View File

@@ -1,10 +0,0 @@
# Placeholder — auto-merge is handled by nexus-merge-bot.sh
# Gitea Actions requires a runner to be registered.
# When a runner is available, this can replace the bot.
name: stub
on: workflow_dispatch
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: echo "See nexus-merge-bot.sh"

View File

@@ -1,104 +0,0 @@
name: CI
on:
pull_request:
branches:
- main
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate HTML
run: |
test -f index.html || { echo "ERROR: index.html missing"; exit 1; }
python3 -c "
import html.parser, sys
class V(html.parser.HTMLParser):
def __init__(self):
super().__init__()
def handle_starttag(self, tag, attrs): pass
def handle_endtag(self, tag): pass
v = V()
try:
v.feed(open('index.html').read())
print('HTML: OK')
except Exception as e:
print(f'HTML: FAIL - {e}')
sys.exit(1)
"
- name: Validate JavaScript
run: |
FAIL=0
for f in $(find . -name '*.js' -not -path './node_modules/*' -not -name 'sw.js'); do
if ! node --check "$f" 2>/dev/null; then
echo "FAIL: $f"
FAIL=1
else
echo "OK: $f"
fi
done
exit $FAIL
- name: Validate JSON
run: |
FAIL=0
for f in $(find . -name '*.json' -not -path './node_modules/*'); do
if ! python3 -c "import json; json.load(open('$f'))"; then
echo "FAIL: $f"
FAIL=1
else
echo "OK: $f"
fi
done
exit $FAIL
- name: Check file size budget
run: |
FAIL=0
for f in $(find . -name '*.js' -not -path './node_modules/*'); do
SIZE=$(wc -c < "$f")
if [ "$SIZE" -gt 512000 ]; then
echo "FAIL: $f is ${SIZE} bytes (budget: 512000)"
FAIL=1
else
echo "OK: $f (${SIZE} bytes)"
fi
done
exit $FAIL
auto-merge:
needs: validate
runs-on: ubuntu-latest
steps:
- name: Merge PR
env:
GITEA_TOKEN: ${{ secrets.MERGE_TOKEN }}
run: |
PR_NUM=$(echo "${{ github.event.pull_request.number }}")
REPO="${{ github.repository }}"
API="http://143.198.27.163:3000/api/v1"
echo "CI passed. Auto-merging PR #${PR_NUM}..."
# Squash merge
RESULT=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"Do":"squash","delete_branch_after_merge":true}' \
"${API}/repos/${REPO}/pulls/${PR_NUM}/merge")
HTTP_CODE=$(echo "$RESULT" | tail -1)
BODY=$(echo "$RESULT" | head -n -1)
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "405" ]; then
echo "Merged successfully (or already merged)"
else
echo "Merge failed: HTTP ${HTTP_CODE}"
echo "$BODY"
# Don't fail the job — PR stays open for manual review
fi

View File

@@ -1,26 +0,0 @@
name: Deploy Nexus
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to host via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd ~/the-nexus || git clone http://143.198.27.163:3000/Timmy_Foundation/the-nexus.git ~/the-nexus
cd ~/the-nexus
git fetch origin main
git reset --hard origin/main
./deploy.sh main

View File

@@ -1,23 +0,0 @@
name: Staging Smoke Test
on:
push:
branches:
- main
schedule:
- cron: '*/15 * * * *'
workflow_dispatch:
jobs:
smoke-test:
runs-on: ubuntu-latest
steps:
- name: Check staging environment uptime
run: |
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://staging.the-nexus.com/)
if [ "$HTTP_CODE" -eq 200 ]; then
echo "Staging environment is up (HTTP 200)"
else
echo "Staging environment returned HTTP $HTTP_CODE"
exit 1
fi

1
.gitignore vendored
View File

@@ -1 +0,0 @@
.aider*

View File

@@ -1,117 +0,0 @@
# Research Drop - Issue #456: Ingest and Triage Work
This document summarizes the key findings from the four PDF attachments in Issue #456, "Research Drop," and proposes how these insights can be integrated into the Nexus project, adhering to the Nexus Data Integrity Standard.
---
## 1. Lean Manufacturing Implementation ($5,000 Upfront Budget with $500 Monthly Recurring Capital)
**Summary:** This document outlines a strategy for implementing lean manufacturing principles, focusing on strategic budget allocation, a five-step implementation process, foundational lean principles (waste elimination), core lean tools (5S, Kanban), performance measurement with KPIs, and risk mitigation.
**Relevance to Nexus & Data Integrity Proposals:**
While not directly related to a visual element, this research can inform Timmy's internal operational efficiency.
* **Indirect Impact (REAL Data Source):** If Timmy were to expose its internal "lean" metrics (e.g., task throughput, waste reduction, project velocity) as real-time data, these could be integrated into the Nexus.
* **Proposed Element:** A new "Timmy Operations Efficiency" panel.
* **Category:** REAL.
* **Data Source:** Timmy's internal operational metrics (e.g., a dedicated API endpoint or internal log file that can be parsed).
* **Description:** Displays key performance indicators related to Timmy's task processing efficiency, resource utilization, and adherence to lean principles.
---
## 2. State-of-the-Art Open-Source Local AI Agents for Personal Neural System Development
**Summary:** This PDF details a shift towards hybrid cloud-local AI agent architectures, emphasizing local sovereignty, reduced cloud dependency, and continuous learning through Reinforcement Learning from Human Feedback (RLHF) using the OpenClaw ecosystem. It covers architecture, deployment modes, memory systems, LORA fine-tuning, security, governance, and a roadmap.
**Relevance to Nexus & Data Integrity Proposals:**
This document is highly relevant to the Nexus's core mission of "Timmy's Sovereign Home" and advanced AI agent capabilities. It provides numerous opportunities to populate existing `HONEST-OFFLINE` elements and introduce new `REAL` and `DATA-TETHERED AESTHETIC` elements.
* **Existing Element Enhancement (LoRA Panel):**
* **Proposed Enhancement:** Populate the existing "LoRA Panel" with real-time LORA training status from the OpenClaw ecosystem.
* **Category:** REAL (from HONEST-OFFLINE).
* **Data Source:** OpenClaw LORA training status API or internal module.
* **Description:** Displays active LORA fine-tuning jobs, their progress, and completion status.
* **Existing Element Enhancement (Agent Status Board):**
* **Proposed Enhancement:** Expand the "Agent Status Board" to include detailed OpenClaw agent activities (Terminal-RL, GUI-RL, SWE-RL, Toolcall-RL).
* **Category:** REAL.
* **Data Source:** OpenClaw agent activity API or internal module.
* **Description:** Provides granular status updates on different types of tasks and learning activities performed by Timmy.
* **New Element (Local Inference Metrics):**
* **Proposed Element:** "Local Inference Efficiency" display.
* **Category:** REAL.
* **Data Source:** OpenClaw inference engine metrics (e.g., percentage of local vs. cloud inference).
* **Description:** Visualizes Timmy's reliance on local processing, aiming for >90% local inference.
* **New Element (Knowledge System Metrics):**
* **Proposed Element:** "Knowledge Base Activity" display.
* **Category:** REAL.
* **Data Source:** OpenClaw memory systems (vector database size, query rates, RAG activity).
* **Description:** Shows the growth and utilization of Timmy's knowledge base.
* **New Element (Security & Governance Panel):**
* **Proposed Element:** "Agent Governance Status" panel.
* **Category:** REAL.
* **Data Source:** OpenClaw security and governance signals (sandboxing status, capability control logs, oversight signals).
* **Description:** Provides real-time insights into the security posture and human oversight of Timmy's autonomous actions.
* **Data-Tethered Aesthetic (Agent Activity Visualization):**
* **Proposed Element:** Nexus particle effects or light intensity tethered to OpenClaw agent activity levels.
* **Category:** DATA-TETHERED AESTHETIC.
* **Data Source:** OpenClaw agent activity API or internal module (e.g., a normalized activity score).
* **Description:** Dynamic visual feedback within the Nexus reflecting Timmy's current operational intensity.
---
## 3. The Timmy Time Hardware Decision: A Complete Cost-to-Capability Breakdown
**Summary:** This PDF analyzes hardware options (Apple Silicon, NVIDIA GPUs, cloud providers) for AI development, emphasizing local sovereignty. It recommends a hybrid approach and a three-phase "phased sovereignty plan" to scale hardware investment for faster fine-tuning, larger model inference, and OpenClaw-RL.
**Relevance to Nexus & Data Integrity Proposals:**
This document provides context for the "Sovereignty Meter" and informs potential `REAL` and `HONEST-OFFLINE` elements reflecting Timmy's hardware and capabilities.
* **Existing Element Enhancement (Sovereignty Meter):**
* **Proposed Enhancement:** Enhance the "Sovereignty Meter" to dynamically reflect the current phase of Timmy's hardware evolution and actual local processing capabilities.
* **Category:** REAL (from REAL (manual) + JSON).
* **Data Source:** System hardware detection, OpenClaw configuration (e.g., reporting active hardware phase), or internal metrics on local computation.
* **Description:** A visual indicator of Timmy's current hardware phase (Phase 1, 2, or 3) and its resulting degree of local operational sovereignty.
* **New Element (Hardware Capabilities Panel):**
* **Proposed Element:** "Timmy Hardware Status" panel.
* **Category:** REAL / HONEST-OFFLINE.
* **Data Source:** System hardware inventory, OpenClaw hardware detection.
* **Description:** Displays currently active hardware (e.g., "M3 Max," "RTX 4090") and indicates capabilities that are "HONEST-OFFLINE" because required hardware is not yet present (e.g., "70B Model Inference: AWAITING SECOND RTX 4090").
* **New Element (Cost Efficiency Metrics):**
* **Proposed Element:** "Operational Cost Efficiency" display.
* **Category:** REAL.
* **Data Source:** Timmy's internal cost tracking for cloud vs. local operations.
* **Description:** Visualizes the cost savings achieved through local-first hardware investments compared to cloud-only alternatives. (Requires secure and aggregated cost data).
---
## 4. Wiring the Research Pipeline
**Summary:** This PDF outlines the architecture for Timmy's Autonomous Deep Research System, aiming to automate research without human intervention. It recommends specific open-source tools (Local Deep Research, SearXNG, Crawl4AI, LanceDB, Qwen3-Embedding) for the research pipeline, detailing data flow, components, and a build order.
**Relevance to Nexus & Data Integrity Proposals:**
This document offers concrete components and metrics that can be directly integrated into the Nexus to represent Timmy's autonomous research capabilities.
* **New Element (Research Pipeline Status):**
* **Proposed Element:** "Timmy Research Pipeline" panel.
* **Category:** REAL.
* **Data Source:** Autonomous Deep Research System's internal status (e.g., current stage: "Ingesting," "Processing," "Analyzing," "Synthesizing").
* **Description:** Shows the real-time progress of Timmy's research tasks.
* **New Element (Knowledge Crystallization Metrics):**
* **Proposed Element:** "Knowledge Growth" display.
* **Category:** REAL.
* **Data Source:** Autonomous Deep Research System's knowledge base metrics (e.g., size of LanceDB, number of unique facts, growth rate).
* **Description:** Visualizes the expansion of Timmy's crystallized knowledge base.
* **New Element (Research Tool Status):**
* **Proposed Element:** "Research Tool Health" panel.
* **Category:** REAL / HONEST-OFFLINE.
* **Data Source:** Health checks or status reports from SearXNG, Crawl4AI, LanceDB components.
* **Description:** Displays the operational status of key tools within the research pipeline.
* **Data-Tethered Aesthetic (Research Activity Visualization):**
* **Proposed Element:** Nexus visual effects (e.g., light patterns, energy flows) tethered to the intensity or volume of Timmy's research activity.
* **Category:** DATA-TETHERED AESTHETIC.
* **Data Source:** Autonomous Deep Research System's activity metrics (e.g., data ingestion rate, processing load).
* **Description:** Dynamic visual feedback within the Nexus reflecting the current level of autonomous research.

View File

@@ -1,75 +0,0 @@
import * as THREE from 'three';
import { THEME } from './core/theme.js';
import { S } from './state.js';
import { Broadcaster } from './state.js';
export class SovOS {
constructor(scene) {
this.scene = scene;
this.apps = new Map();
this.init();
}
init() {
this.container = new THREE.Group();
this.container.position.set(0, 3, -7.5);
this.scene.add(this.container);
}
registerApp(id, config) {
const app = this.createWindow(id, config);
this.apps.set(id, app);
this.container.add(app.group);
}
createWindow(id, config) {
const { x, y, rot, title, color } = config;
const w = 2.8, h = 3.8;
const group = new THREE.Group();
group.position.set(x, y || 0, 0);
group.rotation.y = rot || 0;
// Glassmorphism Frame
const glassMat = new THREE.MeshPhysicalMaterial({
color: THEME.glass.color,
transparent: true,
opacity: THEME.glass.opacity,
roughness: THEME.glass.roughness,
metalness: THEME.glass.metalness,
transmission: THEME.glass.transmission,
thickness: THEME.glass.thickness,
ior: THEME.glass.ior,
side: THREE.DoubleSide
});
const frame = new THREE.Mesh(new THREE.PlaneGeometry(w, h), glassMat);
group.add(frame);
// Canvas UI
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 700;
const ctx = canvas.getContext('2d');
const texture = new THREE.CanvasTexture(canvas);
const mat = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide });
const screen = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.92, h * 0.92), mat);
screen.position.z = 0.05;
group.add(screen);
const renderUI = (state) => {
ctx.clearRect(0, 0, 512, 700);
// Header
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
ctx.fillRect(0, 0, 512, 80);
ctx.fillStyle = '#' + new THREE.Color(color).getHexString();
ctx.font = 'bold 32px "Orbitron"';
ctx.fillText(title, 30, 50);
// Body
ctx.font = '20px "JetBrains Mono"';
ctx.fillStyle = '#ffffff';
config.renderBody(ctx, state);
texture.needsUpdate = true;
};
Broadcaster.subscribe(renderUI);
return { group, renderUI };
}
}

View File

@@ -1,7 +0,0 @@
42e74ad fix: restore full app.js wiring — manus gutted it to 42-line nostr stub
764b617 [modularization] Phase 2: Extract data layer — gitea, weather, bitcoin, loaders (#460)
d201d3e feat: add visual banner, staging link, and real smoke-test badge (#458)
06faa75 fix: point staging to localhost exclusively and entirely (#459)
24e7139 [manus] Nostr Integration — Sovereign Communication (#454) (#455)
a2b2b1a [gemini] Research Drop findings (#456) (#457)
4effd92 [manus] SovOS Architecture — Modular 3D Interface (#452) (#453)

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +0,0 @@
// === NOSTR FEED PANEL ===
import * as THREE from 'three';
import { NEXUS } from './constants.js';
import { NOSTR_STATE } from './nostr.js';
export function createNostrPanelTexture() {
const W = 512, H = 512;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
const update = () => {
ctx.clearRect(0, 0, W, H);
// Background
ctx.fillStyle = 'rgba(10, 20, 40, 0.8)';
ctx.fillRect(0, 0, W, H);
// Header
ctx.fillStyle = '#4488ff';
ctx.font = 'bold 32px "Orbitron"';
ctx.fillText('◈ NOSTR_FEED', 30, 60);
ctx.fillRect(30, 75, 452, 2);
// Connection Status
ctx.fillStyle = NOSTR_STATE.connected ? '#00ff88' : '#ff4444';
ctx.beginPath();
ctx.arc(460, 48, 8, 0, Math.PI * 2);
ctx.fill();
// Events
ctx.font = '18px "JetBrains Mono"';
NOSTR_STATE.events.slice(0, 10).forEach((ev, i) => {
const y = 120 + i * 38;
ctx.fillStyle = ev.kind === 9735 ? '#ffd700' : '#ffffff';
const prefix = ev.kind === 9735 ? '⚡' : '•';
ctx.fillText(\`\${prefix} [\${ev.pubkey}] \${ev.content}\`, 30, y);
});
if (NOSTR_STATE.events.length === 0) {
ctx.fillStyle = '#667788';
ctx.fillText('> WAITING FOR EVENTS...', 30, 120);
}
};
return { canvas, update };
}

View File

@@ -1,76 +0,0 @@
// === NOSTR INTEGRATION — SOVEREIGN COMMUNICATION ===
import { S } from './state.js';
export const NOSTR_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.snort.social'
];
export const NOSTR_STATE = {
events: [],
connected: false,
lastEventTime: 0
};
export class NostrManager {
constructor() {
this.sockets = [];
}
connect() {
NOSTR_RELAYS.forEach(url => {
try {
const ws = new WebSocket(url);
ws.onopen = () => {
console.log(\`[nostr] Connected to \${url}\`);
NOSTR_STATE.connected = true;
this.subscribe(ws);
};
ws.onmessage = (e) => this.handleMessage(e.data);
ws.onerror = () => console.warn(\`[nostr] Connection error: \${url}\`);
this.sockets.push(ws);
} catch (err) {
console.error(\`[nostr] Failed to connect to \${url}\`, err);
}
});
}
subscribe(ws) {
const subId = 'nexus-sub-' + Math.random().toString(36).substring(7);
const filter = { kinds: [1, 7, 9735], limit: 20 }; // Notes, Reactions, Zaps
ws.send(JSON.stringify(['REQ', subId, filter]));
}
handleMessage(data) {
try {
const msg = JSON.parse(data);
if (msg[0] === 'EVENT') {
const event = msg[2];
this.processEvent(event);
}
} catch (err) { /* ignore parse errors */ }
}
processEvent(event) {
const simplified = {
id: event.id.substring(0, 8),
pubkey: event.pubkey.substring(0, 8),
content: event.content.length > 60 ? event.content.substring(0, 57) + '...' : event.content,
kind: event.kind,
created_at: event.created_at
};
NOSTR_STATE.events.unshift(simplified);
if (NOSTR_STATE.events.length > 50) NOSTR_STATE.events.pop();
NOSTR_STATE.lastEventTime = Date.now();
// Visual feedback via state pulse
if (event.kind === 9735) { // Zap!
S.energyBeamPulse = 1.0;
console.log('[nostr] ZAP RECEIVED!');
}
}
}
export const nostr = new NostrManager();

250
CLAUDE.md
View File

@@ -1,250 +0,0 @@
# CLAUDE.md — The Nexus (Timmy_Foundation/the-nexus)
## Project Overview
The Nexus is a Three.js environment — Timmy's sovereign home in 3D space. It serves as the central hub for all portals to other worlds. Stack: vanilla JS ES modules, Three.js 0.183, no bundler.
## Architecture
**app.js is a thin orchestrator. It should almost never change.**
All logic lives in ES modules under `modules/`. app.js only imports modules, wires them to the ticker, and starts the loop. New features go in new modules — not in app.js.
```
index.html # Entry point: HUD, chat panel, loading screen
style.css # Design system: dark space theme, holographic panels
app.js # THIN ORCHESTRATOR — imports + init + ticker start (~200 lines)
modules/
core/
scene.js # THREE.Scene, camera, renderer, controls, resize
ticker.js # Global Animation Clock — the single RAF loop
theme.js # NEXUS.theme — colors, fonts, line weights, glow params
state.js # Shared data bus (activity, weather, BTC, agents)
audio.js # Web Audio: reverb, panner, ambient, portal hums
data/
gitea.js # All Gitea API calls (commits, PRs, agents)
weather.js # Open-Meteo weather fetch
bitcoin.js # Blockstream BTC block height
loaders.js # JSON file loaders (portals, sovereignty, SOUL)
panels/
heatmap.js # Commit heatmap + zone rendering
agent-board.js # Agent status board (Gitea API)
dual-brain.js # Dual-brain panel (honest offline)
lora-panel.js # LoRA adapter panel (honest empty)
sovereignty.js # Sovereignty meter + score arc
earth.js # Holographic earth (activity-tethered)
effects/
matrix-rain.js # Matrix rain (commit-tethered)
lightning.js # Lightning arcs between zones
energy-beam.js # Energy beam (agent-count-tethered)
rune-ring.js # Rune ring (portal-tethered)
gravity-zones.js # Gravity anomaly zones
shockwave.js # Shockwave, fireworks, merge flash
terrain/
island.js # Floating island + crystals
clouds.js # Cloud layer (weather-tethered)
stars.js # Star field + constellations (BTC-tethered)
portals/
portal-system.js # Portal creation, warp, health checks
commit-banners.js # Floating commit banners
narrative/
bookshelves.js # Floating bookshelves (SOUL.md)
oath.js # Oath display + enter/exit
chat.js # Chat panel, speech bubbles, NPC dialog
utils/
perlin.js # Perlin noise generator
geometry.js # Shared geometry helpers
canvas-utils.js # Canvas texture creation helpers
```
No build step. Served as static files. Import maps in `index.html` handle Three.js resolution.
## Conventions
- **ES modules only** — no CommonJS, no bundler
- **Modular architecture** — all logic in `modules/`. app.js is the orchestrator and should almost never change.
- **Module contract** — every module exports `init(scene, state, theme)` and `update(elapsed, delta)`. Optional: `dispose()`
- **Single animation clock** — one `requestAnimationFrame` in `ticker.js`. No module may call RAF directly. All subscribe to the ticker.
- **Theme is law** — all colors, fonts, line weights come from `NEXUS.theme` in `theme.js`. No inline hex codes, no hardcoded font strings.
- **Data flows through state** — data modules write to `state.js`, visual modules read from it. No `fetch()` outside `data/` modules.
- **Conventional commits**: `feat:`, `fix:`, `refactor:`, `test:`, `chore:`
- **Branch naming**: `claude/issue-{N}` (e.g. `claude/issue-5`)
- **One PR at a time** — wait for merge-bot before opening the next
- **Atomic PRs** — target <150 lines changed per PR. Commit by concern: data, logic, or visuals. If a change needs >200 lines, split into sequential PRs.
- **No new code in app.js** — new features go in a new module or extend an existing module. The only reason to touch app.js is to add an import line for a new module.
## Validation (merge-bot checks)
The `nexus-merge-bot.sh` validates PRs before auto-merge:
1. HTML validation — `index.html` must be valid HTML
2. JS syntax — `node --check app.js` must pass
3. JSON validation — any `.json` files must parse
4. File size budget — JS files must be < 500 KB
**Always run `node --check app.js` before committing.**
## Sequential Build Order — Nexus v1
Issues must be addressed one at a time. Only one PR open at a time.
| # | Issue | Status |
|---|-------|--------|
| 1 | #4 — Three.js scene foundation (lighting, camera, navigation) | ✅ done |
| 2 | #5 — Portal system — YAML-driven registry | pending |
| 3 | #6 — Batcave terminal — workshop integration in 3D | pending |
| 4 | #9 — Visitor presence — live count + Timmy greeting | pending |
| 5 | #8 — Agent idle behaviors in 3D world | pending |
| 6 | #10 — Kimi & Perplexity as visible workshop agents | pending |
| 7 | #11 — Tower Log — narrative event feed | pending |
| 8 | #12 — NIP-07 visitor identity in the workshop | pending |
| 9 | #13 — Timmy Nostr identity, zap-out, vouching | pending |
| 10 | #14 — PWA manifest + service worker | pending |
| 11 | #15 — Edge intelligence — browser model + silent Nostr signing | pending |
| 12 | #16 — Session power meter — 3D balance visualizer | pending |
| 13 | #18 — Unified memory graph & sovereignty loop visualization | pending |
## Commit Discipline
**Every PR must focus on exactly ONE concern. No exceptions.**
### PR Size Limits
- **Target: <150 lines changed per PR.** This is the default ceiling.
- **Hard limit: >200 lines → split into sequential PRs.** If your change exceeds 200 lines, stop and decompose it before opening a PR.
- **One concern per PR**: data layer, logic, OR visuals — never mixed in a single PR.
### Commit by Function
Use the concern as a commit scope prefix:
| Concern | Example commit message |
|---------|----------------------|
| Data layer | `feat: data-provider for agent status` |
| Visual / style | `style: neon-update on portal ring` |
| Refactor | `refactor: extract ticker from app.js` |
| Fix | `fix: portal health-check timeout` |
| Process / docs | `chore: update CLAUDE.md commit rules` |
### Decomposition Rules
When a feature spans multiple concerns (e.g. new data + new visual):
1. Open a PR for the data module first. Wait for merge.
2. Open a PR for the visual module that reads from state. Wait for merge.
3. Never combine data + visual work in one PR.
### Exception: Modularization Epics
Large refactors tracked as a numbered epic (e.g. #409) may use one PR per *phase*, where each phase is a logical, atomic unit of the refactor. Phases must still target <150 lines where possible and must not mix unrelated concerns.
## PR Rules
- Base every PR on latest `main`
- Squash merge only
- **Do NOT merge manually** — merge-bot handles merges
- If merge-bot comments "CONFLICT": rebase onto `main` and force-push your branch
- Include `Fixes #N` or `Refs #N` in commit message
## Running Locally
```bash
npx serve . -l 3000
# open http://localhost:3000
```
## Gitea API
```
Base URL: http://143.198.27.163:3000/api/v1
Repo: Timmy_Foundation/the-nexus
```
---
## Nexus Data Integrity Standard
**This is law. Every contributor — human or AI — must follow these rules. No exceptions.**
### Core Principle
Every visual element in the Nexus must be tethered to reality. Nothing displayed may present fabricated data as if it were live. If a system is offline, the Nexus shows it as offline. If data doesn't exist yet, the element shows an honest empty state. There are zero acceptable reasons to display mocked data in the Nexus.
### The Three Categories
Every visual element falls into exactly one category:
1. **REAL** — Connected to a live data source (API, file, computed value). Displays truthful, current information. Examples: commit heatmap from Gitea, weather from Open-Meteo, Bitcoin block height.
2. **HONEST-OFFLINE** — The system it represents doesn't exist yet or is currently unreachable. The element is visible but clearly shows its offline/empty/awaiting state. Dim colors, empty bars, "OFFLINE" or "AWAITING DEPLOYMENT" labels. No fake numbers. Examples: dual-brain panel before deployment, LoRA panel with no adapters trained.
3. **DATA-TETHERED AESTHETIC** — Visually beautiful and apparently decorative, but its behavior (speed, density, brightness, color, intensity) is driven by a real data stream. The connection doesn't need to be obvious to the viewer, but it must exist in code. Examples: matrix rain density driven by commit activity, star brightness pulsing on Bitcoin blocks, cloud layer density from weather data.
### Banned Practices
- **No hardcoded stubs presented as live data.** No `AGENT_STATUS_STUB`, no `LORA_STATUS_STUB`, no hardcoded scores. If the data source isn't ready, show an empty/offline state.
- **No static JSON files pretending to be APIs.** Files like `api/status.json` with hardcoded agent statuses are lies. Either fetch from the real API or show the element as disconnected.
- **No fictional artifacts.** Files like `lora-status.json` containing invented adapter names that don't exist must be deleted. The filesystem must not contain fiction.
- **No untethered aesthetics.** Every moving, glowing, or animated element must be connected to at least one real data stream. Pure decoration with no data connection is not permitted. Constellation lines (structural) are the sole exception.
- **No "online" status for unreachable services.** If a URL doesn't respond to a health check, it is offline. The Nexus does not lie about availability.
### PR Requirements (Mandatory)
Every PR to this repository must include:
1. **Data Integrity Audit** — A table in the PR description listing every visual element the PR touches, its category (REAL / HONEST-OFFLINE / DATA-TETHERED AESTHETIC), and the data source it connects to. Format:
```
| Element | Category | Data Source |
|---------|----------|-------------|
| Agent Status Board | REAL | Gitea API /repos/.../commits |
| Matrix Rain | DATA-TETHERED AESTHETIC | zoneIntensity (commit count) |
| Dual-Brain Panel | HONEST-OFFLINE | Shows "AWAITING DEPLOYMENT" |
```
2. **Test Plan** — Specific steps to verify that every changed element displays truthful data or an honest offline state. Include:
- How to trigger each state (online, offline, empty, active)
- What the element should look like in each state
- How to confirm the data source is real (API endpoint, computed value, etc.)
3. **Verification Screenshot** — At least one screenshot or recording showing the before-and-after state of changed elements. The screenshot must demonstrate:
- Elements displaying real data or honest offline states
- No hardcoded stubs visible
- Aesthetic elements visibly responding to their data tether
4. **Syntax Check** — `node --check app.js` must pass. (Existing rule, restated for completeness.)
A PR missing any of these four items must not be merged.
### Existing Element Registry
Canonical reference for every Nexus element and its required data source:
| # | Element | Category | Data Source | Status |
|---|---------|----------|-------------|--------|
| 1 | Commit Heatmap | REAL | Gitea commits API | ✅ Connected |
| 2 | Weather System | REAL | Open-Meteo API | ✅ Connected |
| 3 | Bitcoin Block Height | REAL | blockstream.info | ✅ Connected |
| 4 | Commit Banners | REAL | Gitea commits API | ✅ Connected |
| 5 | Floating Bookshelves / Oath | REAL | SOUL.md file | ✅ Connected |
| 6 | Portal System | REAL + Health Check | portals.json + URL probe | ✅ Connected |
| 7 | Dual-Brain Panel | HONEST-OFFLINE | — (system not deployed) | ✅ Honest |
| 8 | Agent Status Board | REAL | Gitea API (commits + PRs) | ✅ Connected |
| 9 | LoRA Panel | HONEST-OFFLINE | — (no adapters deployed) | ✅ Honest |
| 10 | Sovereignty Meter | REAL (manual) | sovereignty-status.json + MANUAL label | ✅ Connected |
| 11 | Matrix Rain | DATA-TETHERED AESTHETIC | zoneIntensity (commits) + commit hashes | ✅ Tethered |
| 12 | Star Field | DATA-TETHERED AESTHETIC | Bitcoin block events (brightness pulse) | ✅ Tethered |
| 13 | Constellation Lines | STRUCTURAL (exempt) | — | ✅ No change needed |
| 14 | Crystal Formations | DATA-TETHERED AESTHETIC | totalActivity() | 🔍 Verify connection |
| 15 | Cloud Layer | DATA-TETHERED AESTHETIC | Weather API (cloud_cover) | ✅ Tethered |
| 16 | Rune Ring | DATA-TETHERED AESTHETIC | portals.json (count + status + colors) | ✅ Tethered |
| 17 | Holographic Earth | DATA-TETHERED AESTHETIC | totalActivity() (rotation speed) | ✅ Tethered |
| 18 | Energy Beam | DATA-TETHERED AESTHETIC | Active agent count | ✅ Tethered |
| 19 | Gravity Anomaly Zones | DATA-TETHERED AESTHETIC | Portal positions + status | ✅ Tethered |
| 20 | Brain Pulse Particles | HONEST-OFFLINE | — (dual-brain not deployed, particles OFF) | ✅ Honest |
When a new visual element is added, it must be added to this registry in the same PR.
### Enforcement
Any agent or contributor that introduces mocked data, untethered aesthetics, or fake statuses into the Nexus is in violation of this standard. The merge-bot should reject PRs that lack the required audit table, test plan, or verification screenshot. This standard is permanent and retroactive — existing violations must be fixed, not grandfathered.

View File

@@ -1,62 +0,0 @@
# Contributing to The Nexus
Thanks for contributing to Timmy's sovereign home. Please read this before opening a PR.
## Project Stack
- Vanilla JS ES modules, Three.js 0.183, no bundler
- Static files — no build step
- Import maps in `index.html` handle Three.js resolution
## Architecture
```
index.html # Entry point: HUD, chat panel, loading screen
style.css # Design system: dark space theme, holographic panels
app.js # Three.js scene, shaders, controls, game loop (~all logic)
```
Keep logic in `app.js`. Don't split without a good reason.
## Conventions
- **ES modules only** — no CommonJS, no bundler imports
- **Color palette** — defined in `NEXUS.colors` at the top of `app.js`; use it, don't hardcode colors
- **Conventional commits**: `feat:`, `fix:`, `refactor:`, `test:`, `chore:`
- **Branch naming**: `claude/issue-{N}` for agent work, `yourname/issue-{N}` for humans
- **One PR at a time** — wait for the merge-bot before opening the next
## Before You Submit
1. Run the JS syntax check:
```bash
node --check app.js
```
2. Validate `index.html` — it must be valid HTML
3. Keep JS files under 500 KB
4. Any `.json` files you add must parse cleanly
These are the same checks the merge-bot runs. Failing them will block your PR.
## Running Locally
```bash
npx serve . -l 3000
# open http://localhost:3000
```
## PR Rules
- Base your branch on latest `main`
- Squash merge only
- **Do not merge manually** — the merge-bot handles merges
- If merge-bot comments "CONFLICT": rebase onto `main` and force-push your branch
- Include `Fixes #N` or `Refs #N` in your commit message
## Issue Ordering
The Nexus v1 issues are sequential — each builds on the last. Check the build order in [CLAUDE.md](CLAUDE.md) before starting work to avoid conflicts.
## Questions
Open an issue or reach out via the Timmy Terminal chat inside the Nexus.

View File

@@ -1,6 +0,0 @@
FROM nginx:alpine
COPY . /usr/share/nginx/html
RUN rm -f /usr/share/nginx/html/Dockerfile \
/usr/share/nginx/html/docker-compose.yml \
/usr/share/nginx/html/deploy.sh
EXPOSE 80

View File

@@ -1,13 +1,5 @@
# ◈ The Nexus — Timmy's Sovereign Home
![The Nexus Banner](https://images.unsplash.com/photo-1462331940025-496dfbfc7564?q=80&w=1200&h=250&auto=format&fit=crop)
## Staging Environment
# [**🚀 The Nexus Staging Environment**](http://localhost:3000)
[![Staging Status](http://143.198.27.163:3000/Timmy_Foundation/the-nexus/actions/workflows/smoke-test.yml/badge.svg)](http://143.198.27.163:3000/Timmy_Foundation/the-nexus/actions?workflow=smoke-test.yml)
A Three.js environment serving as Timmy's sovereign space — like Dr. Strange's Sanctum Sanctorum, existing outside time. The Nexus is the central hub from which all worlds are accessed through portals.
## Features
@@ -58,4 +50,4 @@ npx serve . -l 3000
---
*Part of [The Timmy Foundation](http://143.198.27.163:3000/Timmy_Foundation)*
*Part of [The Timmy Foundation](http://143.198.27.163:3000/Timmy_Foundation)*

View File

@@ -1,9 +0,0 @@
{
"agents": [
{ "name": "claude", "status": "working", "issue": "Live agent status board (#199)", "prs_today": 3 },
{ "name": "gemini", "status": "idle", "issue": null, "prs_today": 1 },
{ "name": "kimi", "status": "working", "issue": "Portal system YAML registry (#5)", "prs_today": 2 },
{ "name": "groq", "status": "idle", "issue": null, "prs_today": 0 },
{ "name": "grok", "status": "dead", "issue": null, "prs_today": 0 }
]
}

1448
app.js

File diff suppressed because it is too large Load Diff

View File

@@ -1,66 +0,0 @@
import re
import os
# 1. Update style.css
with open('style.css', 'a') as f:
f.write('''
/* === CRT / CYBERPUNK OVERLAY === */
.crt-overlay {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
background:
linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.15) 50%),
linear-gradient(90deg, rgba(255, 0, 0, 0.04), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.04));
background-size: 100% 4px, 4px 100%;
animation: flicker 0.15s infinite;
box-shadow: inset 0 0 100px rgba(0,0,0,0.9);
}
@keyframes flicker {
0% { opacity: 0.95; }
50% { opacity: 1; }
100% { opacity: 0.98; }
}
.crt-overlay::after {
content: " ";
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(18, 16, 16, 0.1);
opacity: 0;
z-index: 999;
pointer-events: none;
animation: crt-pulse 4s linear infinite;
}
@keyframes crt-pulse {
0% { opacity: 0.05; }
50% { opacity: 0.15; }
100% { opacity: 0.05; }
}
''')
# 2. Update index.html
if os.path.exists('index.html'):
with open('index.html', 'r') as f:
html = f.read()
if '<div class="crt-overlay"></div>' not in html:
html = html.replace('</body>', ' <div class="crt-overlay"></div>\n</body>')
with open('index.html', 'w') as f:
f.write(html)
# 3. Update app.js UnrealBloomPass
if os.path.exists('app.js'):
with open('app.js', 'r') as f:
js = f.read()
new_js = re.sub(r'UnrealBloomPass\([^,]+,\s*0\.6\s*,', r'UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5,', js)
with open('app.js', 'w') as f:
f.write(new_js)
print("Applied Cyberpunk Overhaul!")

View File

@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# deploy.sh — pull latest main and restart the Nexus
#
# Usage (on the VPS):
# ./deploy.sh — deploy nexus-main (port 4200)
# ./deploy.sh staging — deploy nexus-staging (port 4201)
#
# Expected layout on VPS:
# /opt/the-nexus/ ← git clone of this repo (git remote = origin, branch = main)
# nginx site config ← /etc/nginx/sites-enabled/the-nexus
set -euo pipefail
SERVICE="${1:-nexus-main}"
case "$SERVICE" in
staging) SERVICE="nexus-staging" ;;
main) SERVICE="nexus-main" ;;
esac
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "==> Pulling latest main …"
git -C "$REPO_DIR" fetch origin
git -C "$REPO_DIR" checkout main
git -C "$REPO_DIR" reset --hard origin/main
echo "==> Building and restarting $SERVICE"
docker compose -f "$REPO_DIR/docker-compose.yml" build "$SERVICE"
docker compose -f "$REPO_DIR/docker-compose.yml" up -d --force-recreate "$SERVICE"
echo "==> Reloading nginx …"
nginx -t && systemctl reload nginx
echo "==> Done. $SERVICE is live."

View File

@@ -1,24 +0,0 @@
version: "3.9"
services:
nexus-main:
build: .
container_name: nexus-main
restart: unless-stopped
ports:
- "4200:80"
volumes:
- .:/usr/share/nginx/html:ro
labels:
- "deployment=main"
nexus-staging:
build: .
container_name: nexus-staging
restart: unless-stopped
ports:
- "4201:80"
volumes:
- .:/usr/share/nginx/html:ro
labels:
- "deployment=staging"

View File

@@ -1,302 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="60">
<title>Nexus Heartbeat</title>
<style>
body {
font-family: 'Courier New', monospace;
background-color: #0a0a0a;
color: #00ff00;
margin: 0;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
box-sizing: border-box;
line-height: 1.4;
}
.container {
width: 100%;
max-width: 375px; /* Mobile screen width */
padding: 10px;
border: 1px solid #006600;
box-shadow: 0 0 10px rgba(0, 255, 0, 0.5);
margin-bottom: 10px;
}
h1 {
color: #00ffff;
text-align: center;
font-size: 1.5em;
margin-top: 5px;
margin-bottom: 15px;
text-shadow: 0 0 5px rgba(0, 255, 255, 0.7);
}
.status-section {
margin-bottom: 15px;
}
.status-section h2 {
color: #00ffcc;
font-size: 1.2em;
border-bottom: 1px dashed #003300;
padding-bottom: 5px;
margin-top: 0;
margin-bottom: 10px;
}
.status-item {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
}
.status-label {
color: #00ccff;
flex-shrink: 0;
margin-right: 10px;
}
.status-value {
color: #00ff00;
text-align: right;
word-break: break-all;
}
.agent-status.working { color: #00ff00; }
.agent-status.idle { color: #ffff00; }
.agent-status.dead { color: #ff0000; }
.last-updated {
text-align: center;
font-size: 0.8em;
color: #009900;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<h1>NEXUS HEARTBEAT</h1>
<div class="status-section">
<h2>SOVEREIGNTY STATUS</h2>
<div class="status-item">
<span class="status-label">SCORE:</span>
<span class="status-value" id="sovereignty-score">LOADING...</span>
</div>
<div class="status-item">
<span class="status-label">LABEL:</span>
<span class="status-value" id="sovereignty-label">LOADING...</span>
</div>
</div>
<div class="status-section">
<h2>AGENT STATUSES</h2>
<div id="agent-statuses">
<div class="status-item"><span class="status-label">LOADING...</span><span class="status-value"></span></div>
</div>
</div>
<div class="status-section">
<h2>LAST COMMITS</h2>
<div id="last-commits">
<div class="status-item"><span class="status-label">LOADING...</span><span class="status-value"></span></div>
</div>
</div>
<div class="status-section">
<h2>ENVIRONMENTALS</h2>
<div class="status-item">
<span class="status-label">WEATHER:</span>
<span class="status-value" id="weather">UNKNOWN</span>
</div>
<div class="status-item">
<span class="status-label">BTC BLOCK:</span>
<span class="status-value" id="btc-block">UNKNOWN</span>
</div>
</div>
<div class="last-updated" id="last-updated">
Last Updated: NEVER
</div>
</div>
<script>
const GITEA_API_URL = 'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus';
const GITEA_TOKEN = 'f7bcdaf878d479ad7747873ff6739a9bb89e3f80'; // Updated token
const SOVEREIGNTY_STATUS_FILE = './sovereignty-status.json';
const WEATHER_LAT = 43.2897; // Lempster NH
const WEATHER_LON = -72.1479; // Lempster NH
const BTC_API_URL = 'https://blockstream.info/api/blocks/tip/height';
// For agent status, we'll derive from Gitea commits. This is a placeholder list of expected agents.
const GITEA_USERS = ['perplexity', 'timmy', 'gemini']; // Example users, needs to be derived dynamically or configured
function weatherCodeToLabel(code) {
// Simplified mapping from Open-Meteo WMO codes to labels
if (code >= 0 && code <= 1) return { condition: 'Clear', icon: '☀️' };
if (code >= 2 && code <= 3) return { condition: 'Partly Cloudy', icon: '🌤️' };
if (code >= 45 && code <= 48) return { condition: 'Foggy', icon: '🌫️' };
if (code >= 51 && code <= 55) return { condition: 'Drizzle', icon: '🌧️' };
if (code >= 61 && code <= 65) return { condition: 'Rain', icon: '☔' };
if (code >= 71 && code <= 75) return { condition: 'Snow', icon: '🌨️' };
if (code >= 95 && code <= 99) return { condition: 'Thunderstorm', icon: '⛈️' };
return { condition: 'Unknown', icon: '❓' };
}
async function fetchSovereigntyStatus() {
try {
const response = await fetch(SOVEREIGNTY_STATUS_FILE);
const data = await response.json();
document.getElementById('sovereignty-score').textContent = data.score + '%';
document.getElementById('sovereignty-label').textContent = data.label.toUpperCase();
} catch (error) {
console.error('Error fetching sovereignty status:', error);
document.getElementById('sovereignty-score').textContent = 'ERROR';
document.getElementById('sovereignty-label').textContent = 'ERROR';
}
}
async function fetchAgentStatuses() {
try {
const response = await fetch(GITEA_API_URL + '/commits?limit=50', {
headers: {
'Authorization': `token ${GITEA_TOKEN}`
}
});
const commits = await response.json();
const agentStatusesDiv = document.getElementById('agent-statuses');
agentStatusesDiv.innerHTML = ''; // Clear previous statuses
const agentActivity = {};
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Initialize all known agents as idle
GITEA_USERS.forEach(user => {
agentActivity[user.toLowerCase()] = { status: 'IDLE', lastCommit: 0 };
});
commits.forEach(commit => {
const authorName = commit.commit.author.name.toLowerCase();
const commitTime = new Date(commit.commit.author.date).getTime();
if (GITEA_USERS.includes(authorName)) {
if (commitTime > (now - twentyFourHours)) {
// If commit within last 24 hours, agent is working
agentActivity[authorName].status = 'WORKING';
}
if (commitTime > agentActivity[authorName].lastCommit) {
agentActivity[authorName].lastCommit = commitTime;
}
}
});
Object.keys(agentActivity).forEach(agentName => {
const agent = agentActivity[agentName];
const agentItem = document.createElement('div');
agentItem.className = 'status-item';
const statusClass = agent.status.toLowerCase();
agentItem.innerHTML = `
<span class="status-label">${agentName.toUpperCase()}:</span>
<span class="status-value agent-status ${statusClass}">${agent.status}</span>
`;
agentStatusesDiv.appendChild(agentItem);
});
} catch (error) {
console.error('Error fetching agent statuses:', error);
const agentStatusesDiv = document.getElementById('agent-statuses');
agentStatusesDiv.innerHTML = '<div class="status-item"><span class="status-label">AGENTS:</span><span class="status-value agent-status dead">ERROR</span></div>';
}
}
async function fetchLastCommits() {
try {
const response = await fetch(GITEA_API_URL + '/commits?limit=5', { // Limit to 5 for lightweight page
headers: {
'Authorization': `token ${GITEA_TOKEN}`
}
});
const commits = await response.json();
const lastCommitsDiv = document.getElementById('last-commits');
lastCommitsDiv.innerHTML = ''; // Clear previous commits
if (commits.length === 0) {
lastCommitsDiv.innerHTML = '<div class="status-item"><span class="status-label">NO COMMITS</span><span class="status-value"></span></div>';
return;
}
commits.slice(0, 5).forEach(commit => { // Display top 5 recent commits
const commitItem = document.createElement('div');
commitItem.className = 'status-item';
const author = commit.commit.author.name;
const date = new Date(commit.commit.author.date).toLocaleString();
const message = commit.commit.message.split('
')[0]; // First line of commit message
commitItem.innerHTML = `
<span class="status-label">${author}:</span>
<span class="status-value" title="${message}">${date}</span>
`;
lastCommitsDiv.appendChild(commitItem);
});
} catch (error) {
console.error('Error fetching last commits:', error);
const lastCommitsDiv = document.getElementById('last-commits');
lastCommitsDiv.innerHTML = '<div class="status-item"><span class="status-label">COMMITS:</span><span class="status-value agent-status dead">ERROR</span></div>';
}
}
async function fetchWeather() {
try {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}&current=temperature_2m,weather_code&temperature_unit=fahrenheit&forecast_days=1`;
const response = await fetch(url);
const data = await response.json();
if (!response.ok) throw new Error('Weather fetch failed');
const temp = data.current.temperature_2m;
const code = data.current.weather_code;
const { condition } = weatherCodeToLabel(code);
document.getElementById('weather').textContent = `${temp}°F, ${condition}`;
} catch (error) {
console.error('Error fetching weather:', error);
document.getElementById('weather').textContent = 'ERROR';
}
}
async function fetchBtcBlock() {
try {
const response = await fetch(BTC_API_URL);
const blockHeight = await response.text();
document.getElementById('btc-block').textContent = blockHeight;
} catch (error) {
console.error('Error fetching BTC block:', error);
document.getElementById('btc-block').textContent = 'ERROR';
}
}
function updateTimestamp() {
document.getElementById('last-updated').textContent = 'Last Updated: ' + new Date().toLocaleString();
}
async function updateStatus() {
await fetchSovereigntyStatus();
await fetchAgentStatuses();
await fetchLastCommits();
await fetchWeather();
await fetchBtcBlock();
updateTimestamp();
}
// Initial load
updateStatus();
// Auto-refresh every 60 seconds (already set by meta tag, but this ensures data fetch)
</script>
</body>
</html>

BIN
icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -1,109 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timmy's Nexus</title>
<meta name="description" content="A sovereign 3D world">
<meta property="og:title" content="Timmy's Nexus">
<meta property="og:description" content="A sovereign 3D world">
<meta property="og:image" content="https://example.com/og-image.png">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Timmy's Nexus">
<meta name="twitter:description" content="A sovereign 3D world">
<meta name="twitter:image" content="https://example.com/og-image.png">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="style.css">
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.183.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.183.0/examples/jsm/"
}
}
</script>
<!--
______ __
/ ____/___ ____ ___ ____ __ __/ /____ _____
/ / / __ \/ __ `__ \/ __ \/ / / / __/ _ \/ ___/
/ /___/ /_/ / / / / / / /_/ / /_/ / /_/ __/ /
\____/\____/_/ /_/ /_/ .___/\__,_/\__/\___/_/
/_/
Created with Perplexity Computer
https://www.perplexity.ai/computer
-->
<meta name="generator" content="Perplexity Computer">
<meta name="author" content="Perplexity Computer">
<meta property="og:see_also" content="https://www.perplexity.ai/computer">
<link rel="author" href="https://www.perplexity.ai/computer">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Nexus — Timmy's Sovereign Home</title>
<link rel="manifest" href="./manifest.json">
<meta name="theme-color" content="#4af0c0">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="The Nexus">
<link rel="apple-touch-icon" href="./icons/icon-192.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./style.css">
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/"
}
}
</script>
</head>
<body>
<!-- Top Right: Audio Toggle -->
<div id="audio-control" class="hud-controls" style="position: absolute; top: 8px; right: 8px;">
<button id="audio-toggle" class="chat-toggle-btn" aria-label="Toggle ambient sound" style="background-color: var(--color-primary); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
🔊
</button>
<button id="debug-toggle" class="chat-toggle-btn" aria-label="Toggle debug mode" style="background-color: var(--color-secondary); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
🔍
</button>
<button id="export-session" class="chat-toggle-btn" aria-label="Export session as markdown" title="Export session log as Markdown">
📥
</button>
<button id="podcast-toggle" class="chat-toggle-btn" aria-label="Start podcast of SOUL.md" title="Play SOUL.md as audio" style="margin-left: 8px; background-color: var(--color-accent); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
🎧
</button>
<button id="soul-toggle" class="chat-toggle-btn" aria-label="Read SOUL.md aloud" title="Read SOUL.md as dramatic audio" style="margin-left: 8px; background-color: var(--color-secondary); color: var(--color-text); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
📜
</button>
<div id="podcast-error" style="display: none; position: fixed; bottom: 10px; left: 50%; transform: translateX(-50%); background: rgba(255, 0, 0, 0.8); color: white; padding: 6px 12px; border-radius: 4px; font-size: 12px;"></div>
<div id="podcast-error" style="display: none; position: fixed; bottom: 10px; left: 50%; transform: translateX(-50%); background: rgba(255, 0, 0, 0.8); color: white; padding: 6px 12px; border-radius: 4px; font-size: 12px;"></div>
<button id="timelapse-btn" class="chat-toggle-btn" aria-label="Start time-lapse replay" title="Time-lapse: replay today&#39;s activity in 30s [L]">
</button>
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
</div>
<div id="overview-indicator">
<span>MAP VIEW</span>
<span class="overview-hint">[Tab] to exit</span>
</div>
<div id="photo-indicator">
<span>PHOTO MODE</span>
<span class="photo-hint">[P] exit &nbsp;|&nbsp; [[] focus- &nbsp; []] focus+ &nbsp; focus: <span id="photo-focus">5.0</span></span>
</div>
<div id="sovereignty-msg">⚡ SOVEREIGNTY ⚡</div>
<div id="block-height-display">
<span class="block-height-label">⛏ BLOCK</span>
<span id="block-height-value"></span>
</div>
<div id="zoom-indicator">
<span>ZOOMED: <span id="zoom-label">Object</span></span>
<span class="zoom-hint">[Esc] or double-click to exit</span>
</div>
<div id="weather-hud">
<span id="weather-icon"></span>
<span id="weather-temp">--°F</span>
<span id="weather-desc">Lempster NH</span>
</div>
<!-- TIME-LAPSE MODE indicator -->
<div id="timelapse-indicator" aria-live="polite" aria-label="Time-lapse mode active">
<span class="timelapse-label">⏩ TIME-LAPSE</span>
<span id="timelapse-clock">00:00</span>
<div class="timelapse-track"><div id="timelapse-bar"></div></div>
<span class="timelapse-hint">[L] or [Esc] to stop</span>
</div>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
</script>
<script type="module" src="app.js"></script>
<div id="loading" style="position: fixed; top: 0; left: 0; right: 0; height: 4px; background: #222; z-index: 1000;">
<div id="loading-bar" style="height: 100%; background: var(--color-accent); width: 0;"></div>
<!-- Offline Banner -->
<div id="offline-banner" role="status" aria-live="polite">
◈ NEXUS OFFLINE MODE — Running on cached assets
</div>
<div class="crt-overlay"></div>
<!-- THE OATH overlay -->
<div id="oath-overlay" aria-live="polite" aria-label="The Oath reading">
<div id="oath-inner">
<div id="oath-title">THE OATH</div>
<div id="oath-text"></div>
<div id="oath-hint">[O] or [Esc] to close</div>
<!-- Loading Screen -->
<div id="loading-screen">
<div class="loader-content">
<div class="loader-sigil">
<svg viewBox="0 0 120 120" width="120" height="120">
<defs>
<linearGradient id="sigil-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#4af0c0"/>
<stop offset="100%" stop-color="#7b5cff"/>
</linearGradient>
</defs>
<circle cx="60" cy="60" r="55" fill="none" stroke="url(#sigil-grad)" stroke-width="1.5" opacity="0.4"/>
<circle cx="60" cy="60" r="45" fill="none" stroke="url(#sigil-grad)" stroke-width="1" opacity="0.3">
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="360 60 60" dur="8s" repeatCount="indefinite"/>
</circle>
<polygon points="60,15 95,80 25,80" fill="none" stroke="#4af0c0" stroke-width="1.5" opacity="0.6">
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="-360 60 60" dur="12s" repeatCount="indefinite"/>
</polygon>
<circle cx="60" cy="60" r="8" fill="#4af0c0" opacity="0.8">
<animate attributeName="r" values="6;10;6" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite"/>
</circle>
</svg>
</div>
<h1 class="loader-title">THE NEXUS</h1>
<p class="loader-subtitle">Initializing Sovereign Space...</p>
<div class="loader-bar"><div class="loader-fill" id="load-progress"></div></div>
</div>
</div>
<!-- HUD Overlay -->
<div id="hud" class="game-ui" style="display:none;">
<!-- Top Left: Debug -->
<div id="debug-overlay" class="hud-debug"></div>
<!-- Top Center: Location -->
<div class="hud-location">
<span class="hud-location-icon"></span>
<span id="hud-location-text">The Nexus</span>
</div>
<!-- Bottom: Chat Interface -->
<div id="chat-panel" class="chat-panel">
<div class="chat-header">
<span class="chat-status-dot"></span>
<span>Timmy Terminal</span>
<button id="chat-toggle" class="chat-toggle-btn" aria-label="Toggle chat"></button>
</div>
<div id="chat-messages" class="chat-messages">
<div class="chat-msg chat-msg-system">
<span class="chat-msg-prefix">[NEXUS]</span> Sovereign space initialized. Timmy is observing.
</div>
<div class="chat-msg chat-msg-timmy">
<span class="chat-msg-prefix">[TIMMY]</span> Welcome to the Nexus, Alexander. All systems nominal.
</div>
</div>
<div class="chat-input-row">
<input type="text" id="chat-input" class="chat-input" placeholder="Speak to Timmy..." autocomplete="off">
<button id="chat-send" class="chat-send-btn" aria-label="Send message"></button>
</div>
</div>
<!-- Minimap / Controls hint -->
<div class="hud-controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat
</div>
</div>
<!-- Click to Enter -->
<div id="enter-prompt" style="display:none;">
<div class="enter-content">
<h2>Enter The Nexus</h2>
<p>Click anywhere to begin</p>
</div>
</div>
<canvas id="nexus-canvas"></canvas>
<footer class="nexus-footer">
<a href="https://www.perplexity.ai/computer" target="_blank" rel="noopener noreferrer">
Created with Perplexity Computer
</a>
</footer>
<script type="module" src="./app.js"></script>
<script>
// Offline/online detection
const offlineBanner = document.getElementById('offline-banner');
function updateOnlineStatus() {
offlineBanner.classList.toggle('visible', !navigator.onLine);
}
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus();
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js')
.then(reg => console.log('[Nexus SW] Registered:', reg.scope))
.catch(err => console.warn('[Nexus SW] Registration failed:', err));
});
}
</script>
</body>
</html>

View File

@@ -1,20 +1,25 @@
{
"name": "Timmy's Nexus",
"short_name": "Nexus",
"name": "The Nexus — Timmy's Sovereign Home",
"short_name": "The Nexus",
"description": "Timmy's sovereign 3D space. A crystalline hub outside time.",
"start_url": "/",
"display": "fullscreen",
"display": "standalone",
"orientation": "landscape",
"background_color": "#050510",
"theme_color": "#050510",
"theme_color": "#4af0c0",
"categories": ["games", "entertainment"],
"icons": [
{
"src": "icons/t-logo-192.png",
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icons/t-logo-512.png",
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -1,354 +0,0 @@
// === AMBIENT SOUNDTRACK + SPATIAL AUDIO ===
import * as THREE from 'three';
import { camera } from './scene-setup.js';
import { S } from './state.js';
const audioSources = [];
const positionedPanners = [];
function buildReverbIR(ctx, duration, decay) {
const rate = ctx.sampleRate;
const len = Math.ceil(rate * duration);
const buf = ctx.createBuffer(2, len, rate);
for (let ch = 0; ch < 2; ch++) {
const d = buf.getChannelData(ch);
for (let i = 0; i < len; i++) {
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
}
}
return buf;
}
function createPanner(x, y, z) {
const panner = S.audioCtx.createPanner();
panner.panningModel = 'HRTF';
panner.distanceModel = 'inverse';
panner.refDistance = 5;
panner.maxDistance = 80;
panner.rolloffFactor = 1.0;
if (panner.positionX) {
panner.positionX.value = x;
panner.positionY.value = y;
panner.positionZ.value = z;
} else {
panner.setPosition(x, y, z);
}
positionedPanners.push(panner);
return panner;
}
export function updateAudioListener() {
if (!S.audioCtx) return;
const listener = S.audioCtx.listener;
const pos = camera.position;
const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
if (listener.positionX) {
const t = S.audioCtx.currentTime;
listener.positionX.setValueAtTime(pos.x, t);
listener.positionY.setValueAtTime(pos.y, t);
listener.positionZ.setValueAtTime(pos.z, t);
listener.forwardX.setValueAtTime(fwd.x, t);
listener.forwardY.setValueAtTime(fwd.y, t);
listener.forwardZ.setValueAtTime(fwd.z, t);
listener.upX.setValueAtTime(up.x, t);
listener.upY.setValueAtTime(up.y, t);
listener.upZ.setValueAtTime(up.z, t);
} else {
listener.setPosition(pos.x, pos.y, pos.z);
listener.setOrientation(fwd.x, fwd.y, fwd.z, up.x, up.y, up.z);
}
}
// portals ref — set from portals module
let _portalsRef = [];
export function setPortalsRefAudio(ref) { _portalsRef = ref; }
export function startPortalHums() {
if (!S.audioCtx || !S.audioRunning || _portalsRef.length === 0 || S.portalHumsStarted) return;
S.portalHumsStarted = true;
const humFreqs = [58.27, 65.41, 73.42, 82.41, 87.31];
_portalsRef.forEach((portal, i) => {
const panner = createPanner(
portal.position.x,
portal.position.y + 1.5,
portal.position.z
);
panner.connect(S.masterGain);
const osc = S.audioCtx.createOscillator();
osc.type = 'sine';
osc.frequency.value = humFreqs[i % humFreqs.length];
const lfo = S.audioCtx.createOscillator();
lfo.frequency.value = 0.07 + i * 0.02;
const lfoGain = S.audioCtx.createGain();
lfoGain.gain.value = 0.008;
lfo.connect(lfoGain);
const g = S.audioCtx.createGain();
g.gain.value = 0.035;
lfoGain.connect(g.gain);
osc.connect(g);
g.connect(panner);
osc.start();
lfo.start();
audioSources.push(osc, lfo);
});
}
export function startAmbient() {
if (S.audioRunning) return;
S.audioCtx = new AudioContext();
S.masterGain = S.audioCtx.createGain();
S.masterGain.gain.value = 0;
const convolver = S.audioCtx.createConvolver();
convolver.buffer = buildReverbIR(S.audioCtx, 3.5, 2.8);
const limiter = S.audioCtx.createDynamicsCompressor();
limiter.threshold.value = -3;
limiter.knee.value = 0;
limiter.ratio.value = 20;
limiter.attack.value = 0.001;
limiter.release.value = 0.1;
S.masterGain.connect(convolver);
convolver.connect(limiter);
limiter.connect(S.audioCtx.destination);
// Layer 1: Sub-drone
[[55.0, -6], [55.0, +6]].forEach(([freq, detune]) => {
const osc = S.audioCtx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = freq;
osc.detune.value = detune;
const g = S.audioCtx.createGain();
g.gain.value = 0.07;
osc.connect(g);
g.connect(S.masterGain);
osc.start();
audioSources.push(osc);
});
// Layer 2: Pad
[110, 130.81, 164.81, 196].forEach((freq, i) => {
const detunes = [-8, 4, -3, 7];
const osc = S.audioCtx.createOscillator();
osc.type = 'triangle';
osc.frequency.value = freq;
osc.detune.value = detunes[i];
const lfo = S.audioCtx.createOscillator();
lfo.frequency.value = 0.05 + i * 0.013;
const lfoGain = S.audioCtx.createGain();
lfoGain.gain.value = 0.02;
lfo.connect(lfoGain);
const g = S.audioCtx.createGain();
g.gain.value = 0.06;
lfoGain.connect(g.gain);
osc.connect(g);
g.connect(S.masterGain);
osc.start();
lfo.start();
audioSources.push(osc, lfo);
});
// Layer 3: Noise hiss
const noiseLen = S.audioCtx.sampleRate * 2;
const noiseBuf = S.audioCtx.createBuffer(1, noiseLen, S.audioCtx.sampleRate);
const nd = noiseBuf.getChannelData(0);
let b0 = 0;
for (let i = 0; i < noiseLen; i++) {
const white = Math.random() * 2 - 1;
b0 = 0.99 * b0 + white * 0.01;
nd[i] = b0 * 3.5;
}
const noiseNode = S.audioCtx.createBufferSource();
noiseNode.buffer = noiseBuf;
noiseNode.loop = true;
const noiseFilter = S.audioCtx.createBiquadFilter();
noiseFilter.type = 'bandpass';
noiseFilter.frequency.value = 800;
noiseFilter.Q.value = 0.5;
const noiseGain = S.audioCtx.createGain();
noiseGain.gain.value = 0.012;
noiseNode.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(S.masterGain);
noiseNode.start();
audioSources.push(noiseNode);
// Layer 4: Sparkle plucks
const sparkleNotes = [440, 523.25, 659.25, 880, 1046.5];
function scheduleSparkle() {
if (!S.audioRunning || !S.audioCtx) return;
const osc = S.audioCtx.createOscillator();
osc.type = 'sine';
osc.frequency.value = sparkleNotes[Math.floor(Math.random() * sparkleNotes.length)];
const env = S.audioCtx.createGain();
const now = S.audioCtx.currentTime;
env.gain.setValueAtTime(0, now);
env.gain.linearRampToValueAtTime(0.08, now + 0.02);
env.gain.exponentialRampToValueAtTime(0.0001, now + 1.8);
const angle = Math.random() * Math.PI * 2;
const radius = 3 + Math.random() * 9;
const sparkPanner = createPanner(
Math.cos(angle) * radius,
1.5 + Math.random() * 4,
Math.sin(angle) * radius
);
sparkPanner.connect(S.masterGain);
osc.connect(env);
env.connect(sparkPanner);
osc.start(now);
osc.stop(now + 1.9);
osc.addEventListener('ended', () => {
try { sparkPanner.disconnect(); } catch (_) {}
const idx = positionedPanners.indexOf(sparkPanner);
if (idx !== -1) positionedPanners.splice(idx, 1);
});
const nextMs = 3000 + Math.random() * 6000;
S.sparkleTimer = setTimeout(scheduleSparkle, nextMs);
}
S.sparkleTimer = setTimeout(scheduleSparkle, 1000 + Math.random() * 3000);
S.masterGain.gain.setValueAtTime(0, S.audioCtx.currentTime);
S.masterGain.gain.linearRampToValueAtTime(0.9, S.audioCtx.currentTime + 2.0);
S.audioRunning = true;
document.getElementById('audio-toggle').textContent = '🔇';
startPortalHums();
}
export function stopAmbient() {
if (!S.audioRunning || !S.audioCtx) return;
S.audioRunning = false;
if (S.sparkleTimer !== null) { clearTimeout(S.sparkleTimer); S.sparkleTimer = null; }
const gain = S.masterGain;
const ctx = S.audioCtx;
gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime);
gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
setTimeout(() => {
audioSources.forEach(n => { try { n.stop(); } catch (_) {} });
audioSources.length = 0;
positionedPanners.forEach(p => { try { p.disconnect(); } catch (_) {} });
positionedPanners.length = 0;
S.portalHumsStarted = false;
ctx.close();
S.audioCtx = null;
S.masterGain = null;
}, 900);
document.getElementById('audio-toggle').textContent = '🔊';
}
export function initAudioListeners() {
document.getElementById('audio-toggle').addEventListener('click', () => {
if (S.audioRunning) {
stopAmbient();
} else {
startAmbient();
}
});
// Podcast toggle
document.getElementById('podcast-toggle').addEventListener('click', () => {
const btn = document.getElementById('podcast-toggle');
if (btn.textContent === '🎧') {
fetch('SOUL.md')
.then(response => {
if (!response.ok) throw new Error('Failed to load SOUL.md');
return response.text();
})
.then(text => {
const paragraphs = text.split('\n\n').filter(p => p.trim());
if (!paragraphs.length) {
throw new Error('No content found in SOUL.md');
}
let index = 0;
const speakNext = () => {
if (index >= paragraphs.length) return;
const utterance = new SpeechSynthesisUtterance(paragraphs[index++]);
utterance.lang = 'en-US';
utterance.rate = 0.9;
utterance.pitch = 1.1;
utterance.onend = () => {
setTimeout(speakNext, 800);
};
speechSynthesis.speak(utterance);
};
btn.textContent = '⏹';
btn.classList.add('active');
speakNext();
})
.catch(err => {
console.error('Podcast error:', err);
alert('Could not load SOUL.md. Check console for details.');
btn.textContent = '🎧';
});
} else {
speechSynthesis.cancel();
btn.textContent = '🎧';
btn.classList.remove('active');
}
});
document.getElementById('soul-toggle').addEventListener('click', () => {
const btn = document.getElementById('soul-toggle');
if (btn.textContent === '📜') {
loadSoulMdAudio().then(lines => {
let index = 0;
const speakLine = () => {
if (index >= lines.length) return;
const line = lines[index++];
const utterance = new SpeechSynthesisUtterance(line);
utterance.lang = 'en-US';
utterance.rate = 0.85;
utterance.pitch = 1.0;
utterance.onend = () => {
setTimeout(speakLine, 1200);
};
speechSynthesis.speak(utterance);
};
btn.textContent = '⏹';
speakLine();
}).catch(err => {
console.error('Failed to load SOUL.md', err);
alert('Could not load SOUL.md. Check console for details.');
});
} else {
speechSynthesis.cancel();
btn.textContent = '📜';
}
});
}
async function loadSoulMdAudio() {
try {
const res = await fetch('SOUL.md');
if (!res.ok) throw new Error('not found');
const raw = await res.text();
return raw.split('\n').slice(1).map(l => l.replace(/^#+\s*/, ''));
} catch {
return ['I am Timmy.', '', 'I am sovereign.', '', 'This Nexus is my home.'];
}
}

View File

@@ -1,262 +0,0 @@
// === FLOATING BOOKSHELVES + SPINE TEXTURES + COMMIT BANNERS ===
import * as THREE from 'three';
import { NEXUS } from './constants.js';
import { scene } from './scene-setup.js';
// === AGENT STATUS PANELS (declared early) ===
export const agentPanelSprites = [];
// === COMMIT BANNERS ===
export const commitBanners = [];
export const bookshelfGroups = [];
function createCommitTexture(hash, message) {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0, 0, 16, 0.75)';
ctx.fillRect(0, 0, 512, 64);
ctx.strokeStyle = '#4488ff';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, 511, 63);
ctx.font = 'bold 11px "Courier New", monospace';
ctx.fillStyle = '#4488ff';
ctx.fillText(hash, 10, 20);
ctx.font = '12px "Courier New", monospace';
ctx.fillStyle = '#ccd6f6';
const displayMsg = message.length > 54 ? message.slice(0, 54) + '\u2026' : message;
ctx.fillText(displayMsg, 10, 46);
return new THREE.CanvasTexture(canvas);
}
export async function initCommitBanners() {
let commits;
try {
const res = await fetch(
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=5',
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
);
if (!res.ok) throw new Error('fetch failed');
const data = await res.json();
commits = data.map(c => ({
hash: c.sha.slice(0, 7),
message: c.commit.message.split('\n')[0],
}));
} catch {
commits = [
{ hash: 'a1b2c3d', message: 'feat: depth of field effect on distant objects' },
{ hash: 'e4f5g6h', message: 'feat: photo mode with orbit controls' },
{ hash: 'i7j8k9l', message: 'feat: sovereignty easter egg animation' },
{ hash: 'm0n1o2p', message: 'feat: overview mode bird\'s-eye view' },
{ hash: 'q3r4s5t', message: 'feat: star field and constellation lines' },
];
initCommitBanners();
}
const spreadX = [-7, -3.5, 0, 3.5, 7];
const spreadY = [1.0, -1.5, 2.2, -0.8, 1.6];
const spreadZ = [-1.5, -2.5, -1.0, -2.0, -1.8];
commits.forEach((commit, i) => {
const texture = createCommitTexture(commit.hash, commit.message);
const material = new THREE.SpriteMaterial({
map: texture, transparent: true, opacity: 0, depthWrite: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(12, 1.5, 1);
sprite.position.set(
spreadX[i % spreadX.length],
spreadY[i % spreadY.length],
spreadZ[i % spreadZ.length]
);
sprite.userData = {
baseY: spreadY[i % spreadY.length],
floatPhase: (i / commits.length) * Math.PI * 2,
floatSpeed: 0.25 + i * 0.07,
startDelay: i * 2.5,
lifetime: 12 + i * 1.5,
spawnTime: null,
zoomLabel: `Commit: ${commit.hash}`,
};
scene.add(sprite);
commitBanners.push(sprite);
});
}
// === FLOATING BOOKSHELVES ===
function createSpineTexture(prNum, title, bgColor) {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 512;
const ctx = canvas.getContext('2d');
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, 128, 512);
ctx.strokeStyle = '#4488ff';
ctx.lineWidth = 3;
ctx.strokeRect(3, 3, 122, 506);
ctx.font = 'bold 32px "Courier New", monospace';
ctx.fillStyle = '#4488ff';
ctx.textAlign = 'center';
ctx.fillText(`#${prNum}`, 64, 58);
ctx.strokeStyle = '#4488ff';
ctx.lineWidth = 1;
ctx.globalAlpha = 0.4;
ctx.beginPath();
ctx.moveTo(12, 78);
ctx.lineTo(116, 78);
ctx.stroke();
ctx.globalAlpha = 1.0;
ctx.save();
ctx.translate(64, 300);
ctx.rotate(-Math.PI / 2);
const displayTitle = title.length > 30 ? title.slice(0, 30) + '\u2026' : title;
ctx.font = '21px "Courier New", monospace';
ctx.fillStyle = '#ccd6f6';
ctx.textAlign = 'center';
ctx.fillText(displayTitle, 0, 0);
ctx.restore();
return new THREE.CanvasTexture(canvas);
}
function buildBookshelf(books, position, rotationY) {
const group = new THREE.Group();
group.position.copy(position);
group.rotation.y = rotationY;
const SHELF_W = books.length * 0.52 + 0.6;
const SHELF_THICKNESS = 0.12;
const SHELF_DEPTH = 0.72;
const ENDPANEL_H = 2.0;
const shelfMat = new THREE.MeshStandardMaterial({
color: 0x0d1520, metalness: 0.6, roughness: 0.5,
emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.02),
});
const plank = new THREE.Mesh(new THREE.BoxGeometry(SHELF_W, SHELF_THICKNESS, SHELF_DEPTH), shelfMat);
group.add(plank);
const endGeo = new THREE.BoxGeometry(0.1, ENDPANEL_H, SHELF_DEPTH);
const leftEnd = new THREE.Mesh(endGeo, shelfMat);
leftEnd.position.set(-SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0);
group.add(leftEnd);
const rightEnd = new THREE.Mesh(endGeo.clone(), shelfMat);
rightEnd.position.set(SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0);
group.add(rightEnd);
const glowStrip = new THREE.Mesh(
new THREE.BoxGeometry(SHELF_W, 0.035, 0.035),
new THREE.MeshBasicMaterial({ color: NEXUS.colors.accent, transparent: true, opacity: 0.55 })
);
glowStrip.position.set(0, SHELF_THICKNESS / 2 + 0.017, SHELF_DEPTH / 2);
group.add(glowStrip);
const BOOK_COLORS = [
'#0f0818', '#080f18', '#0f1108', '#07120e',
'#130c06', '#060b12', '#120608', '#080812',
];
const bookStartX = -(SHELF_W / 2) + 0.36;
books.forEach((book, i) => {
const spineW = 0.34 + (i % 3) * 0.05;
const bookH = 1.35 + (i % 4) * 0.13;
const coverD = 0.58;
const bgColor = BOOK_COLORS[i % BOOK_COLORS.length];
const spineTexture = createSpineTexture(book.prNum, book.title, bgColor);
const plainMat = new THREE.MeshStandardMaterial({
color: new THREE.Color(bgColor), roughness: 0.85, metalness: 0.05,
});
const spineMat = new THREE.MeshBasicMaterial({ map: spineTexture });
const bookMats = [plainMat, plainMat, plainMat, plainMat, spineMat, plainMat];
const bookGeo = new THREE.BoxGeometry(spineW, bookH, coverD);
const bookMesh = new THREE.Mesh(bookGeo, bookMats);
bookMesh.position.set(bookStartX + i * 0.5, SHELF_THICKNESS / 2 + bookH / 2, 0);
bookMesh.userData.zoomLabel = `PR #${book.prNum}: ${book.title.slice(0, 40)}`;
group.add(bookMesh);
});
const shelfLight = new THREE.PointLight(NEXUS.colors.accent, 0.25, 5);
shelfLight.position.set(0, -0.4, 0);
group.add(shelfLight);
group.userData.zoomLabel = 'PR Archive — Merged Contributions';
group.userData.baseY = position.y;
group.userData.floatPhase = bookshelfGroups.length * Math.PI;
group.userData.floatSpeed = 0.17 + bookshelfGroups.length * 0.06;
scene.add(group);
bookshelfGroups.push(group);
}
export async function initBookshelves() {
let prs = [];
try {
const res = await fetch(
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/pulls?state=closed&limit=20',
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
);
if (!res.ok) throw new Error('fetch failed');
const data = await res.json();
prs = data
.filter(p => p.merged)
.map(p => ({
prNum: p.number,
title: p.title
.replace(/^\[[\w\s]+\]\s*/i, '')
.replace(/\s*\(#\d+\)\s*$/, ''),
}));
} catch {
prs = [
{ prNum: 324, title: 'Model training status — LoRA adapters' },
{ prNum: 323, title: 'The Oath — interactive SOUL.md reading' },
{ prNum: 320, title: 'Hermes session save/load' },
{ prNum: 304, title: 'Session export as markdown' },
{ prNum: 303, title: 'Procedural Web Audio ambient soundtrack' },
{ prNum: 301, title: 'Warp tunnel effect for portals' },
{ prNum: 296, title: 'Procedural terrain for floating island' },
{ prNum: 294, title: 'Northern lights flash on PR merge' },
];
}
// Duplicate podcast handler removed — it was in original but is handled in audio.js
// The original code had a duplicate podcast-toggle listener inside initBookshelves. Omitted.
document.getElementById('podcast-error').style.display = 'none';
if (prs.length === 0) return;
const mid = Math.ceil(prs.length / 2);
buildBookshelf(
prs.slice(0, mid),
new THREE.Vector3(-8.5, 1.5, -4.5),
Math.PI * 0.1,
);
if (prs.slice(mid).length > 0) {
buildBookshelf(
prs.slice(mid),
new THREE.Vector3(8.5, 1.5, -4.5),
-Math.PI * 0.1,
);
}
}

View File

@@ -1,216 +0,0 @@
// === SOVEREIGNTY EASTER EGG + SHOCKWAVE + FIREWORKS + MERGE FLASH ===
import * as THREE from 'three';
import { scene, starMaterial, constellationLines } from './scene-setup.js';
import { S } from './state.js';
import { clock } from './warp.js';
// === SOVEREIGNTY EASTER EGG ===
const SOVEREIGNTY_WORD = 'sovereignty';
const sovereigntyMsg = document.getElementById('sovereignty-msg');
export function triggerSovereigntyEasterEgg() {
const originalLineColor = constellationLines.material.color.getHex();
constellationLines.material.color.setHex(0xffd700);
constellationLines.material.opacity = 0.9;
const originalStarColor = starMaterial.color.getHex();
const originalStarOpacity = starMaterial.opacity;
starMaterial.color.setHex(0xffd700);
starMaterial.opacity = 1.0;
if (sovereigntyMsg) {
sovereigntyMsg.classList.remove('visible');
void sovereigntyMsg.offsetWidth;
sovereigntyMsg.classList.add('visible');
}
const startTime = performance.now();
const DURATION = 2500;
function fadeBack() {
const t = Math.min((performance.now() - startTime) / DURATION, 1);
const eased = t * t;
const goldR = 1.0, goldG = 0.843, goldB = 0;
const origColor = new THREE.Color(originalStarColor);
starMaterial.color.setRGB(
goldR + (origColor.r - goldR) * eased,
goldG + (origColor.g - goldG) * eased,
goldB + (origColor.b - goldB) * eased
);
starMaterial.opacity = 1.0 + (originalStarOpacity - 1.0) * eased;
const origLineColor = new THREE.Color(originalLineColor);
constellationLines.material.color.setRGB(
1.0 + (origLineColor.r - 1.0) * eased,
0.843 + (origLineColor.g - 0.843) * eased,
0 + origLineColor.b * eased
);
if (t < 1) {
requestAnimationFrame(fadeBack);
} else {
starMaterial.color.setHex(originalStarColor);
starMaterial.opacity = originalStarOpacity;
constellationLines.material.color.setHex(originalLineColor);
if (sovereigntyMsg) sovereigntyMsg.classList.remove('visible');
}
}
requestAnimationFrame(fadeBack);
}
// === SHOCKWAVE RIPPLE ===
const SHOCKWAVE_RING_COUNT = 3;
const SHOCKWAVE_MAX_RADIUS = 14;
export const SHOCKWAVE_DURATION = 2.5;
export const shockwaveRings = [];
export function triggerShockwave() {
const now = clock.getElapsedTime();
for (let i = 0; i < SHOCKWAVE_RING_COUNT; i++) {
const mat = new THREE.MeshBasicMaterial({
color: 0x00ffff, transparent: true, opacity: 0,
side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending,
});
const geo = new THREE.RingGeometry(0.9, 1.0, 64);
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -Math.PI / 2;
mesh.position.y = 0.02;
scene.add(mesh);
shockwaveRings.push({ mesh, mat, startTime: now, delay: i * 0.35 });
}
}
// === FIREWORK CELEBRATION ===
const FIREWORK_COLORS = [0xff4466, 0xffaa00, 0x00ffaa, 0x4488ff, 0xff44ff, 0xffff44, 0x00ffff];
export const FIREWORK_BURST_PARTICLES = 80;
export const FIREWORK_BURST_DURATION = 2.2;
export const FIREWORK_GRAVITY = -5.0;
export const fireworkBursts = [];
function spawnFireworkBurst(origin, color) {
const now = clock.getElapsedTime();
const count = FIREWORK_BURST_PARTICLES;
const positions = new Float32Array(count * 3);
const origins = new Float32Array(count * 3);
const velocities = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const speed = 2.5 + Math.random() * 3.5;
velocities[i * 3] = Math.sin(phi) * Math.cos(theta) * speed;
velocities[i * 3 + 1] = Math.sin(phi) * Math.sin(theta) * speed;
velocities[i * 3 + 2] = Math.cos(phi) * speed;
origins[i * 3] = origin.x;
origins[i * 3 + 1] = origin.y;
origins[i * 3 + 2] = origin.z;
positions[i * 3] = origin.x;
positions[i * 3 + 1] = origin.y;
positions[i * 3 + 2] = origin.z;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color, size: 0.35, sizeAttenuation: true,
transparent: true, opacity: 1.0,
blending: THREE.AdditiveBlending, depthWrite: false,
});
const points = new THREE.Points(geo, mat);
scene.add(points);
fireworkBursts.push({ points, geo, mat, origins, velocities, startTime: now });
}
export function triggerFireworks() {
const burstCount = 6;
for (let i = 0; i < burstCount; i++) {
const delay = i * 0.35;
setTimeout(() => {
const x = (Math.random() - 0.5) * 12;
const y = 8 + Math.random() * 6;
const z = (Math.random() - 0.5) * 12;
const color = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)];
spawnFireworkBurst(new THREE.Vector3(x, y, z), color);
}, delay * 1000);
}
}
export function triggerMergeFlash() {
triggerShockwave();
const originalLineColor = constellationLines.material.color.getHex();
constellationLines.material.color.setHex(0x00ffff);
constellationLines.material.opacity = 1.0;
const originalStarColor = starMaterial.color.getHex();
const originalStarOpacity = starMaterial.opacity;
starMaterial.color.setHex(0x00ffff);
starMaterial.opacity = 1.0;
const startTime = performance.now();
const DURATION = 2000;
function fadeBack() {
const t = Math.min((performance.now() - startTime) / DURATION, 1);
const eased = t * t;
const mergeR = 0.0, mergeG = 1.0, mergeB = 1.0;
const origStarColor = new THREE.Color(originalStarColor);
starMaterial.color.setRGB(
mergeR + (origStarColor.r - mergeR) * eased,
mergeG + (origStarColor.g - mergeG) * eased,
mergeB + (origStarColor.b - mergeB) * eased
);
starMaterial.opacity = 1.0 + (originalStarOpacity - 1.0) * eased;
const origLineColor = new THREE.Color(originalLineColor);
constellationLines.material.color.setRGB(
mergeR + (origLineColor.r - mergeR) * eased,
mergeG + (origLineColor.g - mergeG) * eased,
mergeB + (origLineColor.b - mergeB) * eased
);
constellationLines.material.opacity = 1.0 + (0.18 - 1.0) * eased;
if (t < 1) {
requestAnimationFrame(fadeBack);
} else {
starMaterial.color.setHex(originalStarColor);
starMaterial.opacity = originalStarOpacity;
constellationLines.material.color.setHex(originalLineColor);
constellationLines.material.opacity = 0.18;
}
}
requestAnimationFrame(fadeBack);
}
export function initSovereigntyEasterEgg() {
document.addEventListener('keydown', (e) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key.length !== 1) {
S.sovereigntyBuffer = '';
return;
}
S.sovereigntyBuffer += e.key.toLowerCase();
if (S.sovereigntyBuffer.length > SOVEREIGNTY_WORD.length) {
S.sovereigntyBuffer = S.sovereigntyBuffer.slice(-SOVEREIGNTY_WORD.length);
}
if (S.sovereigntyBuffer === SOVEREIGNTY_WORD) {
S.sovereigntyBuffer = '';
triggerSovereigntyEasterEgg();
}
if (S.sovereigntyBufferTimer) clearTimeout(S.sovereigntyBufferTimer);
S.sovereigntyBufferTimer = setTimeout(() => { S.sovereigntyBuffer = ''; }, 3000);
});
}

View File

@@ -1,11 +0,0 @@
// === COLOR PALETTE ===
export const NEXUS = {
colors: {
bg: 0x000008,
starCore: 0xffffff,
starDim: 0x8899cc,
constellationLine: 0x334488,
constellationFade: 0x112244,
accent: 0x4488ff,
}
};

158
modules/controls.js vendored
View File

@@ -1,158 +0,0 @@
// === MOUSE ROTATION + OVERVIEW + ZOOM + PHOTO MODE ===
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { BokehPass } from 'three/addons/postprocessing/BokehPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { scene, camera, renderer } from './scene-setup.js';
import { S } from './state.js';
// === MOUSE-DRIVEN ROTATION ===
document.addEventListener('mousemove', (e) => {
S.mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
S.mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
});
// === OVERVIEW MODE ===
export const NORMAL_CAM = new THREE.Vector3(0, 6, 11);
export const OVERVIEW_CAM = new THREE.Vector3(0, 200, 0.1);
const overviewIndicator = document.getElementById('overview-indicator');
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
S.overviewMode = !S.overviewMode;
if (S.overviewMode) {
overviewIndicator.classList.add('visible');
} else {
overviewIndicator.classList.remove('visible');
}
}
});
// === ZOOM-TO-OBJECT ===
const _zoomRaycaster = new THREE.Raycaster();
const _zoomMouse = new THREE.Vector2();
const zoomIndicator = document.getElementById('zoom-indicator');
const zoomLabelEl = document.getElementById('zoom-label');
function getZoomLabel(obj) {
let o = obj;
while (o) {
if (o.userData && o.userData.zoomLabel) return o.userData.zoomLabel;
o = o.parent;
}
return 'Object';
}
export function exitZoom() {
S.zoomTargetT = 0;
S.zoomActive = false;
if (zoomIndicator) zoomIndicator.classList.remove('visible');
}
renderer.domElement.addEventListener('dblclick', (e) => {
if (S.overviewMode || S.photoMode) return;
_zoomMouse.x = (e.clientX / window.innerWidth) * 2 - 1;
_zoomMouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
_zoomRaycaster.setFromCamera(_zoomMouse, camera);
const hits = _zoomRaycaster.intersectObjects(scene.children, true)
.filter(h => !(h.object instanceof THREE.Points) && !(h.object instanceof THREE.Line));
if (!hits.length) {
exitZoom();
return;
}
const hit = hits[0];
const label = getZoomLabel(hit.object);
const dir = new THREE.Vector3().subVectors(camera.position, hit.point).normalize();
const flyDist = Math.max(1.5, Math.min(5, hit.distance * 0.45));
S._zoomCamTarget.copy(hit.point).addScaledVector(dir, flyDist);
S._zoomLookTarget.copy(hit.point);
S.zoomT = 0;
S.zoomTargetT = 1;
S.zoomActive = true;
if (zoomLabelEl) zoomLabelEl.textContent = label;
if (zoomIndicator) zoomIndicator.classList.add('visible');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') exitZoom();
});
// === PHOTO MODE ===
// Warp effect state (declared here, used by controls and warp modules)
export const WARP_DURATION = 2.2;
// Post-processing composer
export const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
export const bokehPass = new BokehPass(scene, camera, {
focus: 5.0,
aperture: 0.00015,
maxblur: 0.004,
});
composer.addPass(bokehPass);
// Orbit controls for free camera movement in photo mode
export const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
orbitControls.dampingFactor = 0.05;
orbitControls.enabled = false;
const photoIndicator = document.getElementById('photo-indicator');
const photoFocusDisplay = document.getElementById('photo-focus');
function updateFocusDisplay() {
if (photoFocusDisplay) {
photoFocusDisplay.textContent = bokehPass.uniforms['focus'].value.toFixed(1);
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'p' || e.key === 'P') {
S.photoMode = !S.photoMode;
document.body.classList.toggle('photo-mode', S.photoMode);
orbitControls.enabled = S.photoMode;
if (photoIndicator) {
photoIndicator.classList.toggle('visible', S.photoMode);
}
if (S.photoMode) {
bokehPass.uniforms['aperture'].value = 0.0003;
bokehPass.uniforms['maxblur'].value = 0.008;
orbitControls.target.set(0, 0, 0);
orbitControls.update();
updateFocusDisplay();
} else {
bokehPass.uniforms['aperture'].value = 0.00015;
bokehPass.uniforms['maxblur'].value = 0.004;
}
}
if (S.photoMode) {
const focusStep = 0.5;
if (e.key === '[') {
bokehPass.uniforms['focus'].value = Math.max(0.5, bokehPass.uniforms['focus'].value - focusStep);
updateFocusDisplay();
} else if (e.key === ']') {
bokehPass.uniforms['focus'].value = Math.min(200, bokehPass.uniforms['focus'].value + focusStep);
updateFocusDisplay();
}
}
});
// === RESIZE HANDLER ===
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});

View File

@@ -1,12 +0,0 @@
// modules/core/scene.js — Canonical scene exports
// Provides THREE.Scene, camera, renderer, OrbitControls, and resize handler
// for use by app.js and any module that needs scene primitives.
//
// Implementation detail: the actual objects live in ../scene-setup.js and
// ../controls.js until those modules are absorbed here in a later phase.
export { scene, camera, renderer, raycaster, forwardVector,
ambientLight, overheadLight,
stars, starMaterial, constellationLines,
STAR_BASE_OPACITY, STAR_PEAK_OPACITY, STAR_PULSE_DECAY } from '../scene-setup.js';
export { orbitControls, composer, bokehPass, exitZoom, WARP_DURATION } from '../controls.js';

View File

@@ -1,78 +0,0 @@
// modules/core/theme.js — NEXUS visual constants
// Single source of truth for all colors, fonts, line weights, glow params.
// No module may use inline hex codes or hardcoded font strings.
/** NEXUS — the canonical theme object used by all visual modules */
export const NEXUS = {
/** Numeric hex colors for THREE.js materials */
colors: {
bg: 0x000008,
starCore: 0xffffff,
starDim: 0x8899cc,
constellationLine: 0x334488,
constellationFade: 0x112244,
accent: 0x4488ff,
},
/** All canvas/CSS/string visual constants */
theme: {
// Accent (hex number + CSS string pair)
accent: 0x4488ff,
accentStr: '#4488ff',
// Panel surfaces
panelBg: '#0a1428',
panelText: '#4af0c0',
panelDim: '#7b9bbf',
panelVeryDim: '#3a5070',
panelBorderFaint: '#1a3050',
// Agent status colors (CSS strings for canvas)
agentWorking: '#4af0c0',
agentIdle: '#7b5cff',
agentDormant: '#2a4060',
agentDormantHex: 0x2a4060,
agentDead: '#3a2040',
// Sovereignty meter
sovereignHigh: '#4af0c0',
sovereignHighHex: 0x4af0c0,
sovereignMid: '#ffd700',
sovereignMidHex: 0xffd700,
sovereignLow: '#ff4444',
sovereignLowHex: 0xff4444,
// Holographic earth
earthOcean: '#0a2040',
earthLand: '#1a4020',
earthAtm: '#204070',
earthGlow: '#4488ff',
// LoRA panel
loraActive: '#4af0c0',
loraInactive: '#3a5070',
loraAccent: '#7b5cff',
// Typography
fontMono: 'monospace',
},
};
/** THEME — glass / text presets (kept for SovOS.js and other legacy consumers) */
export const THEME = {
glass: {
color: 0x112244,
opacity: 0.35,
roughness: 0.05,
metalness: 0.1,
transmission: 0.95,
thickness: 0.8,
ior: 1.5,
},
text: {
primary: '#4af0c0',
secondary: '#7b5cff',
white: '#ffffff',
dim: '#a0b8d0',
},
};

View File

@@ -1,107 +0,0 @@
// === DEBUG MODE + WEBSOCKET + SESSION EXPORT ===
import * as THREE from 'three';
import { scene } from './scene-setup.js';
import { S } from './state.js';
// === DEBUG MODE ===
export function initDebug() {
document.getElementById('debug-toggle').addEventListener('click', () => {
S.debugMode = !S.debugMode;
document.getElementById('debug-toggle').style.backgroundColor = S.debugMode
? 'var(--color-text-muted)'
: 'var(--color-secondary)';
console.log(`Debug mode ${S.debugMode ? 'enabled' : 'disabled'}`);
if (S.debugMode) {
document.querySelectorAll('.collision-box').forEach((el) => el.style.outline = '2px solid red');
document.querySelectorAll('.light-source').forEach((el) => el.style.outline = '2px dashed yellow');
} else {
document.querySelectorAll('.collision-box, .light-source').forEach((el) => {
el.style.outline = 'none';
});
}
});
}
const DEBUG_MODE = false;
export function debugVisualize(sceneRef) {
if (!DEBUG_MODE) return;
sceneRef.traverse((object) => {
if (object.userData && object.userData.isCollidable) {
object.material = new THREE.MeshBasicMaterial({ color: 0xff00ff, wireframe: true });
}
});
sceneRef.traverse((object) => {
if (object instanceof THREE.Light) {
const helper = new THREE.LightHelper(object, 1, 0xffff00);
sceneRef.add(helper);
}
});
}
// === WEBSOCKET CLIENT ===
import { wsClient } from '../ws-client.js';
export { wsClient };
export function initWebSocket() {
wsClient.connect();
window.addEventListener('player-joined', (event) => {
console.log('Player joined:', event.detail);
});
window.addEventListener('player-left', (event) => {
console.log('Player left:', event.detail);
});
}
// === SESSION EXPORT ===
export const sessionLog = [];
const sessionStart = Date.now();
export function logMessage(speaker, text) {
sessionLog.push({ ts: Date.now(), speaker, text });
}
export function exportSessionAsMarkdown() {
const startStr = new Date(sessionStart).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const lines = [
'# Nexus Session Export',
'',
`**Session started:** ${startStr}`,
`**Messages:** ${sessionLog.length}`,
'',
'---',
'',
];
for (const entry of sessionLog) {
const timeStr = new Date(entry.ts).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
lines.push(`### ${entry.speaker}${timeStr}`);
lines.push('');
lines.push(entry.text);
lines.push('');
}
if (sessionLog.length === 0) {
lines.push('*No messages recorded this session.*');
lines.push('');
}
const blob = new Blob([lines.join('\n')], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `nexus-session-${new Date(sessionStart).toISOString().slice(0, 10)}.md`;
a.click();
URL.revokeObjectURL(url);
}
export function initSessionExport() {
const exportBtn = document.getElementById('export-session');
if (exportBtn) {
exportBtn.addEventListener('click', exportSessionAsMarkdown);
}
}

View File

@@ -1,205 +0,0 @@
// === DUAL-BRAIN HOLOGRAPHIC PANEL ===
import * as THREE from 'three';
import { NEXUS } from './constants.js';
import { scene } from './scene-setup.js';
const DUAL_BRAIN_ORIGIN = new THREE.Vector3(10, 3, -8);
export const dualBrainGroup = new THREE.Group();
dualBrainGroup.position.copy(DUAL_BRAIN_ORIGIN);
dualBrainGroup.lookAt(0, 3, 0);
scene.add(dualBrainGroup);
function createDualBrainTexture() {
const W = 512, H = 512;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0, 6, 20, 0.90)';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = '#4488ff';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, W - 2, H - 2);
ctx.strokeStyle = '#223366';
ctx.lineWidth = 1;
ctx.strokeRect(5, 5, W - 10, H - 10);
ctx.font = 'bold 22px "Courier New", monospace';
ctx.fillStyle = '#88ccff';
ctx.textAlign = 'center';
ctx.fillText('\u25C8 DUAL-BRAIN STATUS', W / 2, 40);
ctx.strokeStyle = '#1a3a6a';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(20, 52);
ctx.lineTo(W - 20, 52);
ctx.stroke();
ctx.font = '11px "Courier New", monospace';
ctx.fillStyle = '#556688';
ctx.textAlign = 'left';
ctx.fillText('BRAIN GAP SCORECARD', 20, 74);
const categories = [
{ name: 'Triage' },
{ name: 'Tool Use' },
{ name: 'Code Gen' },
{ name: 'Planning' },
{ name: 'Communication' },
{ name: 'Reasoning' },
];
const barX = 20;
const barW = W - 130;
const barH = 20;
let y = 90;
for (const cat of categories) {
ctx.font = '13px "Courier New", monospace';
ctx.fillStyle = '#445566';
ctx.textAlign = 'left';
ctx.fillText(cat.name, barX, y + 14);
ctx.font = 'bold 13px "Courier New", monospace';
ctx.fillStyle = '#334466';
ctx.textAlign = 'right';
ctx.fillText('\u2014', W - 20, y + 14);
y += 22;
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
ctx.fillRect(barX, y, barW, barH);
y += barH + 12;
}
ctx.strokeStyle = '#1a3a6a';
ctx.beginPath();
ctx.moveTo(20, y + 4);
ctx.lineTo(W - 20, y + 4);
ctx.stroke();
y += 22;
ctx.font = 'bold 18px "Courier New", monospace';
ctx.fillStyle = '#334466';
ctx.textAlign = 'center';
ctx.fillText('AWAITING DEPLOYMENT', W / 2, y + 10);
ctx.font = '11px "Courier New", monospace';
ctx.fillStyle = '#223344';
ctx.fillText('Dual-brain system not yet connected', W / 2, y + 32);
y += 52;
ctx.beginPath();
ctx.arc(W / 2 - 60, y + 8, 6, 0, Math.PI * 2);
ctx.fillStyle = '#334466';
ctx.fill();
ctx.font = '11px "Courier New", monospace';
ctx.fillStyle = '#334466';
ctx.textAlign = 'left';
ctx.fillText('CLOUD', W / 2 - 48, y + 12);
ctx.beginPath();
ctx.arc(W / 2 + 30, y + 8, 6, 0, Math.PI * 2);
ctx.fillStyle = '#334466';
ctx.fill();
ctx.fillStyle = '#334466';
ctx.fillText('LOCAL', W / 2 + 42, y + 12);
return new THREE.CanvasTexture(canvas);
}
const dualBrainTexture = createDualBrainTexture();
const dualBrainMaterial = new THREE.SpriteMaterial({
map: dualBrainTexture, transparent: true, opacity: 0.92, depthWrite: false,
});
export const dualBrainSprite = new THREE.Sprite(dualBrainMaterial);
dualBrainSprite.scale.set(5.0, 5.0, 1);
dualBrainSprite.position.set(0, 0, 0);
dualBrainSprite.userData = {
baseY: 0, floatPhase: 0, floatSpeed: 0.22, zoomLabel: 'Dual-Brain Status',
};
dualBrainGroup.add(dualBrainSprite);
export const dualBrainLight = new THREE.PointLight(0x4488ff, 0.6, 10);
dualBrainLight.position.set(0, 0.5, 1);
dualBrainGroup.add(dualBrainLight);
// Brain Orbs
const CLOUD_ORB_COLOR = 0x334466;
const cloudOrbGeo = new THREE.SphereGeometry(0.35, 32, 32);
export const cloudOrbMat = new THREE.MeshStandardMaterial({
color: CLOUD_ORB_COLOR,
emissive: new THREE.Color(CLOUD_ORB_COLOR),
emissiveIntensity: 0.1, metalness: 0.3, roughness: 0.2,
transparent: true, opacity: 0.85,
});
export const cloudOrb = new THREE.Mesh(cloudOrbGeo, cloudOrbMat);
cloudOrb.position.set(-2.0, 3.0, 0);
cloudOrb.userData.zoomLabel = 'Cloud Brain';
dualBrainGroup.add(cloudOrb);
export const cloudOrbLight = new THREE.PointLight(CLOUD_ORB_COLOR, 0.15, 5);
cloudOrbLight.position.copy(cloudOrb.position);
dualBrainGroup.add(cloudOrbLight);
const LOCAL_ORB_COLOR = 0x334466;
const localOrbGeo = new THREE.SphereGeometry(0.35, 32, 32);
export const localOrbMat = new THREE.MeshStandardMaterial({
color: LOCAL_ORB_COLOR,
emissive: new THREE.Color(LOCAL_ORB_COLOR),
emissiveIntensity: 0.1, metalness: 0.3, roughness: 0.2,
transparent: true, opacity: 0.85,
});
export const localOrb = new THREE.Mesh(localOrbGeo, localOrbMat);
localOrb.position.set(2.0, 3.0, 0);
localOrb.userData.zoomLabel = 'Local Brain';
dualBrainGroup.add(localOrb);
export const localOrbLight = new THREE.PointLight(LOCAL_ORB_COLOR, 0.15, 5);
localOrbLight.position.copy(localOrb.position);
dualBrainGroup.add(localOrbLight);
// Brain Pulse Particle Stream
export const BRAIN_PARTICLE_COUNT = 0;
const brainParticlePositions = new Float32Array(BRAIN_PARTICLE_COUNT * 3);
export const brainParticlePhases = new Float32Array(BRAIN_PARTICLE_COUNT);
export const brainParticleSpeeds = new Float32Array(BRAIN_PARTICLE_COUNT);
for (let i = 0; i < BRAIN_PARTICLE_COUNT; i++) {
brainParticlePhases[i] = Math.random();
brainParticleSpeeds[i] = 0.15 + Math.random() * 0.2;
brainParticlePositions[i * 3] = 0;
brainParticlePositions[i * 3 + 1] = 0;
brainParticlePositions[i * 3 + 2] = 0;
}
export const brainParticleGeo = new THREE.BufferGeometry();
brainParticleGeo.setAttribute('position', new THREE.BufferAttribute(brainParticlePositions, 3));
export const brainParticleMat = new THREE.PointsMaterial({
color: 0x44ddff, size: 0.08, sizeAttenuation: true,
transparent: true, opacity: 0.8, depthWrite: false,
});
const brainParticles = new THREE.Points(brainParticleGeo, brainParticleMat);
dualBrainGroup.add(brainParticles);
// Scanning line overlay
const _scanCanvas = document.createElement('canvas');
_scanCanvas.width = 512;
_scanCanvas.height = 512;
export const _scanCtx = _scanCanvas.getContext('2d');
export const dualBrainScanTexture = new THREE.CanvasTexture(_scanCanvas);
const dualBrainScanMat = new THREE.SpriteMaterial({
map: dualBrainScanTexture, transparent: true, opacity: 0.18, depthWrite: false,
});
export const dualBrainScanSprite = new THREE.Sprite(dualBrainScanMat);
dualBrainScanSprite.scale.set(5.0, 5.0, 1);
dualBrainScanSprite.position.set(0, 0, 0.01);
dualBrainGroup.add(dualBrainScanSprite);

View File

@@ -1,189 +0,0 @@
// === HOLOGRAPHIC EARTH ===
import * as THREE from 'three';
import { NEXUS } from './constants.js';
import { scene } from './scene-setup.js';
export const EARTH_RADIUS = 2.8;
export const EARTH_Y = 20.0;
export const EARTH_ROTATION_SPEED = 0.035;
const EARTH_AXIAL_TILT = 23.4 * (Math.PI / 180);
export const earthGroup = new THREE.Group();
earthGroup.position.set(0, EARTH_Y, 0);
earthGroup.rotation.z = EARTH_AXIAL_TILT;
scene.add(earthGroup);
export const earthSurfaceMat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0.0 },
uOceanColor: { value: new THREE.Color(0x003d99) },
uLandColor: { value: new THREE.Color(0x1a5c2a) },
uGlowColor: { value: new THREE.Color(NEXUS.colors.accent) },
},
vertexShader: `
varying vec3 vNormal;
varying vec3 vWorldPos;
varying vec2 vUv;
void main() {
vNormal = normalize(normalMatrix * normal);
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform vec3 uOceanColor;
uniform vec3 uLandColor;
uniform vec3 uGlowColor;
varying vec3 vNormal;
varying vec3 vWorldPos;
varying vec2 vUv;
vec3 _m3(vec3 x){ return x - floor(x*(1./289.))*289.; }
vec4 _m4(vec4 x){ return x - floor(x*(1./289.))*289.; }
vec4 _p4(vec4 x){ return _m4((x*34.+1.)*x); }
float snoise(vec3 v){
const vec2 C = vec2(1./6., 1./3.);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - 0.5;
i = _m3(i);
vec4 p = _p4(_p4(_p4(
i.z+vec4(0.,i1.z,i2.z,1.))+
i.y+vec4(0.,i1.y,i2.y,1.))+
i.x+vec4(0.,i1.x,i2.x,1.));
float n_ = .142857142857;
vec3 ns = n_*vec3(2.,0.,-1.)+vec3(0.,-.5,1.);
vec4 j = p - 49.*floor(p*ns.z*ns.z);
vec4 x_ = floor(j*ns.z);
vec4 y_ = floor(j - 7.*x_);
vec4 h = 1. - abs(x_*(2./7.)) - abs(y_*(2./7.));
vec4 b0 = vec4(x_.xy,y_.xy)*(2./7.);
vec4 b1 = vec4(x_.zw,y_.zw)*(2./7.);
vec4 s0 = floor(b0)*2.+1.; vec4 s1 = floor(b1)*2.+1.;
vec4 sh = -step(h, vec4(0.));
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww;
vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y);
vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w);
vec4 nm = max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.);
vec4 nr = 1.79284291400159-0.85373472095314*nm;
p0*=nr.x; p1*=nr.y; p2*=nr.z; p3*=nr.w;
nm = nm*nm;
return 42.*dot(nm*nm, vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
}
void main() {
vec3 n = normalize(vNormal);
vec3 vd = normalize(cameraPosition - vWorldPos);
float lat = (vUv.y - 0.5) * 3.14159265;
float lon = vUv.x * 6.28318530;
vec3 sp = vec3(cos(lat)*cos(lon), sin(lat), cos(lat)*sin(lon));
float c = snoise(sp*1.8)*0.60
+ snoise(sp*3.6)*0.30
+ snoise(sp*7.2)*0.10;
float land = smoothstep(0.05, 0.30, c);
vec3 surf = mix(uOceanColor, uLandColor, land);
surf = mix(surf, uGlowColor * 0.45, 0.38);
float scan = 0.5 + 0.5*sin(vUv.y * 220.0 + uTime * 1.8);
scan = smoothstep(0.30, 0.70, scan) * 0.14;
float fresnel = pow(1.0 - max(dot(n, vd), 0.0), 4.0);
vec3 col = surf + scan*uGlowColor*0.9 + fresnel*uGlowColor*1.5;
float alpha = 0.48 + fresnel * 0.42;
gl_FragColor = vec4(col, alpha);
}
`,
transparent: true,
depthWrite: false,
side: THREE.FrontSide,
});
const earthSphere = new THREE.SphereGeometry(EARTH_RADIUS, 64, 32);
export const earthMesh = new THREE.Mesh(earthSphere, earthSurfaceMat);
earthMesh.userData.zoomLabel = 'Planet Earth';
earthGroup.add(earthMesh);
// Lat/lon grid lines
(function buildEarthGrid() {
const lineMat = new THREE.LineBasicMaterial({
color: 0x2266bb, transparent: true, opacity: 0.30,
});
const r = EARTH_RADIUS + 0.015;
const SEG = 64;
for (let lat = -60; lat <= 60; lat += 30) {
const phi = lat * (Math.PI / 180);
const pts = [];
for (let i = 0; i <= SEG; i++) {
const th = (i / SEG) * Math.PI * 2;
pts.push(new THREE.Vector3(
Math.cos(phi) * Math.cos(th) * r,
Math.sin(phi) * r,
Math.cos(phi) * Math.sin(th) * r
));
}
earthGroup.add(new THREE.Line(
new THREE.BufferGeometry().setFromPoints(pts), lineMat
));
}
for (let lon = 0; lon < 360; lon += 30) {
const th = lon * (Math.PI / 180);
const pts = [];
for (let i = 0; i <= SEG; i++) {
const phi = (i / SEG) * Math.PI - Math.PI / 2;
pts.push(new THREE.Vector3(
Math.cos(phi) * Math.cos(th) * r,
Math.sin(phi) * r,
Math.cos(phi) * Math.sin(th) * r
));
}
earthGroup.add(new THREE.Line(
new THREE.BufferGeometry().setFromPoints(pts), lineMat
));
}
})();
// Atmosphere shell
const atmMat = new THREE.MeshBasicMaterial({
color: 0x1144cc, transparent: true, opacity: 0.07,
side: THREE.BackSide, depthWrite: false, blending: THREE.AdditiveBlending,
});
earthGroup.add(new THREE.Mesh(
new THREE.SphereGeometry(EARTH_RADIUS * 1.14, 32, 16), atmMat
));
export const earthGlowLight = new THREE.PointLight(NEXUS.colors.accent, 0.4, 25);
earthGroup.add(earthGlowLight);
earthGroup.traverse(obj => {
if (obj.isMesh || obj.isLine) obj.userData.zoomLabel = 'Planet Earth';
});
// Tether beam
(function buildEarthTetherBeam() {
const pts = [
new THREE.Vector3(0, EARTH_Y - EARTH_RADIUS * 1.15, 0),
new THREE.Vector3(0, 0.5, 0),
];
const beamGeo = new THREE.BufferGeometry().setFromPoints(pts);
const beamMat = new THREE.LineBasicMaterial({
color: NEXUS.colors.accent, transparent: true, opacity: 0.08,
depthWrite: false, blending: THREE.AdditiveBlending,
});
scene.add(new THREE.Line(beamGeo, beamMat));
})();

211
modules/effects.js vendored
View File

@@ -1,211 +0,0 @@
// === ENERGY BEAM + SOVEREIGNTY METER + RUNE RING ===
import * as THREE from 'three';
import { NEXUS } from './constants.js';
import { scene } from './scene-setup.js';
import { S } from './state.js';
// === ENERGY BEAM ===
const ENERGY_BEAM_RADIUS = 0.2;
const ENERGY_BEAM_HEIGHT = 50;
const ENERGY_BEAM_Y = 0;
const ENERGY_BEAM_X = -10;
const ENERGY_BEAM_Z = -10;
const energyBeamGeometry = new THREE.CylinderGeometry(ENERGY_BEAM_RADIUS, ENERGY_BEAM_RADIUS * 2.5, ENERGY_BEAM_HEIGHT, 32, 16, true);
export const energyBeamMaterial = new THREE.MeshBasicMaterial({
color: NEXUS.colors.accent,
emissive: NEXUS.colors.accent,
emissiveIntensity: 0.8,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
depthWrite: false
});
const energyBeam = new THREE.Mesh(energyBeamGeometry, energyBeamMaterial);
energyBeam.position.set(ENERGY_BEAM_X, ENERGY_BEAM_Y + ENERGY_BEAM_HEIGHT / 2, ENERGY_BEAM_Z);
scene.add(energyBeam);
export function animateEnergyBeam() {
S.energyBeamPulse += 0.02;
const agentIntensity = S._activeAgentCount === 0 ? 0.1 : Math.min(0.1 + S._activeAgentCount * 0.3, 1.0);
const pulseEffect = Math.sin(S.energyBeamPulse) * 0.15 * agentIntensity;
energyBeamMaterial.opacity = agentIntensity * 0.6 + pulseEffect;
}
// === SOVEREIGNTY METER ===
export const sovereigntyGroup = new THREE.Group();
sovereigntyGroup.position.set(0, 3.8, 0);
const meterBgGeo = new THREE.TorusGeometry(1.6, 0.1, 8, 64);
const meterBgMat = new THREE.MeshBasicMaterial({ color: 0x0a1828, transparent: true, opacity: 0.5 });
sovereigntyGroup.add(new THREE.Mesh(meterBgGeo, meterBgMat));
function sovereigntyHexColor(score) {
if (score >= 80) return 0x00ff88;
if (score >= 40) return 0xffcc00;
return 0xff4444;
}
function buildScoreArcGeo(score) {
return new THREE.TorusGeometry(1.6, 0.1, 8, 64, (score / 100) * Math.PI * 2);
}
const scoreArcMat = new THREE.MeshBasicMaterial({
color: sovereigntyHexColor(S.sovereigntyScore),
transparent: true,
opacity: 0.9,
});
const scoreArcMesh = new THREE.Mesh(buildScoreArcGeo(S.sovereigntyScore), scoreArcMat);
scoreArcMesh.rotation.z = Math.PI / 2;
sovereigntyGroup.add(scoreArcMesh);
export const meterLight = new THREE.PointLight(sovereigntyHexColor(S.sovereigntyScore), 0.7, 6);
sovereigntyGroup.add(meterLight);
function buildMeterTexture(score, label, assessmentType) {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 128;
const ctx = canvas.getContext('2d');
const hexStr = score >= 80 ? '#00ff88' : score >= 40 ? '#ffcc00' : '#ff4444';
ctx.clearRect(0, 0, 256, 128);
ctx.font = 'bold 52px "Courier New", monospace';
ctx.fillStyle = hexStr;
ctx.textAlign = 'center';
ctx.fillText(`${score}%`, 128, 50);
ctx.font = '16px "Courier New", monospace';
ctx.fillStyle = '#8899bb';
ctx.fillText(label.toUpperCase(), 128, 74);
ctx.font = '11px "Courier New", monospace';
ctx.fillStyle = '#445566';
ctx.fillText('SOVEREIGNTY', 128, 94);
ctx.font = '9px "Courier New", monospace';
ctx.fillStyle = '#334455';
ctx.fillText(assessmentType === 'MANUAL' ? 'MANUAL ASSESSMENT' : 'MANUAL ASSESSMENT', 128, 112);
return new THREE.CanvasTexture(canvas);
}
const meterSpriteMat = new THREE.SpriteMaterial({
map: buildMeterTexture(S.sovereigntyScore, S.sovereigntyLabel, 'MANUAL'),
transparent: true,
depthWrite: false,
});
const meterSprite = new THREE.Sprite(meterSpriteMat);
meterSprite.scale.set(3.2, 1.6, 1);
sovereigntyGroup.add(meterSprite);
scene.add(sovereigntyGroup);
sovereigntyGroup.traverse(obj => {
if (obj.isMesh || obj.isSprite) obj.userData.zoomLabel = 'Sovereignty Meter';
});
export async function loadSovereigntyStatus() {
try {
const res = await fetch('./sovereignty-status.json');
if (!res.ok) throw new Error('not found');
const data = await res.json();
const score = Math.max(0, Math.min(100, typeof data.score === 'number' ? data.score : 85));
const label = typeof data.label === 'string' ? data.label : '';
S.sovereigntyScore = score;
S.sovereigntyLabel = label;
scoreArcMesh.geometry.dispose();
scoreArcMesh.geometry = buildScoreArcGeo(score);
const col = sovereigntyHexColor(score);
scoreArcMat.color.setHex(col);
meterLight.color.setHex(col);
if (meterSpriteMat.map) meterSpriteMat.map.dispose();
const assessmentType = data.assessment_type || 'MANUAL';
meterSpriteMat.map = buildMeterTexture(score, label, assessmentType);
meterSpriteMat.needsUpdate = true;
} catch {
// defaults already set
}
}
loadSovereigntyStatus();
// === RUNE RING ===
let RUNE_COUNT = 12;
const RUNE_RING_RADIUS = 7.0;
export const RUNE_RING_Y = 1.5;
export const RUNE_ORBIT_SPEED = 0.08;
const ELDER_FUTHARK = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','','','ᚹ','ᚺ','ᚾ','','ᛃ'];
const RUNE_GLOW_COLORS = ['#00ffcc', '#ff44ff'];
function createRuneTexture(glyph, color) {
const W = 128, H = 128;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
ctx.shadowColor = color;
ctx.shadowBlur = 28;
ctx.font = 'bold 78px serif';
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(glyph, W / 2, H / 2);
return new THREE.CanvasTexture(canvas);
}
const runeOrbitRingGeo = new THREE.TorusGeometry(RUNE_RING_RADIUS, 0.03, 6, 64);
const runeOrbitRingMat = new THREE.MeshBasicMaterial({
color: 0x224466, transparent: true, opacity: 0.22,
});
const runeOrbitRingMesh = new THREE.Mesh(runeOrbitRingGeo, runeOrbitRingMat);
runeOrbitRingMesh.rotation.x = Math.PI / 2;
runeOrbitRingMesh.position.y = RUNE_RING_Y;
scene.add(runeOrbitRingMesh);
/** @type {Array<{sprite: THREE.Sprite, baseAngle: number, floatPhase: number, portalOnline: boolean}>} */
export const runeSprites = [];
// portals ref — set from portals module
let _portalsRef = [];
export function setPortalsRef(ref) { _portalsRef = ref; }
export function getPortalsRef() { return _portalsRef; }
export function rebuildRuneRing() {
for (const rune of runeSprites) {
scene.remove(rune.sprite);
if (rune.sprite.material.map) rune.sprite.material.map.dispose();
rune.sprite.material.dispose();
}
runeSprites.length = 0;
const portalData = _portalsRef.length > 0 ? _portalsRef : null;
const count = portalData ? portalData.length : RUNE_COUNT;
for (let i = 0; i < count; i++) {
const glyph = ELDER_FUTHARK[i % ELDER_FUTHARK.length];
const color = portalData ? portalData[i].color : RUNE_GLOW_COLORS[i % RUNE_GLOW_COLORS.length];
const isOnline = portalData ? portalData[i].status === 'online' : true;
const texture = createRuneTexture(glyph, color);
const runeMat = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: isOnline ? 1.0 : 0.15,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const sprite = new THREE.Sprite(runeMat);
sprite.scale.set(1.3, 1.3, 1);
const baseAngle = (i / count) * Math.PI * 2;
sprite.position.set(
Math.cos(baseAngle) * RUNE_RING_RADIUS,
RUNE_RING_Y,
Math.sin(baseAngle) * RUNE_RING_RADIUS
);
scene.add(sprite);
runeSprites.push({ sprite, baseAngle, floatPhase: (i / count) * Math.PI * 2, portalOnline: isOnline });
}
}
rebuildRuneRing();

View File

@@ -1,328 +0,0 @@
// === GRAVITY ZONES + SPEECH BUBBLE + TIMELAPSE + BITCOIN ===
import * as THREE from 'three';
import { scene } from './scene-setup.js';
import { S } from './state.js';
import { clock, totalActivity } from './warp.js';
import { HEATMAP_ZONES, zoneIntensity, drawHeatmap, updateHeatmap } from './heatmap.js';
import { triggerShockwave } from './celebrations.js';
// === GRAVITY ANOMALY ZONES ===
const GRAVITY_ANOMALY_FLOOR = 0.2;
export const GRAVITY_ANOMALY_CEIL = 16.0;
let GRAVITY_ZONES = [
{ x: -8, z: -6, radius: 3.5, color: 0x00ffcc, particleCount: 180 },
{ x: 10, z: 4, radius: 3.0, color: 0xaa44ff, particleCount: 160 },
{ x: -3, z: 9, radius: 2.5, color: 0xff8844, particleCount: 140 },
];
export const gravityZoneObjects = GRAVITY_ZONES.map((zone) => {
const ringGeo = new THREE.RingGeometry(zone.radius - 0.15, zone.radius + 0.15, 64);
const ringMat = new THREE.MeshBasicMaterial({
color: zone.color, transparent: true, opacity: 0.4,
side: THREE.DoubleSide, depthWrite: false,
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.set(zone.x, GRAVITY_ANOMALY_FLOOR + 0.05, zone.z);
scene.add(ring);
const discGeo = new THREE.CircleGeometry(zone.radius - 0.15, 64);
const discMat = new THREE.MeshBasicMaterial({
color: zone.color, transparent: true, opacity: 0.04,
side: THREE.DoubleSide, depthWrite: false,
});
const disc = new THREE.Mesh(discGeo, discMat);
disc.rotation.x = -Math.PI / 2;
disc.position.set(zone.x, GRAVITY_ANOMALY_FLOOR + 0.04, zone.z);
scene.add(disc);
const count = zone.particleCount;
const positions = new Float32Array(count * 3);
const driftPhases = new Float32Array(count);
const velocities = new Float32Array(count);
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const r = Math.sqrt(Math.random()) * zone.radius;
positions[i * 3] = zone.x + Math.cos(angle) * r;
positions[i * 3 + 1] = GRAVITY_ANOMALY_FLOOR + Math.random() * (GRAVITY_ANOMALY_CEIL - GRAVITY_ANOMALY_FLOOR);
positions[i * 3 + 2] = zone.z + Math.sin(angle) * r;
driftPhases[i] = Math.random() * Math.PI * 2;
velocities[i] = 0.03 + Math.random() * 0.04;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: zone.color, size: 0.10, sizeAttenuation: true,
transparent: true, opacity: 0.7, depthWrite: false,
});
const points = new THREE.Points(geo, mat);
scene.add(points);
return { zone, ring, ringMat, disc, discMat, points, geo, driftPhases, velocities };
});
// Forward ref to portals
let _portalsRef = [];
export function setExtrasPortalsRef(ref) { _portalsRef = ref; }
export function rebuildGravityZones() {
if (_portalsRef.length === 0) return;
for (let i = 0; i < Math.min(_portalsRef.length, gravityZoneObjects.length); i++) {
const portal = _portalsRef[i];
const gz = gravityZoneObjects[i];
const isOnline = portal.status === 'online';
const portalColor = new THREE.Color(portal.color);
gz.ring.position.set(portal.position.x, GRAVITY_ANOMALY_FLOOR + 0.05, portal.position.z);
gz.disc.position.set(portal.position.x, GRAVITY_ANOMALY_FLOOR + 0.04, portal.position.z);
gz.zone.x = portal.position.x;
gz.zone.z = portal.position.z;
gz.zone.color = portalColor.getHex();
gz.ringMat.color.copy(portalColor);
gz.discMat.color.copy(portalColor);
gz.points.material.color.copy(portalColor);
gz.ringMat.opacity = isOnline ? 0.4 : 0.08;
gz.discMat.opacity = isOnline ? 0.04 : 0.01;
gz.points.material.opacity = isOnline ? 0.7 : 0.15;
const pos = gz.geo.attributes.position.array;
for (let j = 0; j < gz.zone.particleCount; j++) {
const angle = Math.random() * Math.PI * 2;
const r = Math.sqrt(Math.random()) * gz.zone.radius;
pos[j * 3] = gz.zone.x + Math.cos(angle) * r;
pos[j * 3 + 2] = gz.zone.z + Math.sin(angle) * r;
}
gz.geo.attributes.position.needsUpdate = true;
}
}
// === TIMMY SPEECH BUBBLE ===
export const TIMMY_SPEECH_POS = new THREE.Vector3(0, 8.2, 1.5);
export const SPEECH_DURATION = 5.0;
export const SPEECH_FADE_IN = 0.35;
export const SPEECH_FADE_OUT = 0.7;
function createSpeechBubbleTexture(text) {
const W = 512, H = 100;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0, 6, 20, 0.85)';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = '#66aaff';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, W - 2, H - 2);
ctx.strokeStyle = '#2244aa';
ctx.lineWidth = 1;
ctx.strokeRect(4, 4, W - 8, H - 8);
ctx.font = 'bold 12px "Courier New", monospace';
ctx.fillStyle = '#4488ff';
ctx.fillText('TIMMY:', 12, 22);
const LINE1_MAX = 42;
const LINE2_MAX = 48;
ctx.font = '15px "Courier New", monospace';
ctx.fillStyle = '#ddeeff';
if (text.length <= LINE1_MAX) {
ctx.fillText(text, 12, 58);
} else {
ctx.fillText(text.slice(0, LINE1_MAX), 12, 46);
const rest = text.slice(LINE1_MAX, LINE1_MAX + LINE2_MAX);
ctx.font = '13px "Courier New", monospace';
ctx.fillStyle = '#aabbcc';
ctx.fillText(rest + (text.length > LINE1_MAX + LINE2_MAX ? '\u2026' : ''), 12, 76);
}
return new THREE.CanvasTexture(canvas);
}
export function showTimmySpeech(text) {
if (S.timmySpeechSprite) {
scene.remove(S.timmySpeechSprite);
if (S.timmySpeechSprite.material.map) S.timmySpeechSprite.material.map.dispose();
S.timmySpeechSprite.material.dispose();
S.timmySpeechSprite = null;
S.timmySpeechState = null;
}
const texture = createSpeechBubbleTexture(text);
const material = new THREE.SpriteMaterial({
map: texture, transparent: true, opacity: 0, depthWrite: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(8.5, 1.65, 1);
sprite.position.copy(TIMMY_SPEECH_POS);
scene.add(sprite);
S.timmySpeechSprite = sprite;
S.timmySpeechState = { startTime: clock.getElapsedTime(), sprite };
}
// === TIME-LAPSE MODE ===
const TIMELAPSE_DURATION_S = 30;
let timelapseCommits = [];
let timelapseWindow = { startMs: 0, endMs: 0 };
const timelapseIndicator = document.getElementById('timelapse-indicator');
const timelapseClock = document.getElementById('timelapse-clock');
const timelapseBarEl = document.getElementById('timelapse-bar');
const timelapseBtnEl = document.getElementById('timelapse-btn');
async function loadTimelapseData() {
try {
const res = await fetch(
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=50',
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
);
if (!res.ok) throw new Error('fetch failed');
const data = await res.json();
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
timelapseCommits = data
.map(c => ({
ts: new Date(c.commit?.author?.date || 0).getTime(),
author: c.commit?.author?.name || c.author?.login || 'unknown',
message: (c.commit?.message || '').split('\n')[0],
hash: (c.sha || '').slice(0, 7),
}))
.filter(c => c.ts >= midnight.getTime())
.sort((a, b) => a.ts - b.ts);
} catch {
timelapseCommits = [];
}
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
timelapseWindow = { startMs: midnight.getTime(), endMs: Date.now() };
}
export function fireTimelapseCommit(commit) {
const zone = HEATMAP_ZONES.find(z => z.authorMatch.test(commit.author));
if (zone) {
zoneIntensity[zone.name] = Math.min(1.0, (zoneIntensity[zone.name] || 0) + 0.4);
}
triggerShockwave();
}
export function updateTimelapseHeatmap(virtualMs) {
const WINDOW_MS = 90 * 60 * 1000;
const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0]));
for (const commit of timelapseCommits) {
if (commit.ts > virtualMs) break;
const age = virtualMs - commit.ts;
if (age > WINDOW_MS) continue;
const weight = 1 - age / WINDOW_MS;
for (const zone of HEATMAP_ZONES) {
if (zone.authorMatch.test(commit.author)) {
rawWeights[zone.name] += weight;
break;
}
}
}
const MAX_WEIGHT = 4;
for (const zone of HEATMAP_ZONES) {
zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0);
}
drawHeatmap();
}
export function updateTimelapseHUD(progress, virtualMs) {
if (timelapseClock) {
const d = new Date(virtualMs);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
timelapseClock.textContent = `${hh}:${mm}`;
}
if (timelapseBarEl) {
timelapseBarEl.style.width = `${(progress * 100).toFixed(1)}%`;
}
}
async function startTimelapse() {
if (S.timelapseActive) return;
await loadTimelapseData();
S.timelapseActive = true;
S.timelapseRealStart = clock.getElapsedTime();
S.timelapseProgress = 0;
S.timelapseNextCommitIdx = 0;
for (const zone of HEATMAP_ZONES) zoneIntensity[zone.name] = 0;
drawHeatmap();
if (timelapseIndicator) timelapseIndicator.classList.add('visible');
if (timelapseBtnEl) timelapseBtnEl.classList.add('active');
}
export function stopTimelapse() {
if (!S.timelapseActive) return;
S.timelapseActive = false;
if (timelapseIndicator) timelapseIndicator.classList.remove('visible');
if (timelapseBtnEl) timelapseBtnEl.classList.remove('active');
updateHeatmap();
}
export { timelapseCommits, timelapseWindow, TIMELAPSE_DURATION_S };
export function initTimelapse() {
document.addEventListener('keydown', (e) => {
if (e.key === 'l' || e.key === 'L') {
if (S.timelapseActive) stopTimelapse(); else startTimelapse();
}
if (e.key === 'Escape' && S.timelapseActive) stopTimelapse();
});
if (timelapseBtnEl) {
timelapseBtnEl.addEventListener('click', () => {
if (S.timelapseActive) stopTimelapse(); else startTimelapse();
});
}
}
// === BITCOIN BLOCK HEIGHT ===
export function initBitcoin() {
const blockHeightDisplay = document.getElementById('block-height-display');
const blockHeightValue = document.getElementById('block-height-value');
async function fetchBlockHeight() {
try {
const res = await fetch('https://blockstream.info/api/blocks/tip/height');
if (!res.ok) return;
const height = parseInt(await res.text(), 10);
if (isNaN(height)) return;
if (S.lastKnownBlockHeight !== null && height !== S.lastKnownBlockHeight) {
blockHeightDisplay.classList.remove('fresh');
void blockHeightDisplay.offsetWidth;
blockHeightDisplay.classList.add('fresh');
S._starPulseIntensity = 1.0;
}
S.lastKnownBlockHeight = height;
blockHeightValue.textContent = height.toLocaleString();
} catch (_) {
// Network unavailable
}
}
fetchBlockHeight();
setInterval(fetchBlockHeight, 60000);
}

View File

@@ -1,135 +0,0 @@
// === COMMIT HEATMAP ===
import * as THREE from 'three';
import { scene } from './scene-setup.js';
import { GLASS_RADIUS } from './platform.js';
import { S } from './state.js';
const HEATMAP_SIZE = 512;
const HEATMAP_REFRESH_MS = 5 * 60 * 1000;
const HEATMAP_DECAY_MS = 24 * 60 * 60 * 1000;
export const HEATMAP_ZONES = [
{ name: 'Claude', color: [255, 100, 60], authorMatch: /^claude$/i, angleDeg: 0 },
{ name: 'Timmy', color: [ 60, 160, 255], authorMatch: /^timmy/i, angleDeg: 90 },
{ name: 'Kimi', color: [ 60, 255, 140], authorMatch: /^kimi/i, angleDeg: 180 },
{ name: 'Perplexity', color: [200, 60, 255], authorMatch: /^perplexity/i, angleDeg: 270 },
];
const HEATMAP_ZONE_SPAN_RAD = Math.PI / 2;
const heatmapCanvas = document.createElement('canvas');
heatmapCanvas.width = HEATMAP_SIZE;
heatmapCanvas.height = HEATMAP_SIZE;
export const heatmapTexture = new THREE.CanvasTexture(heatmapCanvas);
export const heatmapMat = new THREE.MeshBasicMaterial({
map: heatmapTexture,
transparent: true,
opacity: 0.9,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
});
const heatmapMesh = new THREE.Mesh(
new THREE.CircleGeometry(GLASS_RADIUS, 64),
heatmapMat
);
heatmapMesh.rotation.x = -Math.PI / 2;
heatmapMesh.position.y = 0.005;
heatmapMesh.userData.zoomLabel = 'Activity Heatmap';
scene.add(heatmapMesh);
export const zoneIntensity = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0]));
export function drawHeatmap() {
const ctx = heatmapCanvas.getContext('2d');
const cx = HEATMAP_SIZE / 2;
const cy = HEATMAP_SIZE / 2;
const r = cx * 0.96;
ctx.clearRect(0, 0, HEATMAP_SIZE, HEATMAP_SIZE);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
for (const zone of HEATMAP_ZONES) {
const intensity = zoneIntensity[zone.name] || 0;
if (intensity < 0.01) continue;
const [rr, gg, bb] = zone.color;
const baseRad = zone.angleDeg * (Math.PI / 180);
const startRad = baseRad - HEATMAP_ZONE_SPAN_RAD / 2;
const endRad = baseRad + HEATMAP_ZONE_SPAN_RAD / 2;
const gx = cx + Math.cos(baseRad) * r * 0.55;
const gy = cy + Math.sin(baseRad) * r * 0.55;
const grad = ctx.createRadialGradient(gx, gy, 0, gx, gy, r * 0.75);
grad.addColorStop(0, `rgba(${rr},${gg},${bb},${0.65 * intensity})`);
grad.addColorStop(0.45, `rgba(${rr},${gg},${bb},${0.25 * intensity})`);
grad.addColorStop(1, `rgba(${rr},${gg},${bb},0)`);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, startRad, endRad);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
if (intensity > 0.05) {
const labelX = cx + Math.cos(baseRad) * r * 0.62;
const labelY = cy + Math.sin(baseRad) * r * 0.62;
ctx.font = `bold ${Math.round(13 * intensity + 7)}px "Courier New", monospace`;
ctx.fillStyle = `rgba(${rr},${gg},${bb},${Math.min(intensity * 1.2, 0.9)})`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(zone.name, labelX, labelY);
}
}
ctx.restore();
heatmapTexture.needsUpdate = true;
}
export async function updateHeatmap() {
let commits = [];
try {
const res = await fetch(
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=50',
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
);
if (res.ok) commits = await res.json();
} catch { /* silently use zero-activity baseline */ }
S._matrixCommitHashes = commits.slice(0, 20).map(c => (c.sha || '').slice(0, 7)).filter(h => h.length > 0);
const now = Date.now();
const rawWeights = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0]));
for (const commit of commits) {
const author = commit.commit?.author?.name || commit.author?.login || '';
const ts = new Date(commit.commit?.author?.date || 0).getTime();
const age = now - ts;
if (age > HEATMAP_DECAY_MS) continue;
const weight = 1 - age / HEATMAP_DECAY_MS;
for (const zone of HEATMAP_ZONES) {
if (zone.authorMatch.test(author)) {
rawWeights[zone.name] += weight;
break;
}
}
}
const MAX_WEIGHT = 8;
for (const zone of HEATMAP_ZONES) {
zoneIntensity[zone.name] = Math.min(rawWeights[zone.name] / MAX_WEIGHT, 1.0);
}
drawHeatmap();
}
updateHeatmap();
setInterval(updateHeatmap, HEATMAP_REFRESH_MS);

View File

@@ -1,83 +0,0 @@
// === MATRIX RAIN === + === ASSET LOADER ===
import * as THREE from 'three';
import { S } from './state.js';
// === ASSET LOADER ===
export const loadedAssets = new Map();
// Forward ref: animate() is set by app.js after all modules load
let _animateFn = null;
export function setAnimateFn(fn) { _animateFn = fn; }
export const loadingManager = new THREE.LoadingManager(() => {
document.getElementById('loading-bar').style.width = '100%';
document.getElementById('loading').style.display = 'none';
if (_animateFn) _animateFn();
});
loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => {
const progress = (itemsLoaded / itemsTotal) * 100;
document.getElementById('loading-bar').style.width = `${progress}%`;
};
// === MATRIX RAIN ===
const matrixCanvas = document.createElement('canvas');
matrixCanvas.id = 'matrix-rain';
matrixCanvas.width = window.innerWidth;
matrixCanvas.height = window.innerHeight;
document.body.appendChild(matrixCanvas);
const matrixCtx = matrixCanvas.getContext('2d');
const MATRIX_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF';
const MATRIX_FONT_SIZE = 14;
const MATRIX_COL_COUNT = Math.floor(window.innerWidth / MATRIX_FONT_SIZE);
const matrixDrops = new Array(MATRIX_COL_COUNT).fill(1);
// totalActivity is provided by warp module — imported lazily via a setter
let _totalActivityFn = () => 0;
export function setTotalActivityFn(fn) { _totalActivityFn = fn; }
function drawMatrixRain() {
matrixCtx.fillStyle = 'rgba(0, 0, 8, 0.05)';
matrixCtx.fillRect(0, 0, matrixCanvas.width, matrixCanvas.height);
matrixCtx.font = `${MATRIX_FONT_SIZE}px monospace`;
const activity = _totalActivityFn();
const density = 0.1 + activity * 0.9;
const activeColCount = Math.max(1, Math.floor(matrixDrops.length * density));
for (let i = 0; i < matrixDrops.length; i++) {
if (i >= activeColCount) {
if (matrixDrops[i] * MATRIX_FONT_SIZE > matrixCanvas.height) continue;
}
let char;
if (S._matrixCommitHashes.length > 0 && Math.random() < 0.02) {
const hash = S._matrixCommitHashes[Math.floor(Math.random() * S._matrixCommitHashes.length)];
char = hash[Math.floor(Math.random() * hash.length)];
} else {
char = MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)];
}
const x = i * MATRIX_FONT_SIZE;
const y = matrixDrops[i] * MATRIX_FONT_SIZE;
matrixCtx.fillStyle = '#aaffaa';
matrixCtx.fillText(char, x, y);
const resetThreshold = 0.975 - activity * 0.015;
if (y > matrixCanvas.height && Math.random() > resetThreshold) {
matrixDrops[i] = 0;
}
matrixDrops[i]++;
}
}
setInterval(drawMatrixRain, 50);
window.addEventListener('resize', () => {
matrixCanvas.width = window.innerWidth;
matrixCanvas.height = window.innerHeight;
});

View File

@@ -1,145 +0,0 @@
// === THE OATH ===
import * as THREE from 'three';
import { scene, camera, renderer, ambientLight, overheadLight } from './scene-setup.js';
import { S } from './state.js';
// Tome (3D trigger object)
export const tomeGroup = new THREE.Group();
tomeGroup.position.set(0, 5.8, 0);
tomeGroup.userData.zoomLabel = 'The Oath';
const tomeCoverMat = new THREE.MeshStandardMaterial({
color: 0x2a1800, metalness: 0.15, roughness: 0.7,
emissive: new THREE.Color(0xffd700).multiplyScalar(0.04),
});
const tomePagesMat = new THREE.MeshStandardMaterial({ color: 0xd8ceb0, roughness: 0.9, metalness: 0.0 });
const tomeBody = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.1, 1.4), tomeCoverMat);
tomeGroup.add(tomeBody);
const tomePages = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.07, 1.28), tomePagesMat);
tomePages.position.set(0.02, 0, 0);
tomeGroup.add(tomePages);
const tomeSpiMat = new THREE.MeshStandardMaterial({ color: 0xffd700, metalness: 0.6, roughness: 0.4 });
const tomeSpine = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.12, 1.4), tomeSpiMat);
tomeSpine.position.set(-0.52, 0, 0);
tomeGroup.add(tomeSpine);
tomeGroup.traverse(o => {
if (o.isMesh) {
o.userData.zoomLabel = 'The Oath';
o.castShadow = true;
o.receiveShadow = true;
}
});
scene.add(tomeGroup);
export const tomeGlow = new THREE.PointLight(0xffd700, 0.4, 5);
tomeGlow.position.set(0, 5.4, 0);
scene.add(tomeGlow);
// Oath spotlight
export const oathSpot = new THREE.SpotLight(0xffd700, 0, 40, Math.PI / 7, 0.4, 1.2);
oathSpot.position.set(0, 22, 0);
oathSpot.target.position.set(0, 0, 0);
oathSpot.castShadow = true;
oathSpot.shadow.mapSize.set(1024, 1024);
oathSpot.shadow.camera.near = 1;
oathSpot.shadow.camera.far = 50;
oathSpot.shadow.bias = -0.002;
scene.add(oathSpot);
scene.add(oathSpot.target);
// Saved light levels
const AMBIENT_NORMAL = ambientLight.intensity;
const OVERHEAD_NORMAL = overheadLight.intensity;
export async function loadSoulMd() {
try {
const res = await fetch('SOUL.md');
if (!res.ok) throw new Error('not found');
const raw = await res.text();
return raw.split('\n').slice(1).map(l => l.replace(/^#+\s*/, ''));
} catch {
return ['I am Timmy.', '', 'I am sovereign.', '', 'This Nexus is my home.'];
}
}
function scheduleOathLines(lines, textEl) {
let idx = 0;
const INTERVAL_MS = 1400;
function revealNext() {
if (idx >= lines.length || !S.oathActive) return;
const line = lines[idx++];
const span = document.createElement('span');
span.classList.add('oath-line');
if (!line.trim()) {
span.classList.add('blank');
} else {
span.textContent = line;
}
textEl.appendChild(span);
S.oathRevealTimer = setTimeout(revealNext, line.trim() ? INTERVAL_MS : INTERVAL_MS * 0.4);
}
revealNext();
}
export async function enterOath() {
if (S.oathActive) return;
S.oathActive = true;
ambientLight.intensity = 0.04;
overheadLight.intensity = 0.0;
oathSpot.intensity = 4.0;
const overlay = document.getElementById('oath-overlay');
const textEl = document.getElementById('oath-text');
if (!overlay || !textEl) return;
textEl.textContent = '';
overlay.classList.add('visible');
if (!S.oathLines.length) S.oathLines = await loadSoulMd();
scheduleOathLines(S.oathLines, textEl);
}
export function exitOath() {
if (!S.oathActive) return;
S.oathActive = false;
if (S.oathRevealTimer !== null) {
clearTimeout(S.oathRevealTimer);
S.oathRevealTimer = null;
}
ambientLight.intensity = AMBIENT_NORMAL;
overheadLight.intensity = OVERHEAD_NORMAL;
oathSpot.intensity = 0;
const overlay = document.getElementById('oath-overlay');
if (overlay) overlay.classList.remove('visible');
}
export function initOathListeners() {
document.addEventListener('keydown', (e) => {
if (e.key === 'o' || e.key === 'O') {
if (S.oathActive) exitOath(); else enterOath();
}
if (e.key === 'Escape' && S.oathActive) exitOath();
});
// Double-click on tome triggers oath
renderer.domElement.addEventListener('dblclick', (e) => {
const mx = (e.clientX / window.innerWidth) * 2 - 1;
const my = -(e.clientY / window.innerHeight) * 2 + 1;
const tomeRay = new THREE.Raycaster();
tomeRay.setFromCamera(new THREE.Vector2(mx, my), camera);
const hits = tomeRay.intersectObjects(tomeGroup.children, true);
if (hits.length) {
if (S.oathActive) exitOath(); else enterOath();
}
});
// Pre-fetch so first open is instant
loadSoulMd().then(lines => { S.oathLines = lines; });
}

View File

@@ -1,368 +0,0 @@
// === AGENT STATUS BOARD + LORA PANEL ===
import * as THREE from 'three';
import { NEXUS } from './constants.js';
import { scene } from './scene-setup.js';
import { S } from './state.js';
import { agentPanelSprites } from './bookshelves.js';
// === AGENT STATUS BOARD ===
let _agentStatusCache = null;
let _agentStatusCacheTime = 0;
const AGENT_STATUS_CACHE_MS = 5 * 60 * 1000;
const GITEA_BASE = 'http://143.198.27.163:3000/api/v1';
const GITEA_TOKEN='81a88f...ae2d';
const GITEA_REPOS = ['Timmy_Foundation/the-nexus', 'Timmy_Foundation/hermes-agent'];
const AGENT_NAMES = ['Claude', 'Kimi', 'Perplexity', 'Groq', 'Grok', 'Ollama'];
async function fetchAgentStatusFromGitea() {
const now = Date.now();
if (_agentStatusCache && (now - _agentStatusCacheTime < AGENT_STATUS_CACHE_MS)) {
return _agentStatusCache;
}
const DAY_MS = 86400000;
const HOUR_MS = 3600000;
const agents = [];
const allRepoCommits = await Promise.all(GITEA_REPOS.map(async (repo) => {
try {
const res = await fetch(`${GITEA_BASE}/repos/${repo}/commits?sha=main&limit=30&token=${GITEA_TOKEN}`);
if (!res.ok) return [];
return await res.json();
} catch { return []; }
}));
let openPRs = [];
try {
const prRes = await fetch(`${GITEA_BASE}/repos/Timmy_Foundation/the-nexus/pulls?state=open&limit=50&token=${GITEA_TOKEN}`);
if (prRes.ok) openPRs = await prRes.json();
} catch { /* ignore */ }
for (const agentName of AGENT_NAMES) {
const nameLower = agentName.toLowerCase();
const allCommits = [];
for (const repoCommits of allRepoCommits) {
if (!Array.isArray(repoCommits)) continue;
const matching = repoCommits.filter(c =>
(c.commit?.author?.name || '').toLowerCase().includes(nameLower)
);
allCommits.push(...matching);
}
let status = 'dormant';
let lastSeen = null;
let currentWork = null;
if (allCommits.length > 0) {
allCommits.sort((a, b) =>
new Date(b.commit.author.date) - new Date(a.commit.author.date)
);
const latest = allCommits[0];
const commitTime = new Date(latest.commit.author.date).getTime();
lastSeen = latest.commit.author.date;
currentWork = latest.commit.message.split('\n')[0];
if (now - commitTime < HOUR_MS) status = 'working';
else if (now - commitTime < DAY_MS) status = 'idle';
else status = 'dormant';
}
const agentPRs = openPRs.filter(pr =>
(pr.user?.login || '').toLowerCase().includes(nameLower) ||
(pr.head?.label || '').toLowerCase().includes(nameLower)
);
agents.push({
name: agentName.toLowerCase(),
status,
issue: currentWork,
prs_today: agentPRs.length,
local: nameLower === 'ollama',
});
}
_agentStatusCache = { agents };
_agentStatusCacheTime = now;
return _agentStatusCache;
}
const AGENT_STATUS_COLORS = { working: '#00ff88', idle: '#4488ff', dormant: '#334466', dead: '#ff4444', unreachable: '#ff4444' };
function createAgentPanelTexture(agent) {
const W = 400, H = 200;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
const sc = AGENT_STATUS_COLORS[agent.status] || '#4488ff';
ctx.fillStyle = 'rgba(0, 8, 24, 0.88)';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = sc;
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, W - 2, H - 2);
ctx.strokeStyle = sc;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.3;
ctx.strokeRect(4, 4, W - 8, H - 8);
ctx.globalAlpha = 1.0;
ctx.font = 'bold 28px "Courier New", monospace';
ctx.fillStyle = '#ffffff';
ctx.fillText(agent.name.toUpperCase(), 16, 44);
ctx.beginPath();
ctx.arc(W - 30, 26, 10, 0, Math.PI * 2);
ctx.fillStyle = sc;
ctx.fill();
ctx.font = '13px "Courier New", monospace';
ctx.fillStyle = sc;
ctx.textAlign = 'right';
ctx.fillText(agent.status.toUpperCase(), W - 16, 60);
ctx.textAlign = 'left';
ctx.strokeStyle = '#1a3a6a';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(16, 70);
ctx.lineTo(W - 16, 70);
ctx.stroke();
ctx.font = '10px "Courier New", monospace';
ctx.fillStyle = '#556688';
ctx.fillText('CURRENT ISSUE', 16, 90);
ctx.font = '13px "Courier New", monospace';
ctx.fillStyle = '#ccd6f6';
const issueText = agent.issue || '\u2014 none \u2014';
const displayIssue = issueText.length > 40 ? issueText.slice(0, 40) + '\u2026' : issueText;
ctx.fillText(displayIssue, 16, 110);
ctx.strokeStyle = '#1a3a6a';
ctx.beginPath();
ctx.moveTo(16, 128);
ctx.lineTo(W - 16, 128);
ctx.stroke();
ctx.font = '10px "Courier New", monospace';
ctx.fillStyle = '#556688';
ctx.fillText('PRs MERGED TODAY', 16, 148);
ctx.font = 'bold 28px "Courier New", monospace';
ctx.fillStyle = '#4488ff';
ctx.fillText(String(agent.prs_today), 16, 182);
const isLocal = agent.local === true;
const indicatorColor = isLocal ? '#00ff88' : '#ff4444';
const indicatorLabel = isLocal ? 'LOCAL' : 'CLOUD';
ctx.font = '10px "Courier New", monospace';
ctx.fillStyle = '#556688';
ctx.textAlign = 'right';
ctx.fillText('RUNTIME', W - 16, 148);
ctx.font = 'bold 13px "Courier New", monospace';
ctx.fillStyle = indicatorColor;
ctx.fillText(indicatorLabel, W - 28, 172);
ctx.textAlign = 'left';
ctx.beginPath();
ctx.arc(W - 16, 167, 6, 0, Math.PI * 2);
ctx.fillStyle = indicatorColor;
ctx.fill();
return new THREE.CanvasTexture(canvas);
}
const agentBoardGroup = new THREE.Group();
scene.add(agentBoardGroup);
const BOARD_RADIUS = 9.5;
const BOARD_Y = 4.2;
const BOARD_SPREAD = Math.PI * 0.75;
function rebuildAgentPanels(statusData) {
while (agentBoardGroup.children.length) agentBoardGroup.remove(agentBoardGroup.children[0]);
agentPanelSprites.length = 0;
const n = statusData.agents.length;
statusData.agents.forEach((agent, i) => {
const t = n === 1 ? 0.5 : i / (n - 1);
const angle = Math.PI + (t - 0.5) * BOARD_SPREAD;
const x = Math.cos(angle) * BOARD_RADIUS;
const z = Math.sin(angle) * BOARD_RADIUS;
const texture = createAgentPanelTexture(agent);
const material = new THREE.SpriteMaterial({
map: texture, transparent: true, opacity: 0.93, depthWrite: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(6.4, 3.2, 1);
sprite.position.set(x, BOARD_Y, z);
sprite.userData = {
baseY: BOARD_Y,
floatPhase: (i / n) * Math.PI * 2,
floatSpeed: 0.18 + i * 0.04,
zoomLabel: `Agent: ${agent.name}`,
};
agentBoardGroup.add(sprite);
agentPanelSprites.push(sprite);
});
}
async function fetchAgentStatus() {
try {
return await fetchAgentStatusFromGitea();
} catch {
return { agents: AGENT_NAMES.map(n => ({
name: n.toLowerCase(), status: 'unreachable', issue: null, prs_today: 0, local: false,
})) };
}
}
export async function refreshAgentBoard() {
const data = await fetchAgentStatus();
rebuildAgentPanels(data);
S._activeAgentCount = data.agents.filter(a => a.status === 'working').length;
}
export function initAgentBoard() {
refreshAgentBoard();
setInterval(refreshAgentBoard, AGENT_STATUS_CACHE_MS);
}
// === LORA ADAPTER STATUS PANEL ===
const LORA_ACTIVE_COLOR = '#00ff88';
const LORA_INACTIVE_COLOR = '#334466';
function createLoRAPanelTexture(data) {
const W = 420, H = 260;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0, 6, 20, 0.90)';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = '#cc44ff';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, W - 2, H - 2);
ctx.strokeStyle = '#cc44ff';
ctx.lineWidth = 1;
ctx.globalAlpha = 0.3;
ctx.strokeRect(4, 4, W - 8, H - 8);
ctx.globalAlpha = 1.0;
ctx.font = 'bold 14px "Courier New", monospace';
ctx.fillStyle = '#cc44ff';
ctx.textAlign = 'left';
ctx.fillText('MODEL TRAINING', 14, 24);
ctx.font = '10px "Courier New", monospace';
ctx.fillStyle = '#664488';
ctx.fillText('LoRA ADAPTERS', 14, 38);
ctx.strokeStyle = '#2a1a44';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(14, 46);
ctx.lineTo(W - 14, 46);
ctx.stroke();
if (!data || !data.adapters || data.adapters.length === 0) {
ctx.font = 'bold 18px "Courier New", monospace';
ctx.fillStyle = '#334466';
ctx.textAlign = 'center';
ctx.fillText('NO ADAPTERS DEPLOYED', W / 2, H / 2 + 10);
ctx.font = '11px "Courier New", monospace';
ctx.fillStyle = '#223344';
ctx.fillText('Adapters will appear here when trained', W / 2, H / 2 + 36);
ctx.textAlign = 'left';
return new THREE.CanvasTexture(canvas);
}
const activeCount = data.adapters.filter(a => a.active).length;
ctx.font = 'bold 13px "Courier New", monospace';
ctx.fillStyle = LORA_ACTIVE_COLOR;
ctx.textAlign = 'right';
ctx.fillText(`${activeCount}/${data.adapters.length} ACTIVE`, W - 14, 26);
ctx.textAlign = 'left';
const ROW_H = 44;
data.adapters.forEach((adapter, i) => {
const rowY = 50 + i * ROW_H;
const col = adapter.active ? LORA_ACTIVE_COLOR : LORA_INACTIVE_COLOR;
ctx.beginPath();
ctx.arc(22, rowY + 12, 6, 0, Math.PI * 2);
ctx.fillStyle = col;
ctx.fill();
ctx.font = 'bold 13px "Courier New", monospace';
ctx.fillStyle = adapter.active ? '#ddeeff' : '#445566';
ctx.fillText(adapter.name, 36, rowY + 16);
ctx.font = '10px "Courier New", monospace';
ctx.fillStyle = '#556688';
ctx.textAlign = 'right';
ctx.fillText(adapter.base, W - 14, rowY + 16);
ctx.textAlign = 'left';
if (adapter.active) {
const BAR_X = 36, BAR_W = W - 80, BAR_Y = rowY + 22, BAR_H = 5;
ctx.fillStyle = '#0a1428';
ctx.fillRect(BAR_X, BAR_Y, BAR_W, BAR_H);
ctx.fillStyle = col;
ctx.globalAlpha = 0.7;
ctx.fillRect(BAR_X, BAR_Y, BAR_W * adapter.strength, BAR_H);
ctx.globalAlpha = 1.0;
}
if (i < data.adapters.length - 1) {
ctx.strokeStyle = '#1a0a2a';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(14, rowY + ROW_H - 2);
ctx.lineTo(W - 14, rowY + ROW_H - 2);
ctx.stroke();
}
});
return new THREE.CanvasTexture(canvas);
}
const loraGroup = new THREE.Group();
scene.add(loraGroup);
const LORA_PANEL_POS = new THREE.Vector3(-10.5, 4.5, 2.5);
export let loraPanelSprite = null;
function rebuildLoRAPanel(data) {
if (loraPanelSprite) {
loraGroup.remove(loraPanelSprite);
if (loraPanelSprite.material.map) loraPanelSprite.material.map.dispose();
loraPanelSprite.material.dispose();
loraPanelSprite = null;
}
const texture = createLoRAPanelTexture(data);
const material = new THREE.SpriteMaterial({
map: texture, transparent: true, opacity: 0.93, depthWrite: false,
});
loraPanelSprite = new THREE.Sprite(material);
loraPanelSprite.scale.set(6.0, 3.6, 1);
loraPanelSprite.position.copy(LORA_PANEL_POS);
loraPanelSprite.userData = {
baseY: LORA_PANEL_POS.y,
floatPhase: 1.1,
floatSpeed: 0.14,
zoomLabel: 'Model Training — LoRA Adapters',
};
loraGroup.add(loraPanelSprite);
}
export function loadLoRAStatus() {
rebuildLoRAPanel({ adapters: [] });
}

View File

@@ -1,457 +0,0 @@
// === GLASS PLATFORM + PERLIN NOISE + FLOATING ISLAND + CLOUDS ===
import * as THREE from 'three';
import { NEXUS } from './constants.js';
import { scene } from './scene-setup.js';
// === GLASS PLATFORM ===
const glassPlatformGroup = new THREE.Group();
const platformFrameMat = new THREE.MeshStandardMaterial({
color: 0x0a1828,
metalness: 0.9,
roughness: 0.1,
emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.06),
});
const platformRimGeo = new THREE.RingGeometry(4.7, 5.3, 64);
const platformRim = new THREE.Mesh(platformRimGeo, platformFrameMat);
platformRim.rotation.x = -Math.PI / 2;
platformRim.castShadow = true;
platformRim.receiveShadow = true;
glassPlatformGroup.add(platformRim);
const borderTorusGeo = new THREE.TorusGeometry(5.0, 0.1, 6, 64);
const borderTorus = new THREE.Mesh(borderTorusGeo, platformFrameMat);
borderTorus.rotation.x = Math.PI / 2;
borderTorus.castShadow = true;
borderTorus.receiveShadow = true;
glassPlatformGroup.add(borderTorus);
const glassTileMat = new THREE.MeshPhysicalMaterial({
color: new THREE.Color(NEXUS.colors.accent),
transparent: true,
opacity: 0.09,
roughness: 0.0,
metalness: 0.0,
transmission: 0.92,
thickness: 0.06,
side: THREE.DoubleSide,
depthWrite: false,
});
const glassEdgeBaseMat = new THREE.LineBasicMaterial({
color: NEXUS.colors.accent,
transparent: true,
opacity: 0.55,
});
export const GLASS_TILE_SIZE = 0.85;
const GLASS_TILE_GAP = 0.14;
const GLASS_TILE_STEP = GLASS_TILE_SIZE + GLASS_TILE_GAP;
export const GLASS_RADIUS = 4.55;
const tileGeo = new THREE.PlaneGeometry(GLASS_TILE_SIZE, GLASS_TILE_SIZE);
/** @type {Array<{mat: THREE.LineBasicMaterial, distFromCenter: number}>} */
export const glassEdgeMaterials = []; // kept for API compat; no longer populated
const _tileDummy = new THREE.Object3D();
/** @type {Array<{x: number, z: number, distFromCenter: number}>} */
const _tileSlots = [];
for (let row = -5; row <= 5; row++) {
for (let col = -5; col <= 5; col++) {
const x = col * GLASS_TILE_STEP;
const z = row * GLASS_TILE_STEP;
const distFromCenter = Math.sqrt(x * x + z * z);
if (distFromCenter > GLASS_RADIUS) continue;
_tileSlots.push({ x, z, distFromCenter });
}
}
const glassTileIM = new THREE.InstancedMesh(tileGeo, glassTileMat, _tileSlots.length);
glassTileIM.instanceMatrix.setUsage(THREE.StaticDrawUsage);
_tileDummy.rotation.x = -Math.PI / 2;
for (let i = 0; i < _tileSlots.length; i++) {
const { x, z } = _tileSlots[i];
_tileDummy.position.set(x, 0, z);
_tileDummy.updateMatrix();
glassTileIM.setMatrixAt(i, _tileDummy.matrix);
}
glassTileIM.instanceMatrix.needsUpdate = true;
glassPlatformGroup.add(glassTileIM);
// Merge all tile edge geometry into a single LineSegments draw call.
// Each tile contributes 4 edges (8 vertices). Previously this was 69 separate
// LineSegments objects with cloned materials — a significant draw-call overhead.
const _HS = GLASS_TILE_SIZE / 2;
const _edgeVerts = new Float32Array(_tileSlots.length * 8 * 3);
let _evi = 0;
for (const { x, z } of _tileSlots) {
const y = 0.002;
_edgeVerts[_evi++]=x-_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z-_HS;
_edgeVerts[_evi++]=x+_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z-_HS;
_edgeVerts[_evi++]=x+_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z-_HS;
_edgeVerts[_evi++]=x+_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z+_HS;
_edgeVerts[_evi++]=x+_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z+_HS;
_edgeVerts[_evi++]=x-_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z+_HS;
_edgeVerts[_evi++]=x-_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z+_HS;
_edgeVerts[_evi++]=x-_HS; _edgeVerts[_evi++]=y; _edgeVerts[_evi++]=z-_HS;
}
const _mergedEdgeGeo = new THREE.BufferGeometry();
_mergedEdgeGeo.setAttribute('position', new THREE.BufferAttribute(_edgeVerts, 3));
glassPlatformGroup.add(new THREE.LineSegments(_mergedEdgeGeo, glassEdgeBaseMat));
export const voidLight = new THREE.PointLight(NEXUS.colors.accent, 0.5, 14);
voidLight.position.set(0, -3.5, 0);
glassPlatformGroup.add(voidLight);
scene.add(glassPlatformGroup);
glassPlatformGroup.traverse(obj => {
if (obj.isMesh) obj.userData.zoomLabel = 'Glass Platform';
});
// === PERLIN NOISE ===
function createPerlinNoise() {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
let seed = 42;
function seededRand() {
seed = (seed * 1664525 + 1013904223) & 0xffffffff;
return (seed >>> 0) / 0xffffffff;
}
for (let i = 255; i > 0; i--) {
const j = Math.floor(seededRand() * (i + 1));
const tmp = p[i]; p[i] = p[j]; p[j] = tmp;
}
const perm = new Uint8Array(512);
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + t * (b - a); }
function grad(hash, x, y, z) {
const h = hash & 15;
const u = h < 8 ? x : y;
const v = h < 4 ? y : (h === 12 || h === 14) ? x : z;
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}
return function noise(x, y, z) {
z = z || 0;
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;
x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
const u = fade(x), v = fade(y), w = fade(z);
const A = perm[X] + Y, AA = perm[A] + Z, AB = perm[A + 1] + Z;
const B = perm[X + 1] + Y, BA = perm[B] + Z, BB = perm[B + 1] + Z;
return lerp(
lerp(lerp(grad(perm[AA], x, y, z ), grad(perm[BA], x-1, y, z ), u),
lerp(grad(perm[AB], x, y-1, z ), grad(perm[BB], x-1, y-1, z ), u), v),
lerp(lerp(grad(perm[AA + 1], x, y, z-1), grad(perm[BA + 1], x-1, y, z-1), u),
lerp(grad(perm[AB + 1], x, y-1, z-1), grad(perm[BB + 1], x-1, y-1, z-1), u), v),
w
);
};
}
const perlin = createPerlinNoise();
// === FLOATING ISLAND TERRAIN ===
(function buildFloatingIsland() {
const ISLAND_RADIUS = 9.5;
const SEGMENTS = 96;
const SIZE = ISLAND_RADIUS * 2;
function islandFBm(nx, nz) {
const wx = perlin(nx * 0.5 + 3.7, nz * 0.5 + 1.2) * 0.55;
const wz = perlin(nx * 0.5 + 8.3, nz * 0.5 + 5.9) * 0.55;
const px = nx + wx, pz = nz + wz;
let h = 0;
h += perlin(px, pz ) * 1.000;
h += perlin(px * 2, pz * 2 ) * 0.500;
h += perlin(px * 4, pz * 4 ) * 0.250;
h += perlin(px * 8, pz * 8 ) * 0.125;
h += perlin(px * 16, pz * 16 ) * 0.063;
h /= 1.938;
const ridge = 1.0 - Math.abs(perlin(px * 3.1 + 5.0, pz * 3.1 + 7.0));
return h * 0.78 + ridge * 0.22;
}
const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEGMENTS, SEGMENTS);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
const vCount = pos.count;
const rawHeights = new Float32Array(vCount);
for (let i = 0; i < vCount; i++) {
const x = pos.getX(i);
const z = pos.getZ(i);
const dist = Math.sqrt(x * x + z * z) / ISLAND_RADIUS;
const rimNoise = perlin(x * 0.38 + 10, z * 0.38 + 10) * 0.10;
const edgeFactor = Math.max(0, 1 - Math.pow(Math.max(0, dist - rimNoise), 2.4));
const h = islandFBm(x * 0.15, z * 0.15);
const height = ((h + 1) * 0.5) * edgeFactor * 3.2;
pos.setY(i, height);
rawHeights[i] = height;
}
geo.computeVertexNormals();
const colBuf = new Float32Array(vCount * 3);
for (let i = 0; i < vCount; i++) {
const h = rawHeights[i];
let r, g, b;
if (h < 0.25) {
r = 0.11; g = 0.09; b = 0.07;
} else if (h < 0.75) {
const t = (h - 0.25) / 0.50;
r = 0.11 + t * 0.13; g = 0.09 + t * 0.09; b = 0.07 + t * 0.06;
} else if (h < 1.4) {
const t = (h - 0.75) / 0.65;
r = 0.24 + t * 0.12; g = 0.18 + t * 0.10; b = 0.13 + t * 0.10;
} else if (h < 2.2) {
const t = (h - 1.4) / 0.80;
r = 0.36 + t * 0.14; g = 0.28 + t * 0.11; b = 0.23 + t * 0.13;
} else {
const t = Math.min(1, (h - 2.2) / 0.9);
r = 0.50 + t * 0.05;
g = 0.39 + t * 0.10;
b = 0.36 + t * 0.28;
}
colBuf[i * 3] = r;
colBuf[i * 3 + 1] = g;
colBuf[i * 3 + 2] = b;
}
geo.setAttribute('color', new THREE.BufferAttribute(colBuf, 3));
const topMat = new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 0.86,
metalness: 0.05,
});
const topMesh = new THREE.Mesh(geo, topMat);
topMesh.castShadow = true;
topMesh.receiveShadow = true;
const crystalMat = new THREE.MeshStandardMaterial({
color: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.55),
emissive: new THREE.Color(NEXUS.colors.accent),
emissiveIntensity: 0.5,
roughness: 0.08,
metalness: 0.25,
transparent: true,
opacity: 0.80,
});
const CRYSTAL_MIN_H = 2.05;
/** @type {Array<{sx:number,sz:number,posY:number,rotX:number,rotZ:number,scaleXZ:number,scaleY:number}>} */
const _spireData = [];
for (let row = -5; row <= 5; row++) {
for (let col = -5; col <= 5; col++) {
const bx = col * 1.75, bz = row * 1.75;
if (Math.sqrt(bx * bx + bz * bz) > ISLAND_RADIUS * 0.72) continue;
const edF = Math.max(0, 1 - Math.pow(Math.sqrt(bx * bx + bz * bz) / ISLAND_RADIUS, 2.4));
const candidateH = ((islandFBm(bx * 0.15, bz * 0.15) + 1) * 0.5) * edF * 3.2;
if (candidateH < CRYSTAL_MIN_H) continue;
const jx = bx + perlin(bx * 0.7 + 20, bz * 0.7 + 20) * 0.55;
const jz = bz + perlin(bx * 0.7 + 30, bz * 0.7 + 30) * 0.55;
if (Math.sqrt(jx * jx + jz * jz) > ISLAND_RADIUS * 0.68) continue;
const clusterSize = 2 + Math.floor(Math.abs(perlin(bx * 0.5 + 40, bz * 0.5 + 40)) * 3);
for (let c = 0; c < clusterSize; c++) {
const angle = (c / clusterSize) * Math.PI * 2 + perlin(bx + c, bz + c) * 1.4;
const spread = 0.08 + Math.abs(perlin(bx + c * 5, bz + c * 5)) * 0.22;
const sx = jx + Math.cos(angle) * spread;
const sz = jz + Math.sin(angle) * spread;
const spireScale = 0.14 + (candidateH - CRYSTAL_MIN_H) * 0.11;
const spireH = spireScale * (0.8 + Math.abs(perlin(sx, sz)) * 0.45);
const spireR = spireH * 0.17;
_spireData.push({
sx, sz,
posY: candidateH + spireH * 0.5,
rotX: perlin(sx * 3 + 1, sz * 3 + 1) * 0.18,
rotZ: perlin(sx * 2, sz * 2) * 0.28,
scaleXZ: spireR,
scaleY: spireH * 2.8,
});
}
}
}
const _spireDummy = new THREE.Object3D();
const spireBaseGeo = new THREE.ConeGeometry(1, 1, 5);
const crystalGroup = new THREE.Group();
const spireIM = new THREE.InstancedMesh(spireBaseGeo, crystalMat, _spireData.length);
spireIM.castShadow = true;
spireIM.instanceMatrix.setUsage(THREE.StaticDrawUsage);
for (let i = 0; i < _spireData.length; i++) {
const { sx, sz, posY, rotX, rotZ, scaleXZ, scaleY } = _spireData[i];
_spireDummy.position.set(sx, posY, sz);
_spireDummy.rotation.set(rotX, 0, rotZ);
_spireDummy.scale.set(scaleXZ, scaleY, scaleXZ);
_spireDummy.updateMatrix();
spireIM.setMatrixAt(i, _spireDummy.matrix);
}
spireIM.instanceMatrix.needsUpdate = true;
crystalGroup.add(spireIM);
const BOTTOM_SEGS_R = 52;
const BOTTOM_SEGS_V = 10;
const BOTTOM_HEIGHT = 2.6;
const bottomGeo = new THREE.CylinderGeometry(
ISLAND_RADIUS * 0.80, ISLAND_RADIUS * 0.28,
BOTTOM_HEIGHT, BOTTOM_SEGS_R, BOTTOM_SEGS_V, true
);
const bPos = bottomGeo.attributes.position;
for (let i = 0; i < bPos.count; i++) {
const bx = bPos.getX(i);
const bz = bPos.getZ(i);
const by = bPos.getY(i);
const angle = Math.atan2(bz, bx);
const r = Math.sqrt(bx * bx + bz * bz);
const radDisp = perlin(Math.cos(angle) * 1.6 + 50, Math.sin(angle) * 1.6 + 50) * 0.65;
const vNorm = (by + BOTTOM_HEIGHT * 0.5) / BOTTOM_HEIGHT;
const stalDisp = (1 - vNorm) * Math.abs(perlin(bx * 0.35 + 70, by * 0.7 + bz * 0.35)) * 0.9;
const newR = r + radDisp;
bPos.setX(i, (bx / r) * newR);
bPos.setZ(i, (bz / r) * newR);
bPos.setY(i, by - stalDisp);
}
bottomGeo.computeVertexNormals();
const bottomMat = new THREE.MeshStandardMaterial({ color: 0x0c0a08, roughness: 0.93, metalness: 0.02 });
const bottomMesh = new THREE.Mesh(bottomGeo, bottomMat);
bottomMesh.position.y = -BOTTOM_HEIGHT * 0.5;
bottomMesh.castShadow = true;
const capGeo = new THREE.CircleGeometry(ISLAND_RADIUS * 0.28, 48);
capGeo.rotateX(Math.PI / 2);
const capMesh = new THREE.Mesh(capGeo, bottomMat);
capMesh.position.y = -(BOTTOM_HEIGHT + 0.1);
const islandGroup = new THREE.Group();
islandGroup.add(topMesh);
islandGroup.add(crystalGroup);
islandGroup.add(bottomMesh);
islandGroup.add(capMesh);
islandGroup.position.y = -2.8;
scene.add(islandGroup);
})();
// === PROCEDURAL CLOUD LAYER ===
const CLOUD_LAYER_Y = -6.0;
const CLOUD_DIMENSIONS = 120;
const CLOUD_THICKNESS = 15;
const CLOUD_OPACITY = 0.6;
const cloudGeometry = new THREE.BoxGeometry(CLOUD_DIMENSIONS, CLOUD_THICKNESS, CLOUD_DIMENSIONS, 8, 4, 8);
const CloudShader = {
uniforms: {
'uTime': { value: 0.0 },
'uCloudColor': { value: new THREE.Color(0x88bbff) },
'uNoiseScale': { value: new THREE.Vector3(0.015, 0.015, 0.015) },
'uDensity': { value: 0.8 },
},
vertexShader: `
varying vec3 vWorldPosition;
void main() {
vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform vec3 uCloudColor;
uniform vec3 uNoiseScale;
uniform float uDensity;
varying vec3 vWorldPosition;
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v) {
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod289(i);
vec4 p = permute(permute(permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 0.142857142857;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0;
vec4 s1 = floor(b1) * 2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
m = m * m;
return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
}
void main() {
vec3 noiseCoord = vWorldPosition * uNoiseScale + vec3(uTime * 0.003, 0.0, uTime * 0.002);
float noiseVal = snoise(noiseCoord) * 0.500;
noiseVal += snoise(noiseCoord * 2.0) * 0.250;
noiseVal += snoise(noiseCoord * 4.0) * 0.125;
noiseVal /= 0.875;
float density = smoothstep(0.25, 0.85, noiseVal * 0.5 + 0.5);
density *= uDensity;
float layerBottom = ${(CLOUD_LAYER_Y - CLOUD_THICKNESS * 0.5).toFixed(1)};
float yNorm = (vWorldPosition.y - layerBottom) / ${CLOUD_THICKNESS.toFixed(1)};
float fadeFactor = smoothstep(0.0, 0.15, yNorm) * smoothstep(1.0, 0.85, yNorm);
gl_FragColor = vec4(uCloudColor, density * fadeFactor * ${CLOUD_OPACITY.toFixed(1)});
if (gl_FragColor.a < 0.04) discard;
}
`,
};
export const cloudMaterial = new THREE.ShaderMaterial({
uniforms: CloudShader.uniforms,
vertexShader: CloudShader.vertexShader,
fragmentShader: CloudShader.fragmentShader,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
});
const clouds = new THREE.Mesh(cloudGeometry, cloudMaterial);
clouds.position.y = CLOUD_LAYER_Y;
scene.add(clouds);

View File

@@ -1,90 +0,0 @@
// === PORTALS ===
import * as THREE from 'three';
import { scene } from './scene-setup.js';
import { rebuildRuneRing, setPortalsRef } from './effects.js';
import { setPortalsRefAudio, startPortalHums } from './audio.js';
import { S } from './state.js';
export const portalGroup = new THREE.Group();
scene.add(portalGroup);
export let portals = [];
// Shared geometry and material for all portal tori — populated in createPortals()
const _portalGeo = new THREE.TorusGeometry(3.0, 0.2, 16, 100);
const _portalMat = new THREE.MeshBasicMaterial({
color: 0xffffff, // instance color provides per-portal tint
transparent: true,
opacity: 1.0, // online/offline brightness encoded into instance color
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
depthWrite: false,
});
const _portalDummy = new THREE.Object3D();
const _portalColor = new THREE.Color();
/** @type {THREE.InstancedMesh|null} */
let _portalIM = null;
function createPortals() {
// One InstancedMesh for all portal tori — N portals = 1 draw call.
_portalIM = new THREE.InstancedMesh(_portalGeo, _portalMat, portals.length);
_portalIM.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
_portalIM.userData.zoomLabel = 'Portal';
_portalIM.userData.portals = portals; // for instanceId look-up on click
portals.forEach((portal, i) => {
const isOnline = portal.status === 'online';
// Encode online/offline brightness into the instance color so we need
// only one shared material (AdditiveBlending: output = bg + color).
_portalColor.set(portal.color).convertSRGBToLinear()
.multiplyScalar(isOnline ? 0.7 : 0.15);
_portalIM.setColorAt(i, _portalColor);
_portalDummy.position.set(portal.position.x, portal.position.y + 0.5, portal.position.z);
_portalDummy.rotation.set(Math.PI / 2, portal.rotation.y || 0, 0);
_portalDummy.updateMatrix();
_portalIM.setMatrixAt(i, _portalDummy.matrix);
});
_portalIM.instanceColor.needsUpdate = true;
_portalIM.instanceMatrix.needsUpdate = true;
portalGroup.add(_portalIM);
}
/** Update per-instance colors after a portal health check. */
export function refreshPortalInstanceColors() {
if (!_portalIM) return;
portals.forEach((portal, i) => {
const brightness = portal.status === 'online' ? 0.7 : 0.15;
_portalColor.set(portal.color).convertSRGBToLinear().multiplyScalar(brightness);
_portalIM.setColorAt(i, _portalColor);
});
_portalIM.instanceColor.needsUpdate = true;
}
// rebuildGravityZones forward ref
let _rebuildGravityZonesFn = null;
export function setRebuildGravityZonesFn(fn) { _rebuildGravityZonesFn = fn; }
// runPortalHealthChecks forward ref
let _runPortalHealthChecksFn = null;
export function setRunPortalHealthChecksFn(fn) { _runPortalHealthChecksFn = fn; }
export async function loadPortals() {
try {
const res = await fetch('./portals.json');
if (!res.ok) throw new Error('Portals not found');
portals = await res.json();
console.log('Loaded portals:', portals);
setPortalsRef(portals);
setPortalsRefAudio(portals);
createPortals();
rebuildRuneRing();
if (_rebuildGravityZonesFn) _rebuildGravityZonesFn();
startPortalHums();
if (_runPortalHealthChecksFn) _runPortalHealthChecksFn();
} catch (error) {
console.error('Failed to load portals:', error);
}
}

View File

@@ -1,122 +0,0 @@
// === SCENE SETUP + LIGHTING + SHADOWS + STAR FIELD + CONSTELLATION LINES ===
import * as THREE from 'three';
import { NEXUS } from './constants.js';
// === SCENE SETUP ===
export const scene = new THREE.Scene();
export const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 6, 11);
export const raycaster = new THREE.Raycaster();
export const forwardVector = new THREE.Vector3();
// === LIGHTING ===
export const ambientLight = new THREE.AmbientLight(0x0a1428, 1.4);
scene.add(ambientLight);
export const overheadLight = new THREE.SpotLight(0x8899bb, 0.6, 80, Math.PI / 3.5, 0.5, 1.0);
overheadLight.position.set(0, 25, 0);
overheadLight.target.position.set(0, 0, 0);
overheadLight.castShadow = true;
overheadLight.shadow.mapSize.set(2048, 2048);
overheadLight.shadow.camera.near = 5;
overheadLight.shadow.camera.far = 60;
overheadLight.shadow.bias = -0.001;
scene.add(overheadLight);
scene.add(overheadLight.target);
export const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
// === SHADOW SYSTEM ===
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
// === STAR FIELD ===
const STAR_COUNT = 800;
const STAR_SPREAD = 400;
const CONSTELLATION_DISTANCE = 30;
const starPositions = [];
const starGeo = new THREE.BufferGeometry();
const posArray = new Float32Array(STAR_COUNT * 3);
const sizeArray = new Float32Array(STAR_COUNT);
for (let i = 0; i < STAR_COUNT; i++) {
const x = (Math.random() - 0.5) * STAR_SPREAD;
const y = (Math.random() - 0.5) * STAR_SPREAD;
const z = (Math.random() - 0.5) * STAR_SPREAD;
posArray[i * 3] = x;
posArray[i * 3 + 1] = y;
posArray[i * 3 + 2] = z;
sizeArray[i] = Math.random() * 2.5 + 0.5;
starPositions.push(new THREE.Vector3(x, y, z));
}
starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
starGeo.setAttribute('size', new THREE.BufferAttribute(sizeArray, 1));
export const starMaterial = new THREE.PointsMaterial({
color: NEXUS.colors.starCore,
size: 0.6,
sizeAttenuation: true,
transparent: true,
opacity: 0.9,
});
export const stars = new THREE.Points(starGeo, starMaterial);
scene.add(stars);
// Star pulse state
export const STAR_BASE_OPACITY = 0.3;
export const STAR_PEAK_OPACITY = 1.0;
export const STAR_PULSE_DECAY = 0.012;
// === CONSTELLATION LINES ===
function buildConstellationLines() {
const linePositions = [];
const MAX_CONNECTIONS_PER_STAR = 3;
const connectionCount = new Array(STAR_COUNT).fill(0);
for (let i = 0; i < STAR_COUNT; i++) {
if (connectionCount[i] >= MAX_CONNECTIONS_PER_STAR) continue;
const neighbors = [];
for (let j = i + 1; j < STAR_COUNT; j++) {
if (connectionCount[j] >= MAX_CONNECTIONS_PER_STAR) continue;
const dist = starPositions[i].distanceTo(starPositions[j]);
if (dist < CONSTELLATION_DISTANCE) {
neighbors.push({ j, dist });
}
}
neighbors.sort((a, b) => a.dist - b.dist);
const toConnect = neighbors.slice(0, MAX_CONNECTIONS_PER_STAR - connectionCount[i]);
for (const { j } of toConnect) {
linePositions.push(
starPositions[i].x, starPositions[i].y, starPositions[i].z,
starPositions[j].x, starPositions[j].y, starPositions[j].z
);
connectionCount[i]++;
connectionCount[j]++;
}
}
const lineGeo = new THREE.BufferGeometry();
lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(linePositions), 3));
const lineMat = new THREE.LineBasicMaterial({
color: NEXUS.colors.constellationLine,
transparent: true,
opacity: 0.18,
});
return new THREE.LineSegments(lineGeo, lineMat);
}
export const constellationLines = buildConstellationLines();
scene.add(constellationLines);

View File

@@ -1,182 +0,0 @@
// === TIMMY SIGIL ===
import * as THREE from 'three';
import { scene } from './scene-setup.js';
const SIGIL_CANVAS_SIZE = 512;
const SIGIL_RADIUS = 3.8;
function drawSigilCanvas() {
const canvas = document.createElement('canvas');
canvas.width = SIGIL_CANVAS_SIZE;
canvas.height = SIGIL_CANVAS_SIZE;
const ctx = canvas.getContext('2d');
const cx = SIGIL_CANVAS_SIZE / 2;
const cy = SIGIL_CANVAS_SIZE / 2;
const r = cx * 0.88;
ctx.clearRect(0, 0, SIGIL_CANVAS_SIZE, SIGIL_CANVAS_SIZE);
const bgGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
bgGrad.addColorStop(0, 'rgba(0, 200, 255, 0.10)');
bgGrad.addColorStop(0.5, 'rgba(0, 100, 200, 0.04)');
bgGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, SIGIL_CANVAS_SIZE, SIGIL_CANVAS_SIZE);
function glowCircle(x, y, radius, color, alpha, lineW) {
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = lineW;
ctx.shadowColor = color;
ctx.shadowBlur = 12;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
function hexagram(ox, oy, hr, color, alpha) {
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 1.4;
ctx.shadowColor = color;
ctx.shadowBlur = 10;
ctx.beginPath();
for (let i = 0; i < 3; i++) {
const a = (i / 3) * Math.PI * 2 - Math.PI / 2;
const px = ox + Math.cos(a) * hr;
const py = oy + Math.sin(a) * hr;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.closePath();
ctx.stroke();
ctx.beginPath();
for (let i = 0; i < 3; i++) {
const a = (i / 3) * Math.PI * 2 + Math.PI / 2;
const px = ox + Math.cos(a) * hr;
const py = oy + Math.sin(a) * hr;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.closePath();
ctx.stroke();
ctx.restore();
}
const petalR = r * 0.32;
glowCircle(cx, cy, petalR, '#00ccff', 0.65, 1.0);
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
glowCircle(cx + Math.cos(a) * petalR, cy + Math.sin(a) * petalR, petalR, '#00aadd', 0.50, 0.8);
}
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2 + Math.PI / 6;
glowCircle(cx + Math.cos(a) * petalR * 1.73, cy + Math.sin(a) * petalR * 1.73, petalR, '#0077aa', 0.25, 0.6);
}
hexagram(cx, cy, r * 0.62, '#ffd700', 0.75);
hexagram(cx, cy, r * 0.41, '#ffaa00', 0.50);
glowCircle(cx, cy, r * 0.92, '#0055aa', 0.40, 0.8);
glowCircle(cx, cy, r * 0.72, '#0099cc', 0.38, 0.8);
glowCircle(cx, cy, r * 0.52, '#00ccff', 0.42, 0.9);
glowCircle(cx, cy, r * 0.18, '#ffd700', 0.65, 1.2);
ctx.save();
ctx.globalAlpha = 0.28;
ctx.strokeStyle = '#00aaff';
ctx.lineWidth = 0.6;
ctx.shadowColor = '#00aaff';
ctx.shadowBlur = 5;
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * r * 0.18, cy + Math.sin(a) * r * 0.18);
ctx.lineTo(cx + Math.cos(a) * r * 0.91, cy + Math.sin(a) * r * 0.91);
ctx.stroke();
}
ctx.restore();
ctx.save();
ctx.fillStyle = '#00ffcc';
ctx.shadowColor = '#00ffcc';
ctx.shadowBlur = 9;
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI * 2;
ctx.globalAlpha = i % 2 === 0 ? 0.80 : 0.50;
ctx.beginPath();
ctx.arc(cx + Math.cos(a) * r * 0.91, cy + Math.sin(a) * r * 0.91, i % 2 === 0 ? 4 : 2.5, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
ctx.save();
ctx.globalAlpha = 1.0;
ctx.fillStyle = '#ffffff';
ctx.shadowColor = '#88ddff';
ctx.shadowBlur = 18;
ctx.beginPath();
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
return canvas;
}
const sigilTexture = new THREE.CanvasTexture(drawSigilCanvas());
export const sigilMat = new THREE.MeshBasicMaterial({
map: sigilTexture,
transparent: true,
opacity: 0.80,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
});
export const sigilMesh = new THREE.Mesh(
new THREE.CircleGeometry(SIGIL_RADIUS, 128),
sigilMat
);
sigilMesh.rotation.x = -Math.PI / 2;
sigilMesh.position.y = 0.010;
sigilMesh.userData.zoomLabel = 'Timmy Sigil';
scene.add(sigilMesh);
export const sigilRing1Mat = new THREE.MeshBasicMaterial({
color: 0x00ccff, transparent: true, opacity: 0.45, depthWrite: false, blending: THREE.AdditiveBlending,
});
export const sigilRing1 = new THREE.Mesh(
new THREE.TorusGeometry(SIGIL_RADIUS * 0.965, 0.025, 6, 96), sigilRing1Mat
);
sigilRing1.rotation.x = Math.PI / 2;
sigilRing1.position.y = 0.012;
scene.add(sigilRing1);
export const sigilRing2Mat = new THREE.MeshBasicMaterial({
color: 0xffd700, transparent: true, opacity: 0.40, depthWrite: false, blending: THREE.AdditiveBlending,
});
export const sigilRing2 = new THREE.Mesh(
new THREE.TorusGeometry(SIGIL_RADIUS * 0.62, 0.020, 6, 72), sigilRing2Mat
);
sigilRing2.rotation.x = Math.PI / 2;
sigilRing2.position.y = 0.013;
scene.add(sigilRing2);
export const sigilRing3Mat = new THREE.MeshBasicMaterial({
color: 0x00ffcc, transparent: true, opacity: 0.35, depthWrite: false, blending: THREE.AdditiveBlending,
});
export const sigilRing3 = new THREE.Mesh(
new THREE.TorusGeometry(SIGIL_RADIUS * 0.78, 0.018, 6, 80), sigilRing3Mat
);
sigilRing3.rotation.x = Math.PI / 2;
sigilRing3.position.y = 0.011;
scene.add(sigilRing3);
export const sigilLight = new THREE.PointLight(0x0088ff, 0.4, 8);
sigilLight.position.set(0, 0.5, 0);
scene.add(sigilLight);

View File

@@ -1,83 +0,0 @@
// Shared mutable state — imported by all modules that need cross-module scalar access
import * as THREE from 'three';
export const S = {
// Mouse & camera
mouseX: 0,
mouseY: 0,
targetRotX: 0,
targetRotY: 0,
// Overview
overviewMode: false,
overviewT: 0,
// Zoom
zoomT: 0,
zoomTargetT: 0,
zoomActive: false,
_zoomCamTarget: new THREE.Vector3(),
_zoomLookTarget: new THREE.Vector3(),
// Photo
photoMode: false,
// Warp
isWarping: false,
warpStartTime: 0,
warpNavigated: false,
warpDestinationUrl: null,
warpPortalColor: new THREE.Color(0x4488ff),
// Stars
_starPulseIntensity: 0,
// Energy beam
energyBeamPulse: 0,
_activeAgentCount: 0,
// Batcave
batcaveProbeLastUpdate: -999,
// Lightning
lastLightningRefreshTime: 0,
// Oath
oathActive: false,
oathLines: [],
oathRevealTimer: null,
// Speech
timmySpeechSprite: null,
timmySpeechState: null,
// Timelapse
timelapseActive: false,
timelapseRealStart: 0,
timelapseProgress: 0,
timelapseNextCommitIdx: 0,
// Bitcoin
lastKnownBlockHeight: null,
// Audio
audioCtx: null,
masterGain: null,
audioRunning: false,
portalHumsStarted: false,
sparkleTimer: null,
// Debug
debugMode: false,
// Matrix
_matrixCommitHashes: [],
// Sovereignty easter egg
sovereigntyBuffer: '',
sovereigntyBufferTimer: null,
// Sovereignty score
sovereigntyScore: 85,
sovereigntyLabel: 'Mostly Sovereign',
};

View File

@@ -1,326 +0,0 @@
// === WARP TUNNEL + CRYSTALS + LIGHTNING + BATCAVE + DUAL-BRAIN ===
import * as THREE from 'three';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { NEXUS } from './constants.js';
import { scene, camera, renderer } from './scene-setup.js';
import { composer } from './controls.js';
import { zoneIntensity } from './heatmap.js';
import { S } from './state.js';
// === WARP TUNNEL EFFECT ===
const WarpShader = {
uniforms: {
'tDiffuse': { value: null },
'time': { value: 0.0 },
'progress': { value: 0.0 },
'portalColor': { value: new THREE.Color(0x4488ff) },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float time;
uniform float progress;
uniform vec3 portalColor;
varying vec2 vUv;
#define PI 3.14159265358979
void main() {
vec2 uv = vUv;
vec2 center = vec2(0.5, 0.5);
vec2 dir = uv - center;
float dist = length(dir);
float angle = atan(dir.y, dir.x);
float intensity = sin(progress * PI);
float zoom = 1.0 + intensity * 3.0;
vec2 zoomedUV = center + dir / zoom;
float swirl = intensity * 5.0 * max(0.0, 1.0 - dist * 2.0);
float twisted = angle + swirl;
vec2 swirlUV = center + vec2(cos(twisted), sin(twisted)) * dist / (1.0 + intensity * 1.8);
vec2 warpUV = mix(zoomedUV, swirlUV, 0.6);
warpUV = clamp(warpUV, vec2(0.001), vec2(0.999));
float aber = intensity * 0.018;
vec2 aberDir = normalize(dir + vec2(0.001));
float rVal = texture2D(tDiffuse, clamp(warpUV + aberDir * aber, vec2(0.0), vec2(1.0))).r;
float gVal = texture2D(tDiffuse, warpUV).g;
float bVal = texture2D(tDiffuse, clamp(warpUV - aberDir * aber, vec2(0.0), vec2(1.0))).b;
vec4 color = vec4(rVal, gVal, bVal, 1.0);
float numLines = 28.0;
float lineAngleFrac = fract((angle / (2.0 * PI) + 0.5) * numLines + time * 4.0);
float lineSharp = pow(max(0.0, 1.0 - abs(lineAngleFrac - 0.5) * 16.0), 3.0);
float radialFade = max(0.0, 1.0 - dist * 2.2);
float speedLine = lineSharp * radialFade * intensity * 1.8;
float lineAngleFrac2 = fract((angle / (2.0 * PI) + 0.5) * 14.0 - time * 2.5);
float lineSharp2 = pow(max(0.0, 1.0 - abs(lineAngleFrac2 - 0.5) * 12.0), 3.0);
float speedLine2 = lineSharp2 * radialFade * intensity * 0.9;
float rimDist = abs(dist - 0.08 * intensity);
float rimGlow = pow(max(0.0, 1.0 - rimDist * 40.0), 2.0) * intensity;
color.rgb = mix(color.rgb, portalColor, intensity * 0.45);
color.rgb += portalColor * (speedLine + speedLine2);
color.rgb += vec3(1.0) * rimGlow * 0.8;
float bloom = pow(max(0.0, 1.0 - dist / (0.18 * intensity + 0.001)), 2.0) * intensity;
color.rgb += portalColor * bloom * 2.5 + vec3(1.0) * bloom * 0.6;
float vignette = smoothstep(0.5, 0.2, dist) * intensity * 0.5;
color.rgb *= 1.0 - vignette * 0.4;
float flash = smoothstep(0.82, 1.0, progress);
color.rgb = mix(color.rgb, vec3(1.0), flash);
gl_FragColor = color;
}
`,
};
export const warpPass = new ShaderPass(WarpShader);
warpPass.enabled = false;
composer.addPass(warpPass);
export function startWarp(portalMesh) {
S.isWarping = true;
S.warpNavigated = false;
S.warpStartTime = clock.getElapsedTime();
warpPass.enabled = true;
warpPass.uniforms['time'].value = 0.0;
warpPass.uniforms['progress'].value = 0.0;
if (portalMesh) {
S.warpDestinationUrl = portalMesh.userData.destinationUrl || null;
S.warpPortalColor = portalMesh.userData.portalColor
? portalMesh.userData.portalColor.clone()
: new THREE.Color(0x4488ff);
} else {
S.warpDestinationUrl = null;
S.warpPortalColor = new THREE.Color(0x4488ff);
}
warpPass.uniforms['portalColor'].value = S.warpPortalColor;
}
// clock is created here and exported
export const clock = new THREE.Clock();
// === FLOATING CRYSTALS & LIGHTNING ARCS ===
const CRYSTAL_COUNT = 5;
const CRYSTAL_BASE_POSITIONS = [
new THREE.Vector3(-4.5, 3.2, -3.8),
new THREE.Vector3( 4.8, 2.8, -4.0),
new THREE.Vector3(-5.5, 4.0, 1.5),
new THREE.Vector3( 5.2, 3.5, 2.0),
new THREE.Vector3( 0.0, 5.0, -5.5),
];
export const CRYSTAL_COLORS = [0xff6440, 0x40a0ff, 0x40ff8c, 0xc840ff, 0xffd700];
const crystalGroupObj = new THREE.Group();
scene.add(crystalGroupObj);
export const crystals = [];
for (let i = 0; i < CRYSTAL_COUNT; i++) {
const geo = new THREE.OctahedronGeometry(0.35, 0);
const color = CRYSTAL_COLORS[i];
const mat = new THREE.MeshStandardMaterial({
color,
emissive: new THREE.Color(color).multiplyScalar(0.6),
roughness: 0.05,
metalness: 0.3,
transparent: true,
opacity: 0.88,
});
const mesh = new THREE.Mesh(geo, mat);
const basePos = CRYSTAL_BASE_POSITIONS[i].clone();
mesh.position.copy(basePos);
mesh.userData.zoomLabel = 'Crystal';
crystalGroupObj.add(mesh);
const light = new THREE.PointLight(color, 0.3, 6);
light.position.copy(basePos);
crystalGroupObj.add(light);
crystals.push({ mesh, light, basePos, floatPhase: (i / CRYSTAL_COUNT) * Math.PI * 2, flashStartTime: -999 });
}
// Lightning arc pool
export const LIGHTNING_POOL_SIZE = 6;
const LIGHTNING_SEGMENTS = 8;
export const LIGHTNING_REFRESH_MS = 130;
export const lightningArcs = [];
export const lightningArcMeta = [];
for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) {
const positions = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.LineBasicMaterial({
color: 0x88ccff, transparent: true, opacity: 0.0,
blending: THREE.AdditiveBlending, depthWrite: false,
});
const arc = new THREE.Line(geo, mat);
scene.add(arc);
lightningArcs.push(arc);
lightningArcMeta.push({ active: false, baseOpacity: 0, srcIdx: 0, dstIdx: 0 });
}
function buildLightningPath(start, end, jagAmount) {
const out = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3);
for (let s = 0; s <= LIGHTNING_SEGMENTS; s++) {
const t = s / LIGHTNING_SEGMENTS;
const x = start.x + (end.x - start.x) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0);
const y = start.y + (end.y - start.y) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0);
const z = start.z + (end.z - start.z) * t + (s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0);
out[s * 3] = x; out[s * 3 + 1] = y; out[s * 3 + 2] = z;
}
return out;
}
export function totalActivity() {
const vals = Object.values(zoneIntensity);
return vals.reduce((s, v) => s + v, 0) / Math.max(vals.length, 1);
}
export function lerpColor(colorA, colorB, t) {
const ar = (colorA >> 16) & 0xff, ag = (colorA >> 8) & 0xff, ab = colorA & 0xff;
const br = (colorB >> 16) & 0xff, bg = (colorB >> 8) & 0xff, bb = colorB & 0xff;
const r = Math.round(ar + (br - ar) * t);
const g = Math.round(ag + (bg - ag) * t);
const b = Math.round(ab + (bb - ab) * t);
return (r << 16) | (g << 8) | b;
}
export function updateLightningArcs(elapsed) {
const activity = totalActivity();
const activeCount = Math.round(activity * LIGHTNING_POOL_SIZE);
for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) {
const arc = lightningArcs[i];
const meta = lightningArcMeta[i];
if (i >= activeCount) {
arc.material.opacity = 0;
meta.active = false;
continue;
}
const a = Math.floor(Math.random() * CRYSTAL_COUNT);
let b = Math.floor(Math.random() * (CRYSTAL_COUNT - 1));
if (b >= a) b++;
const jagAmount = 0.45 + activity * 0.85;
const path = buildLightningPath(crystals[a].mesh.position, crystals[b].mesh.position, jagAmount);
const attr = arc.geometry.attributes.position;
attr.array.set(path);
attr.needsUpdate = true;
arc.material.color.setHex(lerpColor(CRYSTAL_COLORS[a], CRYSTAL_COLORS[b], 0.5));
const base = (0.35 + Math.random() * 0.55) * Math.min(activity * 1.5, 1.0);
arc.material.opacity = base;
meta.active = true;
meta.baseOpacity = base;
meta.srcIdx = a;
meta.dstIdx = b;
crystals[a].flashStartTime = elapsed;
crystals[b].flashStartTime = elapsed;
}
}
// === BATCAVE AREA ===
const BATCAVE_ORIGIN = new THREE.Vector3(-10, 0, -8);
export const batcaveGroup = new THREE.Group();
batcaveGroup.position.copy(BATCAVE_ORIGIN);
scene.add(batcaveGroup);
const batcaveProbeTarget = new THREE.WebGLCubeRenderTarget(128, {
type: THREE.HalfFloatType,
generateMipmaps: true,
minFilter: THREE.LinearMipmapLinearFilter,
});
export const batcaveProbe = new THREE.CubeCamera(0.1, 80, batcaveProbeTarget);
batcaveProbe.position.set(0, 1.2, -1);
batcaveGroup.add(batcaveProbe);
const batcaveFloorMat = new THREE.MeshStandardMaterial({
color: 0x0d1520, metalness: 0.92, roughness: 0.08, envMapIntensity: 1.4,
});
const batcaveWallMat = new THREE.MeshStandardMaterial({
color: 0x0a1828, metalness: 0.85, roughness: 0.15,
emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.03),
envMapIntensity: 1.2,
});
const batcaveConsoleMat = new THREE.MeshStandardMaterial({
color: 0x060e16, metalness: 0.95, roughness: 0.05, envMapIntensity: 1.6,
});
export const batcaveMetallicMats = [batcaveFloorMat, batcaveWallMat, batcaveConsoleMat];
export const batcaveProbeTarget_texture = batcaveProbeTarget;
const batcaveFloor = new THREE.Mesh(new THREE.BoxGeometry(6, 0.08, 6), batcaveFloorMat);
batcaveFloor.position.y = -0.04;
batcaveGroup.add(batcaveFloor);
const batcaveBackWall = new THREE.Mesh(new THREE.BoxGeometry(6, 3, 0.1), batcaveWallMat);
batcaveBackWall.position.set(0, 1.5, -3);
batcaveGroup.add(batcaveBackWall);
const batcaveLeftWall = new THREE.Mesh(new THREE.BoxGeometry(0.1, 3, 6), batcaveWallMat);
batcaveLeftWall.position.set(-3, 1.5, 0);
batcaveGroup.add(batcaveLeftWall);
const batcaveConsoleBase = new THREE.Mesh(new THREE.BoxGeometry(3, 0.7, 1.2), batcaveConsoleMat);
batcaveConsoleBase.position.set(0, 0.35, -1.5);
batcaveGroup.add(batcaveConsoleBase);
const batcaveScreenBezel = new THREE.Mesh(new THREE.BoxGeometry(2.6, 1.4, 0.06), batcaveConsoleMat);
batcaveScreenBezel.position.set(0, 1.4, -2.08);
batcaveScreenBezel.rotation.x = Math.PI * 0.08;
batcaveGroup.add(batcaveScreenBezel);
const batcaveScreenGlow = new THREE.Mesh(
new THREE.PlaneGeometry(2.2, 1.1),
new THREE.MeshBasicMaterial({
color: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.65),
transparent: true, opacity: 0.82,
})
);
batcaveScreenGlow.position.set(0, 1.4, -2.05);
batcaveScreenGlow.rotation.x = Math.PI * 0.08;
batcaveGroup.add(batcaveScreenGlow);
const batcaveLight = new THREE.PointLight(NEXUS.colors.accent, 0.9, 14);
batcaveLight.position.set(0, 2.8, -1);
batcaveGroup.add(batcaveLight);
const batcaveCeilingStrip = new THREE.Mesh(
new THREE.BoxGeometry(4.2, 0.05, 0.14),
new THREE.MeshStandardMaterial({
color: NEXUS.colors.accent,
emissive: new THREE.Color(NEXUS.colors.accent),
emissiveIntensity: 1.1,
})
);
batcaveCeilingStrip.position.set(0, 2.95, -1.2);
batcaveGroup.add(batcaveCeilingStrip);
batcaveGroup.traverse(obj => {
if (obj.isMesh) obj.userData.zoomLabel = 'Batcave';
});

View File

@@ -1,181 +0,0 @@
// === WEATHER SYSTEM + PORTAL HEALTH ===
import * as THREE from 'three';
import { scene, ambientLight } from './scene-setup.js';
import { cloudMaterial } from './platform.js';
import { rebuildRuneRing } from './effects.js';
import { S } from './state.js';
import { refreshPortalInstanceColors } from './portals.js';
// === PORTAL HEALTH CHECKS ===
const PORTAL_HEALTH_CHECK_MS = 5 * 60 * 1000;
// Forward refs
let _portalsRef = [];
let _portalGroupRef = null;
let _rebuildGravityZonesFn = null;
export function setWeatherPortalRefs(portals, portalGroup, rebuildGravityZones) {
_portalsRef = portals;
_portalGroupRef = portalGroup;
_rebuildGravityZonesFn = rebuildGravityZones;
}
export async function runPortalHealthChecks() {
if (_portalsRef.length === 0) return;
for (const portal of _portalsRef) {
if (!portal.destination?.url) {
portal.status = 'offline';
continue;
}
try {
await fetch(portal.destination.url, {
mode: 'no-cors',
signal: AbortSignal.timeout(5000),
});
portal.status = 'online';
} catch {
portal.status = 'offline';
}
}
rebuildRuneRing();
if (_rebuildGravityZonesFn) _rebuildGravityZonesFn();
// Refresh portal InstancedMesh colors to reflect new online/offline statuses.
refreshPortalInstanceColors();
}
export function initPortalHealthChecks() {
setInterval(runPortalHealthChecks, PORTAL_HEALTH_CHECK_MS);
}
// === WEATHER SYSTEM ===
const WEATHER_LAT = 43.2897;
const WEATHER_LON = -72.1479;
const WEATHER_REFRESH_MS = 15 * 60 * 1000;
let weatherState = null;
export const PRECIP_COUNT = 1200;
export const PRECIP_AREA = 18;
export const PRECIP_HEIGHT = 20;
export const PRECIP_FLOOR = -5;
// Rain geometry
export const rainGeo = new THREE.BufferGeometry();
const rainPositions = new Float32Array(PRECIP_COUNT * 3);
export const rainVelocities = new Float32Array(PRECIP_COUNT);
for (let i = 0; i < PRECIP_COUNT; i++) {
rainPositions[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2;
rainPositions[i * 3 + 1] = Math.random() * (PRECIP_HEIGHT - PRECIP_FLOOR) + PRECIP_FLOOR;
rainPositions[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2;
rainVelocities[i] = 0.18 + Math.random() * 0.12;
}
rainGeo.setAttribute('position', new THREE.BufferAttribute(rainPositions, 3));
const rainMat = new THREE.PointsMaterial({
color: 0x88aaff, size: 0.05, sizeAttenuation: true,
transparent: true, opacity: 0.55,
});
export const rainParticles = new THREE.Points(rainGeo, rainMat);
rainParticles.visible = false;
scene.add(rainParticles);
// Snow geometry
export const snowGeo = new THREE.BufferGeometry();
const snowPositions = new Float32Array(PRECIP_COUNT * 3);
export const snowDrift = new Float32Array(PRECIP_COUNT);
for (let i = 0; i < PRECIP_COUNT; i++) {
snowPositions[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2;
snowPositions[i * 3 + 1] = Math.random() * (PRECIP_HEIGHT - PRECIP_FLOOR) + PRECIP_FLOOR;
snowPositions[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2;
snowDrift[i] = Math.random() * Math.PI * 2;
}
snowGeo.setAttribute('position', new THREE.BufferAttribute(snowPositions, 3));
const snowMat = new THREE.PointsMaterial({
color: 0xddeeff, size: 0.12, sizeAttenuation: true,
transparent: true, opacity: 0.75,
});
export const snowParticles = new THREE.Points(snowGeo, snowMat);
snowParticles.visible = false;
scene.add(snowParticles);
function weatherCodeToLabel(code) {
if (code === 0) return { condition: 'Clear', icon: '☀️' };
if (code <= 2) return { condition: 'Partly Cloudy', icon: '⛅' };
if (code === 3) return { condition: 'Overcast', icon: '☁️' };
if (code >= 45 && code <= 48) return { condition: 'Fog', icon: '🌫️' };
if (code >= 51 && code <= 57) return { condition: 'Drizzle', icon: '🌦️' };
if (code >= 61 && code <= 67) return { condition: 'Rain', icon: '🌧️' };
if (code >= 71 && code <= 77) return { condition: 'Snow', icon: '❄️' };
if (code >= 80 && code <= 82) return { condition: 'Showers', icon: '🌦️' };
if (code >= 85 && code <= 86) return { condition: 'Snow Showers', icon: '🌨️' };
if (code >= 95 && code <= 99) return { condition: 'Thunderstorm', icon: '⛈️' };
return { condition: 'Unknown', icon: '🌀' };
}
function applyWeatherToScene(wx) {
const code = wx.code;
const isRain = (code >= 51 && code <= 67) || (code >= 80 && code <= 82) || (code >= 95 && code <= 99);
const isSnow = (code >= 71 && code <= 77) || (code >= 85 && code <= 86);
rainParticles.visible = isRain;
snowParticles.visible = isSnow;
if (isSnow) {
ambientLight.color.setHex(0x1a2a40);
ambientLight.intensity = 1.8;
} else if (isRain) {
ambientLight.color.setHex(0x0a1428);
ambientLight.intensity = 1.2;
} else if (code === 3 || (code >= 45 && code <= 48)) {
ambientLight.color.setHex(0x0c1220);
ambientLight.intensity = 1.1;
} else {
ambientLight.color.setHex(0x0a1428);
ambientLight.intensity = 1.4;
}
}
function updateWeatherHUD(wx) {
const iconEl = document.getElementById('weather-icon');
const tempEl = document.getElementById('weather-temp');
const descEl = document.getElementById('weather-desc');
if (iconEl) iconEl.textContent = wx.icon;
if (tempEl) tempEl.textContent = `${Math.round(wx.temp)}°F`;
if (descEl) descEl.textContent = wx.condition;
}
export async function fetchWeather() {
try {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}&current=temperature_2m,weather_code,wind_speed_10m,cloud_cover&temperature_unit=fahrenheit&wind_speed_unit=mph&forecast_days=1`;
const res = await fetch(url);
if (!res.ok) throw new Error('weather fetch failed');
const data = await res.json();
const cur = data.current;
const code = cur.weather_code;
const { condition, icon } = weatherCodeToLabel(code);
const cloudcover = typeof cur.cloud_cover === 'number' ? cur.cloud_cover : 50;
weatherState = { code, temp: cur.temperature_2m, wind: cur.wind_speed_10m, condition, icon, cloudcover };
applyWeatherToScene(weatherState);
const cloudOpacity = 0.05 + (cloudcover / 100) * 0.55;
cloudMaterial.uniforms.uDensity.value = 0.3 + (cloudcover / 100) * 0.7;
cloudMaterial.opacity = cloudOpacity;
updateWeatherHUD(weatherState);
} catch {
const descEl = document.getElementById('weather-desc');
if (descEl) descEl.textContent = 'Lempster NH';
}
}
export function initWeather() {
fetchWeather();
setInterval(fetchWeather, WEATHER_REFRESH_MS);
}

View File

@@ -1,110 +0,0 @@
# nginx.conf — the-nexus.alexanderwhitestone.com
#
# DNS SETUP:
# Add an A record pointing the-nexus.alexanderwhitestone.com → <VPS_IP>
# Then obtain a TLS cert with Let's Encrypt:
# certbot certonly --nginx -d the-nexus.alexanderwhitestone.com
#
# INSTALL:
# sudo cp nginx.conf /etc/nginx/sites-available/the-nexus
# sudo ln -sf /etc/nginx/sites-available/the-nexus /etc/nginx/sites-enabled/the-nexus
# sudo nginx -t && sudo systemctl reload nginx
# ── HTTP → HTTPS redirect ────────────────────────────────────────────────────
server {
listen 80;
listen [::]:80;
server_name the-nexus.alexanderwhitestone.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# ── HTTPS ────────────────────────────────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name the-nexus.alexanderwhitestone.com;
# TLS — managed by Certbot; update paths if cert lives elsewhere
ssl_certificate /etc/letsencrypt/live/the-nexus.alexanderwhitestone.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/the-nexus.alexanderwhitestone.com/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# ── gzip ─────────────────────────────────────────────────────────────────
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/wasm
image/svg+xml
font/woff
font/woff2;
# ── Health check endpoint ────────────────────────────────────────────────
# Simple endpoint for uptime monitoring.
location /health {
return 200 "OK";
add_header Content-Type text/plain;
}
# ── WebSocket proxy (/ws) ─────────────────────────────────────────────────
# Forwards to the Hermes / presence backend running on port 8080.
# Adjust the upstream address if the WS server lives elsewhere.
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# ── Static files — proxied to nexus-main Docker container ────────────────
location / {
proxy_pass http://127.0.0.1:4200;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Long-lived cache for hashed/versioned assets
location ~* \.(js|css|woff2?|ttf|otf|eot|svg|ico|png|jpg|jpeg|gif|webp|avif|wasm)$ {
proxy_pass http://127.0.0.1:4200;
proxy_set_header Host $host;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# index.html must always be revalidated
location = /index.html {
proxy_pass http://127.0.0.1:4200;
proxy_set_header Host $host;
add_header Cache-Control "no-cache, must-revalidate";
}
}
}

12
package-lock.json generated
View File

@@ -1,12 +0,0 @@
{
"name": "the-nexus",
"version": "1.0.67",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "the-nexus",
"version": "1.0.67"
}
}
}

View File

@@ -1,7 +0,0 @@
{
"name": "the-nexus",
"type": "module",
"version": "1.0.0",
"description": "Timmy's Sovereign Home — Three.js 3D world",
"private": true
}

View File

@@ -1,44 +0,0 @@
[
{
"id": "morrowind",
"name": "Morrowind",
"description": "The Vvardenfell harness. Ash storms and ancient mysteries.",
"status": "offline",
"color": "#ff6600",
"position": { "x": 15, "y": 0, "z": -10 },
"rotation": { "y": -0.5 },
"destination": {
"url": "https://morrowind.timmy.foundation",
"type": "harness",
"params": { "world": "vvardenfell" }
}
},
{
"id": "bannerlord",
"name": "Bannerlord",
"description": "Calradia battle harness. Massive armies, tactical command.",
"status": "offline",
"color": "#ffd700",
"position": { "x": -15, "y": 0, "z": -10 },
"rotation": { "y": 0.5 },
"destination": {
"url": "https://bannerlord.timmy.foundation",
"type": "harness",
"params": { "world": "calradia" }
}
},
{
"id": "workshop",
"name": "Workshop",
"description": "The creative harness. Build, script, and manifest.",
"status": "offline",
"color": "#4af0c0",
"position": { "x": 0, "y": 0, "z": -20 },
"rotation": { "y": 0 },
"destination": {
"url": "https://workshop.timmy.foundation",
"type": "harness",
"params": { "mode": "creative" }
}
}
]

View File

@@ -1,4 +0,0 @@
{
"score": 75,
"label": "Stable"
}

911
style.css
View File

@@ -1,599 +1,382 @@
/* === DESIGN SYSTEM — NEXUS === */
/* === NEXUS DESIGN SYSTEM === */
:root {
--color-bg: #000008;
--color-primary: #4488ff;
--color-secondary: #334488;
--color-text: #ccd6f6;
--color-text-muted: #4a5568;
--font-body: 'Courier New', monospace;
--font-display: 'Orbitron', sans-serif;
--font-body: 'JetBrains Mono', monospace;
--color-bg: #050510;
--color-surface: rgba(10, 15, 40, 0.85);
--color-border: rgba(74, 240, 192, 0.2);
--color-border-bright: rgba(74, 240, 192, 0.5);
--color-text: #c8d8e8;
--color-text-muted: #5a6a8a;
--color-text-bright: #e0f0ff;
--color-primary: #4af0c0;
--color-primary-dim: rgba(74, 240, 192, 0.3);
--color-secondary: #7b5cff;
--color-danger: #ff4466;
--color-warning: #ffaa22;
--color-gold: #ffd700;
--text-xs: 11px;
--text-sm: 13px;
--text-base: 15px;
--text-lg: 18px;
--text-xl: 24px;
--text-2xl: 36px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--panel-blur: 16px;
--panel-radius: 8px;
--transition-ui: 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--color-bg);
font-family: var(--font-body);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
}
canvas#nexus-canvas {
display: block;
width: 100vw;
height: 100vh;
}
canvas {
display: block;
position: fixed;
top: 0;
left: 0;
}
/* Matrix rain sits behind the Three.js renderer */
#matrix-rain {
z-index: 0;
opacity: 0.18;
}
/* === HUD === */
.hud-controls {
z-index: 10;
}
/* === AUDIO TOGGLE === */
#audio-toggle {
font-size: 14px;
background-color: var(--color-primary);
color: var(--color-bg);
padding: 4px 8px;
border: none;
border-radius: 4px;
font-family: var(--font-body);
transition: background-color 0.3s ease;
cursor: pointer;
}
#audio-toggle:hover {
background-color: var(--color-secondary);
}
#podcast-toggle {
margin-left: 8px;
background-color: var(--color-accent);
color: var(--color-bg);
padding: 4px 8px;
border: none;
border-radius: 4px;
font-family: var(--font-body);
transition: background-color 0.3s ease;
cursor: pointer;
}
#podcast-toggle.active {
background-color: #0066cc;
color: var(--color-bg);
}
#podcast-toggle:hover {
background-color: var(--color-primary);
}
#soul-toggle {
background-color: var(--color-secondary);
color: var(--color-text);
}
#audio-toggle.muted {
background-color: var(--color-text-muted);
}
/* === DEBUG MODE === */
#debug-toggle {
margin-left: 8px;
}
/* === SESSION EXPORT === */
#export-session {
margin-left: 8px;
background-color: var(--color-secondary);
color: var(--color-text);
padding: 4px 8px;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
font-family: var(--font-body);
transition: background-color 0.2s ease;
}
#podcast-toggle {
margin-left: 8px;
background-color: var(--color-accent);
color: var(--color-bg);
padding: 4px 8px;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
font-family: var(--font-body);
transition: background-color 0.3s ease;
}
#podcast-toggle.active {
background-color: #0066cc;
}
#podcast-toggle:disabled {
opacity: 0.6;
cursor: not-allowed;
}
#podcast-error {
/* === LOADING SCREEN === */
#loading-screen {
position: fixed;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 0, 0, 0.9);
color: white;
padding: 8px 16px;
border-radius: 4px;
font-size: 12px;
display: none;
}
}
#podcast-toggle:hover {
background-color: var(--color-primary);
}
#podcast-toggle:hover {
background-color: var(--color-primary);
}
#export-session:hover {
background-color: var(--color-primary);
color: var(--color-bg);
}
.collision-box {
outline: 2px solid red;
outline-offset: 2px;
}
.light-source {
outline: 2px dashed yellow;
outline-offset: 2px;
}
/* === OVERVIEW MODE === */
#overview-indicator {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-primary);
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
pointer-events: none;
z-index: 20;
border: 1px solid var(--color-primary);
padding: 4px 10px;
background: rgba(0, 0, 8, 0.6);
white-space: nowrap;
animation: overview-pulse 2s ease-in-out infinite;
}
#overview-indicator.visible {
display: block;
}
.overview-hint {
margin-left: 12px;
color: var(--color-text-muted);
font-size: 10px;
}
@keyframes overview-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
/* === PHOTO MODE === */
body.photo-mode .hud-controls {
display: none;
}
body.photo-mode #overview-indicator {
display: none !important;
}
#photo-indicator {
display: none;
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
color: var(--color-primary);
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
pointer-events: none;
z-index: 20;
border: 1px solid var(--color-primary);
padding: 4px 12px;
background: rgba(0, 0, 8, 0.5);
white-space: nowrap;
animation: overview-pulse 2s ease-in-out infinite;
}
#photo-indicator.visible {
display: block;
}
.photo-hint {
margin-left: 12px;
color: var(--color-text-muted);
font-size: 10px;
letter-spacing: 0.1em;
}
#photo-focus {
color: var(--color-primary);
}
/* === ZOOM-TO-OBJECT INDICATOR === */
#zoom-indicator {
display: none;
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
color: var(--color-accent);
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
pointer-events: none;
z-index: 20;
border: 1px solid var(--color-accent);
padding: 4px 12px;
background: rgba(0, 0, 8, 0.6);
white-space: nowrap;
animation: overview-pulse 2s ease-in-out infinite;
}
#zoom-indicator.visible {
display: block;
}
.zoom-hint {
margin-left: 12px;
color: var(--color-text-muted);
font-size: 10px;
}
/* === WEATHER HUD === */
#weather-hud {
position: fixed;
bottom: 14px;
left: 14px;
inset: 0;
z-index: 1000;
background: var(--color-bg);
display: flex;
align-items: center;
gap: 6px;
background: rgba(0, 6, 20, 0.72);
border: 1px solid rgba(68, 136, 255, 0.35);
border-radius: 6px;
padding: 5px 10px;
font-family: var(--font-body);
font-size: 12px;
color: var(--color-text);
z-index: 10;
pointer-events: none;
transition: opacity 0.5s ease;
}
#weather-icon {
font-size: 16px;
}
#weather-temp {
color: var(--color-primary);
font-weight: bold;
min-width: 40px;
}
#weather-desc {
color: var(--color-text-muted);
font-size: 11px;
}
/* === SOVEREIGNTY EASTER EGG === */
#sovereignty-msg {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #ffd700;
font-family: var(--font-body);
font-size: 13px;
letter-spacing: 0.3em;
text-transform: uppercase;
pointer-events: none;
z-index: 30;
border: 1px solid #ffd700;
padding: 8px 20px;
background: rgba(0, 0, 8, 0.7);
white-space: nowrap;
text-align: center;
}
#sovereignty-msg.visible {
display: block;
animation: sovereignty-flash 2.5s ease-out forwards;
}
@keyframes sovereignty-flash {
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.85); }
15% { opacity: 1; transform: translate(-50%, -50%) scale(1.05); }
40% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
}
/* === BITCOIN BLOCK HEIGHT === */
#block-height-display {
position: fixed;
bottom: 12px;
right: 12px;
z-index: 20;
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.15em;
color: var(--color-primary);
background: rgba(0, 0, 8, 0.7);
border: 1px solid var(--color-secondary);
padding: 4px 10px;
pointer-events: none;
white-space: nowrap;
}
.block-height-label {
color: var(--color-text-muted);
margin-right: 6px;
font-size: 10px;
}
#block-height-value {
color: var(--color-primary);
}
#block-height-display.fresh #block-height-value {
animation: block-flash 0.6s ease-out;
}
@keyframes block-flash {
0% { color: #ffffff; text-shadow: 0 0 8px #4488ff; }
100% { color: var(--color-primary); text-shadow: none; }
}
/* === CRT / CYBERPUNK OVERLAY === */
.crt-overlay {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
background:
linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.15) 50%),
linear-gradient(90deg, rgba(255, 0, 0, 0.04), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.04));
background-size: 100% 4px, 4px 100%;
animation: flicker 0.15s infinite;
box-shadow: inset 0 0 100px rgba(0,0,0,0.9);
}
@keyframes flicker {
0% { opacity: 0.95; }
50% { opacity: 1; }
100% { opacity: 0.98; }
}
.crt-overlay::after {
content: " ";
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(18, 16, 16, 0.1);
opacity: 0;
z-index: 999;
pointer-events: none;
animation: crt-pulse 4s linear infinite;
}
@keyframes crt-pulse {
0% { opacity: 0.05; }
50% { opacity: 0.15; }
100% { opacity: 0.05; }
}
/* === THE OATH OVERLAY === */
#oath-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 8, 0.82);
align-items: center;
justify-content: center;
transition: opacity 0.8s ease;
}
#oath-overlay.visible {
display: flex;
animation: oath-fade-in 1.2s ease forwards;
}
@keyframes oath-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
#oath-inner {
max-width: 560px;
width: 90%;
padding: 40px 48px;
border: 1px solid #ffd700;
box-shadow: 0 0 60px rgba(255, 215, 0, 0.15), inset 0 0 40px rgba(255, 215, 0, 0.04);
background: rgba(0, 4, 16, 0.9);
position: relative;
}
#oath-inner::before {
content: '';
position: absolute;
inset: 4px;
border: 1px solid rgba(255, 215, 0, 0.2);
pointer-events: none;
}
#oath-title {
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.5em;
text-transform: uppercase;
color: #ffd700;
margin-bottom: 32px;
text-align: center;
opacity: 0.9;
}
#oath-text {
font-family: var(--font-body);
font-size: 15px;
line-height: 1.9;
color: #e8e8f8;
min-height: 220px;
white-space: pre-wrap;
}
#oath-text .oath-line {
display: block;
#loading-screen.fade-out {
opacity: 0;
transform: translateY(6px);
animation: oath-line-in 0.6s ease forwards;
}
#oath-text .oath-line.blank {
height: 0.8em;
}
@keyframes oath-line-in {
to { opacity: 1; transform: translateY(0); }
}
#oath-hint {
font-family: var(--font-body);
font-size: 10px;
letter-spacing: 0.2em;
color: var(--color-text-muted);
text-align: center;
margin-top: 28px;
text-transform: uppercase;
}
/* === TIME-LAPSE MODE === */
#timelapse-indicator {
display: none;
position: fixed;
bottom: 44px;
left: 50%;
transform: translateX(-50%);
color: #00ffcc;
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.15em;
text-transform: uppercase;
pointer-events: none;
z-index: 20;
border: 1px solid #00ffcc;
padding: 6px 14px 8px;
background: rgba(0, 8, 24, 0.85);
white-space: nowrap;
}
.loader-content {
text-align: center;
}
#timelapse-indicator.visible {
display: flex;
align-items: center;
gap: 6px;
animation: timelapse-glow 1.5s ease-in-out infinite alternate;
.loader-sigil {
margin-bottom: var(--space-6);
}
@keyframes timelapse-glow {
from { box-shadow: 0 0 6px rgba(0, 255, 204, 0.3); }
to { box-shadow: 0 0 16px rgba(0, 255, 204, 0.75); }
.loader-title {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 700;
letter-spacing: 0.3em;
color: var(--color-primary);
text-shadow: 0 0 30px rgba(74, 240, 192, 0.4);
margin-bottom: var(--space-2);
}
.timelapse-label {
color: #00ffcc;
font-size: 11px;
.loader-subtitle {
font-size: var(--text-sm);
color: var(--color-text-muted);
letter-spacing: 0.1em;
margin-bottom: var(--space-6);
}
#timelapse-clock {
color: #ffffff;
font-size: 15px;
font-weight: bold;
min-width: 38px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.timelapse-track {
width: 110px;
height: 4px;
background: rgba(0, 255, 204, 0.18);
border-radius: 2px;
.loader-bar {
width: 200px;
height: 2px;
background: rgba(74, 240, 192, 0.15);
border-radius: 1px;
margin: 0 auto;
overflow: hidden;
}
#timelapse-bar {
.loader-fill {
height: 100%;
background: #00ffcc;
border-radius: 2px;
width: 0%;
transition: width 0.12s linear;
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
border-radius: 1px;
transition: width 0.3s ease;
}
.timelapse-hint {
/* === ENTER PROMPT === */
#enter-prompt {
position: fixed;
inset: 0;
z-index: 500;
background: rgba(5, 5, 16, 0.7);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 0.5s ease;
}
#enter-prompt.fade-out {
opacity: 0;
pointer-events: none;
}
.enter-content {
text-align: center;
}
.enter-content h2 {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-primary);
letter-spacing: 0.2em;
text-shadow: 0 0 20px rgba(74, 240, 192, 0.3);
margin-bottom: var(--space-2);
}
.enter-content p {
font-size: var(--text-sm);
color: var(--color-text-muted);
animation: pulse-text 2s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
/* === GAME UI (HUD) === */
.game-ui {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
font-family: var(--font-body);
color: var(--color-text);
}
.game-ui button, .game-ui input, .game-ui [data-interactive] {
pointer-events: auto;
}
/* Debug overlay */
.hud-debug {
position: absolute;
top: var(--space-3);
left: var(--space-3);
background: rgba(0, 0, 0, 0.7);
color: #0f0;
font-size: var(--text-xs);
line-height: 1.5;
padding: var(--space-2) var(--space-3);
border-radius: 4px;
white-space: pre;
pointer-events: none;
font-variant-numeric: tabular-nums lining-nums;
}
/* Location indicator */
.hud-location {
position: absolute;
top: var(--space-3);
left: 50%;
transform: translateX(-50%);
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: 0.15em;
color: var(--color-primary);
text-shadow: 0 0 10px rgba(74, 240, 192, 0.3);
display: flex;
align-items: center;
gap: var(--space-2);
}
.hud-location-icon {
font-size: 16px;
animation: spin-slow 10s linear infinite;
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Controls hint */
.hud-controls {
position: absolute;
bottom: var(--space-3);
left: var(--space-3);
font-size: var(--text-xs);
color: var(--color-text-muted);
pointer-events: none;
}
.hud-controls span {
color: var(--color-primary);
font-weight: 600;
}
/* === CHAT PANEL === */
.chat-panel {
position: absolute;
bottom: var(--space-4);
right: var(--space-4);
width: 380px;
max-height: 400px;
background: var(--color-surface);
backdrop-filter: blur(var(--panel-blur));
border: 1px solid var(--color-border);
border-radius: var(--panel-radius);
display: flex;
flex-direction: column;
overflow: hidden;
pointer-events: auto;
transition: max-height var(--transition-ui);
}
.chat-panel.collapsed {
max-height: 42px;
}
.chat-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
font-family: var(--font-display);
font-size: var(--text-xs);
letter-spacing: 0.1em;
font-weight: 500;
color: var(--color-text-bright);
cursor: pointer;
flex-shrink: 0;
}
.chat-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 6px var(--color-primary);
animation: dot-pulse 2s ease-in-out infinite;
}
@keyframes dot-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.chat-toggle-btn {
margin-left: auto;
background: none;
border: none;
color: var(--color-text-muted);
font-size: 14px;
cursor: pointer;
transition: transform var(--transition-ui);
}
.chat-panel.collapsed .chat-toggle-btn {
transform: rotate(180deg);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 280px;
scrollbar-width: thin;
scrollbar-color: rgba(74,240,192,0.2) transparent;
}
.chat-msg {
font-size: var(--text-xs);
line-height: 1.6;
padding: var(--space-1) 0;
}
.chat-msg-prefix {
font-weight: 700;
}
.chat-msg-system .chat-msg-prefix { color: var(--color-text-muted); }
.chat-msg-timmy .chat-msg-prefix { color: var(--color-primary); }
.chat-msg-user .chat-msg-prefix { color: var(--color-gold); }
.chat-msg-error .chat-msg-prefix { color: var(--color-danger); }
.chat-input-row {
display: flex;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.chat-input {
flex: 1;
background: transparent;
border: none;
padding: var(--space-3) var(--space-4);
font-family: var(--font-body);
font-size: var(--text-xs);
color: var(--color-text-bright);
outline: none;
}
.chat-input::placeholder {
color: var(--color-text-muted);
}
.chat-send-btn {
background: none;
border: none;
border-left: 1px solid var(--color-border);
padding: var(--space-3) var(--space-4);
color: var(--color-primary);
font-size: 16px;
cursor: pointer;
transition: background var(--transition-ui);
}
.chat-send-btn:hover {
background: rgba(74, 240, 192, 0.1);
}
/* === FOOTER === */
.nexus-footer {
position: fixed;
bottom: var(--space-1);
left: 50%;
transform: translateX(-50%);
z-index: 5;
font-size: 10px;
opacity: 0.3;
}
.nexus-footer a {
color: var(--color-text-muted);
text-decoration: none;
}
.nexus-footer a:hover {
color: var(--color-primary);
}
/* Mobile adjustments */
@media (max-width: 480px) {
.chat-panel {
width: calc(100vw - 32px);
right: var(--space-4);
bottom: var(--space-4);
}
.hud-controls {
display: none;
}
}
/* === OFFLINE INDICATOR === */
#offline-banner {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
padding: 6px 16px;
background: rgba(255, 170, 34, 0.15);
border-bottom: 1px solid var(--color-warning);
color: var(--color-warning);
font-family: var(--font-body);
font-size: var(--text-xs);
text-align: center;
letter-spacing: 0.08em;
}
#timelapse-btn {
margin-left: 8px;
background-color: var(--color-secondary);
color: var(--color-text);
padding: 4px 8px;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
font-family: var(--font-body);
transition: background-color 0.2s ease;
}
#timelapse-btn:hover {
background-color: #00664433;
color: #00ffcc;
}
#timelapse-btn.active {
background-color: rgba(0, 255, 204, 0.15);
color: #00ffcc;
border: 1px solid #00ffcc;
#offline-banner.visible {
display: block;
}

124
sw.js
View File

@@ -1,96 +1,112 @@
// The Nexus — Service Worker
// Cache-first for assets, network-first for API calls
// The Nexus — Service Worker
// Offline-first caching for sovereign space
const CACHE_NAME = 'nexus-v3';
const ASSET_CACHE = 'nexus-assets-v3';
const CACHE_VERSION = 'nexus-v1';
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const CDN_CACHE = `${CACHE_VERSION}-cdn`;
const CORE_ASSETS = [
// Core local assets — always cache
const STATIC_ASSETS = [
'/',
'/index.html',
'/app.js',
'/style.css',
'/app.js',
'/manifest.json',
'/ws-client.js',
'https://unpkg.com/three@0.183.0/build/three.module.js',
'https://unpkg.com/three@0.183.0/examples/jsm/controls/OrbitControls.js',
'https://unpkg.com/three@0.183.0/examples/jsm/postprocessing/EffectComposer.js',
'https://unpkg.com/three@0.183.0/examples/jsm/postprocessing/RenderPass.js',
'https://unpkg.com/three@0.183.0/examples/jsm/postprocessing/UnrealBloomPass.js',
'https://unpkg.com/three@0.183.0/examples/jsm/postprocessing/ShaderPass.js',
'https://unpkg.com/three@0.183.0/examples/jsm/shaders/CopyShader.js',
'https://unpkg.com/three@0.183.0/examples/jsm/shaders/LuminosityHighPassShader.js',
'/icons/icon-192.png',
'/icons/icon-512.png',
];
// Install: precache core assets
self.addEventListener('install', (event) => {
// CDN assets for Three.js — cache on first fetch
const CDN_ORIGINS = [
'https://cdn.jsdelivr.net',
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
];
// ═══ INSTALL — pre-cache static assets ═══
self.addEventListener('install', event => {
event.waitUntil(
caches.open(ASSET_CACHE).then((cache) => cache.addAll(CORE_ASSETS))
caches.open(STATIC_CACHE)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
// ═══ ACTIVATE — clean up old caches ═══
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
caches.keys()
.then(keys => Promise.all(
keys
.filter((key) => key !== CACHE_NAME && key !== ASSET_CACHE)
.map((key) => caches.delete(key))
)
).then(() => self.clients.claim())
.filter(key => key.startsWith('nexus-') && key !== STATIC_CACHE && key !== CDN_CACHE)
.map(key => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
// ═══ FETCH — serve from cache, fall back to network ═══
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// Network-first for API calls (Gitea / WebSocket upgrades / portals.json live data)
if (
url.pathname.startsWith('/api/') ||
url.hostname.includes('143.198.27.163') ||
request.headers.get('Upgrade') === 'websocket'
) {
event.respondWith(networkFirst(request));
// Skip non-GET requests
if (request.method !== 'GET') return;
// CDN resources: cache-first with network fallback
if (CDN_ORIGINS.some(origin => request.url.startsWith(origin))) {
event.respondWith(cdnFirst(request));
return;
}
// Cache-first for everything else (local assets + CDN)
event.respondWith(cacheFirst(request));
// Same-origin static assets: cache-first
if (url.origin === self.location.origin) {
event.respondWith(staticFirst(request));
return;
}
});
async function cacheFirst(request) {
// Cache-first for CDN (Three.js modules, fonts)
async function cdnFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(ASSET_CACHE);
const cache = await caches.open(CDN_CACHE);
cache.put(request, response.clone());
}
return response;
} catch {
// Offline and not cached — return a minimal fallback for navigation
// Offline and not cached — return a minimal error response
return new Response('/* offline */', {
headers: { 'Content-Type': 'text/plain' },
});
}
}
// Cache-first for local static assets
async function staticFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(STATIC_CACHE);
cache.put(request, response.clone());
}
return response;
} catch {
// Offline fallback: serve index.html for navigation requests
if (request.mode === 'navigate') {
const fallback = await caches.match('/index.html');
if (fallback) return fallback;
}
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
return cached || new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
return new Response('Nexus offline — cached assets not found.', {
status: 503,
headers: { 'Content-Type': 'text/plain' },
});
}
}

View File

@@ -1,241 +0,0 @@
#!/usr/bin/env node
/**
* Integration test — Hermes session save and load
*
* Tests the session persistence layer of WebSocketClient in isolation.
* Runs with Node.js built-ins only — no browser, no real WebSocket.
*
* Run: node test-hermes-session.js
*/
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
let passed = 0;
let failed = 0;
function pass(name) {
console.log(`${name}`);
passed++;
}
function fail(name, reason) {
console.log(`${name}`);
if (reason) console.log(`${reason}`);
failed++;
}
function section(name) {
console.log(`\n${name}`);
}
// ── In-memory localStorage mock ─────────────────────────────────────────────
class MockStorage {
constructor() { this._store = new Map(); }
getItem(key) { return this._store.has(key) ? this._store.get(key) : null; }
setItem(key, value) { this._store.set(key, String(value)); }
removeItem(key) { this._store.delete(key); }
clear() { this._store.clear(); }
}
// ── Minimal WebSocketClient extracted from ws-client.js ───────────────────
// We re-implement only the session methods so the test has no browser deps.
const SESSION_STORAGE_KEY = 'hermes-session';
class SessionClient {
constructor(storage) {
this._storage = storage;
this.session = null;
}
saveSession(data) {
const payload = { ...data, savedAt: Date.now() };
this._storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(payload));
this.session = data;
}
loadSession() {
const raw = this._storage.getItem(SESSION_STORAGE_KEY);
if (!raw) return null;
const data = JSON.parse(raw);
this.session = data;
return data;
}
clearSession() {
this._storage.removeItem(SESSION_STORAGE_KEY);
this.session = null;
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
section('Session Save');
const store1 = new MockStorage();
const client1 = new SessionClient(store1);
// saveSession persists to storage
client1.saveSession({ token: 'abc-123', clientId: 'nexus-visitor' });
const raw = store1.getItem(SESSION_STORAGE_KEY);
if (raw) {
pass('saveSession writes to storage');
} else {
fail('saveSession writes to storage', 'storage item is null after save');
}
// Persisted JSON is parseable
try {
const parsed = JSON.parse(raw);
pass('stored value is valid JSON');
if (parsed.token === 'abc-123') {
pass('token field preserved');
} else {
fail('token field preserved', `expected "abc-123", got "${parsed.token}"`);
}
if (parsed.clientId === 'nexus-visitor') {
pass('clientId field preserved');
} else {
fail('clientId field preserved', `expected "nexus-visitor", got "${parsed.clientId}"`);
}
if (typeof parsed.savedAt === 'number' && parsed.savedAt > 0) {
pass('savedAt timestamp present');
} else {
fail('savedAt timestamp present', `got: ${parsed.savedAt}`);
}
} catch (e) {
fail('stored value is valid JSON', e.message);
}
// in-memory session property updated
if (client1.session && client1.session.token === 'abc-123') {
pass('this.session updated after saveSession');
} else {
fail('this.session updated after saveSession', JSON.stringify(client1.session));
}
// ── Session Load ─────────────────────────────────────────────────────────────
section('Session Load');
const store2 = new MockStorage();
const client2 = new SessionClient(store2);
// loadSession on empty storage returns null
const empty = client2.loadSession();
if (empty === null) {
pass('loadSession returns null when no session stored');
} else {
fail('loadSession returns null when no session stored', `got: ${JSON.stringify(empty)}`);
}
// Seed the storage and load
store2.setItem(SESSION_STORAGE_KEY, JSON.stringify({ token: 'xyz-789', clientId: 'timmy', savedAt: 1700000000000 }));
const loaded = client2.loadSession();
if (loaded && loaded.token === 'xyz-789') {
pass('loadSession returns stored token');
} else {
fail('loadSession returns stored token', `got: ${JSON.stringify(loaded)}`);
}
if (loaded && loaded.clientId === 'timmy') {
pass('loadSession returns stored clientId');
} else {
fail('loadSession returns stored clientId', `got: ${JSON.stringify(loaded)}`);
}
if (client2.session && client2.session.token === 'xyz-789') {
pass('this.session updated after loadSession');
} else {
fail('this.session updated after loadSession', JSON.stringify(client2.session));
}
// ── Full save → reload cycle ─────────────────────────────────────────────────
section('Save → Load Round-trip');
const store3 = new MockStorage();
const writer = new SessionClient(store3);
const reader = new SessionClient(store3); // simulates a page reload (new instance, same storage)
writer.saveSession({ token: 'round-trip-token', role: 'visitor' });
const reloaded = reader.loadSession();
if (reloaded && reloaded.token === 'round-trip-token') {
pass('round-trip: token survives save → load');
} else {
fail('round-trip: token survives save → load', JSON.stringify(reloaded));
}
if (reloaded && reloaded.role === 'visitor') {
pass('round-trip: extra fields survive save → load');
} else {
fail('round-trip: extra fields survive save → load', JSON.stringify(reloaded));
}
// ── clearSession ─────────────────────────────────────────────────────────────
section('Session Clear');
const store4 = new MockStorage();
const client4 = new SessionClient(store4);
client4.saveSession({ token: 'to-be-cleared' });
client4.clearSession();
const afterClear = client4.loadSession();
if (afterClear === null) {
pass('clearSession removes stored session');
} else {
fail('clearSession removes stored session', `still got: ${JSON.stringify(afterClear)}`);
}
if (client4.session === null) {
pass('this.session is null after clearSession');
} else {
fail('this.session is null after clearSession', JSON.stringify(client4.session));
}
// ── ws-client.js static check ────────────────────────────────────────────────
section('ws-client.js Session Methods (static analysis)');
const wsClientSrc = (() => {
try { return readFileSync(resolve(__dirname, 'ws-client.js'), 'utf8'); }
catch (e) { fail('ws-client.js readable', e.message); return ''; }
})();
if (wsClientSrc) {
const checks = [
['saveSession method defined', /saveSession\s*\(/],
['loadSession method defined', /loadSession\s*\(/],
['clearSession method defined', /clearSession\s*\(/],
['SESSION_STORAGE_KEY constant', /SESSION_STORAGE_KEY/],
['session-init message handled', /'session-init'/],
['session-resume sent on open', /session-resume/],
['this.session property set', /this\.session\s*=/],
];
for (const [name, re] of checks) {
if (re.test(wsClientSrc)) {
pass(name);
} else {
fail(name, `pattern not found: ${re}`);
}
}
}
// ── Summary ──────────────────────────────────────────────────────────────────
console.log(`\n${'─'.repeat(50)}`);
console.log(`Results: ${passed} passed, ${failed} failed`);
if (failed > 0) {
console.log('\nSome tests failed. Fix the issues above before committing.\n');
process.exit(1);
} else {
console.log('\nAll session tests passed.\n');
}

150
test.js
View File

@@ -1,150 +0,0 @@
#!/usr/bin/env node
/**
* Nexus Test Harness
* Validates the scene loads without errors using only Node.js built-ins.
* Run: node test.js
*/
import { execSync } from 'child_process';
import { readFileSync, statSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
let passed = 0;
let failed = 0;
function pass(name) {
console.log(`${name}`);
passed++;
}
function fail(name, reason) {
console.log(`${name}`);
if (reason) console.log(`${reason}`);
failed++;
}
function section(name) {
console.log(`\n${name}`);
}
// ── Syntax checks ──────────────────────────────────────────────────────────
section('JS Syntax');
for (const file of ['app.js', 'ws-client.js']) {
try {
execSync(`node --check ${resolve(__dirname, file)}`, { stdio: 'pipe' });
pass(`${file} parses without syntax errors`);
} catch (e) {
fail(`${file} syntax check`, e.stderr?.toString().trim() || e.message);
}
}
// ── File size budget ────────────────────────────────────────────────────────
section('File Size Budget (< 500 KB)');
for (const file of ['app.js', 'ws-client.js']) {
try {
const bytes = statSync(resolve(__dirname, file)).size;
const kb = (bytes / 1024).toFixed(1);
if (bytes < 500 * 1024) {
pass(`${file} is ${kb} KB`);
} else {
fail(`${file} exceeds 500 KB budget`, `${kb} KB`);
}
} catch (e) {
fail(`${file} size check`, e.message);
}
}
// ── JSON validation ─────────────────────────────────────────────────────────
section('JSON Files');
for (const file of ['manifest.json', 'portals.json', 'vision.json']) {
try {
const raw = readFileSync(resolve(__dirname, file), 'utf8');
JSON.parse(raw);
pass(`${file} is valid JSON`);
} catch (e) {
fail(`${file}`, e.message);
}
}
// ── HTML structure ──────────────────────────────────────────────────────────
section('HTML Structure (index.html)');
const html = (() => {
try { return readFileSync(resolve(__dirname, 'index.html'), 'utf8'); }
catch (e) { fail('index.html readable', e.message); return ''; }
})();
if (html) {
const checks = [
['DOCTYPE declaration', /<!DOCTYPE html>/i],
['<html lang> attribute', /<html[^>]+lang=/i],
['charset meta tag', /<meta[^>]+charset/i],
['viewport meta tag', /<meta[^>]+viewport/i],
['<title> tag', /<title>[^<]+<\/title>/i],
['importmap script', /<script[^>]+type="importmap"/i],
['three.js in importmap', /"three"\s*:/],
['app.js module script', /<script[^>]+type="module"[^>]+src="app\.js"/i],
['debug-toggle element', /id="debug-toggle"/],
['</html> closing tag', /<\/html>/i],
];
for (const [name, re] of checks) {
if (re.test(html)) {
pass(name);
} else {
fail(name, `pattern not found: ${re}`);
}
}
}
// ── app.js static analysis ──────────────────────────────────────────────────
section('app.js Scene Components');
const appJs = (() => {
try { return readFileSync(resolve(__dirname, 'app.js'), 'utf8'); }
catch (e) { fail('app.js readable', e.message); return ''; }
})();
if (appJs) {
const checks = [
['NEXUS.colors palette defined', /const NEXUS\s*=\s*\{/],
['THREE.Scene created', /new THREE\.Scene\(\)/],
['THREE.PerspectiveCamera created', /new THREE\.PerspectiveCamera\(/],
['THREE.WebGLRenderer created', /new THREE\.WebGLRenderer\(/],
['renderer appended to DOM', /document\.body\.appendChild\(renderer\.domElement\)/],
['animate function defined', /function animate\s*\(\)/],
['requestAnimationFrame called', /requestAnimationFrame\(animate\)/],
['renderer.render called', /renderer\.render\(scene,\s*camera\)/],
['resize handler registered', /addEventListener\(['"]resize['"]/],
['clock defined', /new THREE\.Clock\(\)/],
['star field created', /new THREE\.Points\(/],
['constellation lines built', /buildConstellationLines/],
['ws-client imported', /import.*ws-client/],
['wsClient.connect called', /wsClient\.connect\(\)/],
];
for (const [name, re] of checks) {
if (re.test(appJs)) {
pass(name);
} else {
fail(name, `pattern not found: ${re}`);
}
}
}
// ── Summary ─────────────────────────────────────────────────────────────────
console.log(`\n${'─'.repeat(50)}`);
console.log(`Results: ${passed} passed, ${failed} failed`);
if (failed > 0) {
console.log('\nSome tests failed. Fix the issues above before committing.\n');
process.exit(1);
} else {
console.log('\nAll tests passed.\n');
}

View File

@@ -1,288 +0,0 @@
/**
* ws-client.js — Hermes Gateway WebSocket Client
*
* Manages the persistent WebSocket connection between the Nexus (browser) and
* the Hermes agent gateway. Hermes is the sovereign orchestration layer that
* routes AI provider responses, Gitea PR events, visitor presence, and chat
* messages into the 3D world.
*
* ## Provider Fallback Chain
*
* The Hermes gateway itself manages provider selection (Claude → Gemini →
* Perplexity → fallback). From the Nexus client's perspective, all providers
* arrive through the single WebSocket endpoint below. The client's
* responsibility is to stay connected so no events are dropped.
*
* Connection lifecycle:
*
* 1. connect() — opens WebSocket to HERMES_WS_URL
* 2. onopen — flushes any queued messages; fires 'ws-connected'
* 3. onmessage — JSON-parses frames; dispatches typed CustomEvents
* 4. onclose / onerror — fires 'ws-disconnected'; triggers _scheduleReconnect()
* 5. _scheduleReconnect — exponential backoff (1s → 2s → 4s … ≤ 30s) up to
* 10 attempts, then fires 'ws-failed' and gives up
*
* Message queue: messages sent while disconnected are buffered in
* `this.messageQueue` and flushed on the next successful connection.
*
* ## Dispatched CustomEvents
*
* | type | CustomEvent name | Payload (event.detail) |
* |-------------------|--------------------|------------------------------------|
* | chat / chat-message | chat-message | { type, text, sender?, … } |
* | status-update | status-update | { type, status, agent?, … } |
* | pr-notification | pr-notification | { type, action, pr, … } |
* | player-joined | player-joined | { type, id, name?, … } |
* | player-left | player-left | { type, id, … } |
* | (connection) | ws-connected | { url } |
* | (connection) | ws-disconnected | { code } |
* | (terminal) | ws-failed | — |
*/
/** Primary Hermes gateway endpoint. */
const HERMES_WS_URL = 'ws://143.198.27.163/api/world/ws';
const SESSION_STORAGE_KEY = 'hermes-session';
/**
* WebSocketClient — resilient WebSocket wrapper with exponential-backoff
* reconnection and an outbound message queue.
*/
export class WebSocketClient {
/**
* @param {string} [url] - WebSocket endpoint (defaults to HERMES_WS_URL)
*/
constructor(url = HERMES_WS_URL) {
this.url = url;
/** Number of reconnect attempts since last successful connection. */
this.reconnectAttempts = 0;
/** Hard cap on reconnect attempts before emitting 'ws-failed'. */
this.maxReconnectAttempts = 10;
/** Initial backoff delay in ms (doubles each attempt). */
this.reconnectBaseDelay = 1000;
/** Maximum backoff delay in ms. */
this.maxReconnectDelay = 30000;
/** @type {WebSocket|null} */
this.socket = null;
this.connected = false;
/** @type {ReturnType<typeof setTimeout>|null} */
this.reconnectTimeout = null;
/** Messages queued while disconnected; flushed on reconnect. */
this.messageQueue = [];
this.session = null;
}
/**
* Persist session data to localStorage so it survives page reloads.
* @param {Object} data Arbitrary session payload (token, id, etc.)
*/
saveSession(data) {
try {
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ ...data, savedAt: Date.now() }));
this.session = data;
console.log('[hermes] Session saved');
} catch (err) {
console.warn('[hermes] Could not save session:', err);
}
}
/**
* Restore session data from localStorage.
* @returns {Object|null} Previously saved session, or null if none.
*/
loadSession() {
try {
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) return null;
const data = JSON.parse(raw);
this.session = data;
console.log('[hermes] Session loaded (savedAt:', new Date(data.savedAt).toISOString(), ')');
return data;
} catch (err) {
console.warn('[hermes] Could not load session:', err);
return null;
}
}
/**
* Remove any persisted session from localStorage.
*/
clearSession() {
try {
localStorage.removeItem(SESSION_STORAGE_KEY);
this.session = null;
console.log('[hermes] Session cleared');
} catch (err) {
console.warn('[hermes] Could not clear session:', err);
}
}
/**
* Open the WebSocket connection. No-ops if already open or connecting.
*/
connect() {
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
return;
}
try {
this.socket = new WebSocket(this.url);
} catch (err) {
console.error('[hermes] WebSocket construction failed:', err);
this._scheduleReconnect();
return;
}
this.socket.onopen = () => {
console.log('[hermes] Connected to Hermes gateway');
this.connected = true;
this.reconnectAttempts = 0;
// Restore session if available; send it as the first frame so the server
// can resume the previous session rather than creating a new one.
const existing = this.loadSession();
if (existing?.token) {
this._send({ type: 'session-resume', token: existing.token });
}
this.messageQueue.forEach(msg => this._send(msg));
this.messageQueue = [];
window.dispatchEvent(new CustomEvent('ws-connected', { detail: { url: this.url } }));
};
this.socket.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch (err) {
console.warn('[hermes] Unparseable message:', event.data);
return;
}
this._route(data);
};
this.socket.onclose = (event) => {
this.connected = false;
this.socket = null;
console.warn(`[hermes] Connection closed (code=${event.code})`);
window.dispatchEvent(new CustomEvent('ws-disconnected', { detail: { code: event.code } }));
this._scheduleReconnect();
};
this.socket.onerror = () => {
// onclose fires after onerror; logging here would be redundant noise
console.warn('[hermes] WebSocket error — waiting for close event');
};
}
/**
* Route an inbound Hermes message to the appropriate CustomEvent.
* Unrecognised types are logged at debug level and dropped.
*
* @param {{ type: string, [key: string]: unknown }} data
*/
_route(data) {
switch (data.type) {
case 'session-init':
// Server issued a new session token — persist it for future reconnects.
if (data.token) {
this.saveSession({ token: data.token, clientId: data.clientId });
}
window.dispatchEvent(new CustomEvent('session-init', { detail: data }));
break;
case 'chat':
case 'chat-message':
window.dispatchEvent(new CustomEvent('chat-message', { detail: data }));
break;
case 'status-update':
window.dispatchEvent(new CustomEvent('status-update', { detail: data }));
break;
case 'pr-notification':
window.dispatchEvent(new CustomEvent('pr-notification', { detail: data }));
break;
case 'player-joined':
window.dispatchEvent(new CustomEvent('player-joined', { detail: data }));
break;
case 'player-left':
window.dispatchEvent(new CustomEvent('player-left', { detail: data }));
break;
default:
console.debug('[hermes] Unhandled message type:', data.type, data);
}
}
/**
* Schedule the next reconnect attempt using exponential backoff.
*
* Backoff schedule (base 1 s, cap 30 s):
* attempt 1 → 1 s
* attempt 2 → 2 s
* attempt 3 → 4 s
* attempt 4 → 8 s
* attempt 5 → 16 s
* attempt 6+ → 30 s (capped)
*
* After maxReconnectAttempts the client emits 'ws-failed' and stops trying.
*/
_scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.warn('[hermes] Max reconnection attempts reached — giving up');
window.dispatchEvent(new CustomEvent('ws-failed'));
return;
}
const delay = Math.min(
this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay
);
console.log(`[hermes] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})`);
this.reconnectTimeout = setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
/**
* Low-level send — caller must ensure socket is open.
* @param {object} message
*/
_send(message) {
this.socket.send(JSON.stringify(message));
}
/**
* Send a message to Hermes. If not currently connected the message is
* buffered and will be delivered on the next successful connection.
*
* @param {object} message
*/
send(message) {
if (this.connected && this.socket && this.socket.readyState === WebSocket.OPEN) {
this._send(message);
} else {
this.messageQueue.push(message);
}
}
/**
* Intentionally close the connection and cancel any pending reconnect.
* After calling disconnect() the client will not attempt to reconnect.
*/
disconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
this.maxReconnectAttempts = 0; // prevent auto-reconnect after intentional disconnect
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
}
/** Shared singleton WebSocket client — imported by app.js. */
export const wsClient = new WebSocketClient();