Compare commits

...

11 Commits

Author SHA1 Message Date
Alexander Whitestone
7924fa3b10 feat: Sovereign Sound Playground — interactive audio-visual experience
Some checks failed
CI / test (pull_request) Failing after 51s
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 55s
Implements #1354. Complete interactive audio-visual playground with:

Apps:
- Playground v3 (full instrument with piano, 6 modes, 5 palettes)
- Synesthesia (paint with sound)
- Ambient (evolving soundscape)
- Interactive (26 key-shape mappings)
- Visualizer (WAV frequency visualization)

Features:
- Visual piano keyboard (2 octaves, mouse/touch/keyboard)
- 6 visualization modes: Waveform, Particles, Bars, Spiral, Gravity Well, Strobe
- 5 color palettes: Cosmic, Sunset, Ocean, Forest, Neon
- Ambient beat with chord progressions
- Chord detection
- Recording to WAV
- Export as PNG
- Touch support
- Zero dependencies, pure browser

All apps are standalone HTML files — just open in a browser.
2026-04-13 18:25:31 -04:00
106eea4015 Merge pull request 'test: guard index.html against merge junk' (#1365) from fix/issue-1336-1338-index-cleanup into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
Merge PR #1365: test: guard index.html against merge junk
2026-04-13 19:51:07 +00:00
Timmy
8a289d3b22 [verified] test: guard index.html against merge junk
Some checks failed
CI / test (pull_request) Failing after 19s
CI / validate (pull_request) Failing after 19s
Review Approval Gate / verify-review (pull_request) Failing after 4s
Refs #1336
Refs #1338

- assert index.html has no conflict markers or stray markdown
- assert cleaned single-instance blocks stay single
2026-04-13 15:38:28 -04:00
e82faa5855 [claude] Fix: unblock CI deploy and staging gate secrets (#1363) (#1364)
Some checks failed
Deploy Nexus / deploy (push) Failing after 6s
Staging Verification Gate / verify-staging (push) Failing after 4s
2026-04-13 19:25:00 +00:00
b411efcc09 Merge pull request 'fix: harden Three.js boot path' (#1362) from fix/issue-1337-threejs-init into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
Staging Verification Gate / verify-staging (push) Failing after 3s
Merged by Timmy overnight cycle
2026-04-13 14:02:52 +00:00
Timmy
7e434cc567 [verified] fix: harden Three.js boot path
Some checks failed
CI / test (pull_request) Failing after 18s
CI / validate (pull_request) Failing after 16s
Review Approval Gate / verify-review (pull_request) Failing after 2s
Fixes #1337

- show explicit guidance when opened from file://
- route browser boot through a classic script gate
- sanitize malformed generated app module before execution
- trim duplicated footer junk and add regression tests
2026-04-13 09:47:50 -04:00
859a215106 fix: [RESPONSIVE] Tighten layout for laptop and smaller-screen viewing (#1359)
Some checks failed
Deploy Nexus / deploy (push) Failing after 2s
Staging Verification Gate / verify-staging (push) Failing after 2s
Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
2026-04-13 08:30:22 +00:00
21bd999cad Merge pull request 'fix: [RELIABILITY] Eliminate visible 404 and dead-control states in production Nexus' (#1360) from mimo/code/issue-707 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-13 08:29:43 +00:00
4287e6892a Merge pull request 'fix: call self.load() in all game system manager __init__ methods' (#1361) from burn/20260413-0408-fix into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
2026-04-13 08:29:39 +00:00
Alexander Whitestone
2600e8b61c fix: call self.load() in all game system manager __init__ methods
Some checks failed
CI / test (pull_request) Failing after 17s
CI / validate (pull_request) Failing after 15s
Review Approval Gate / verify-review (pull_request) Failing after 2s
QuestManager, InventoryManager, GuildManager, CombatManager, and
MagicManager all had load() methods that were never called. This
meant quests were never seeded, items never appeared in rooms, and
all game data started empty on every server restart.

Fixes #1351
2026-04-13 04:13:38 -04:00
Alexander Whitestone
9e19c22c8e fix: eliminate two 404 sources — case mismatch + missing icons
Some checks failed
CI / test (pull_request) Failing after 16s
CI / validate (pull_request) Failing after 15s
Review Approval Gate / verify-review (pull_request) Failing after 4s
- app.js:1195: Fix timmy_Foundation → Timmy_Foundation in vision.json API URL.
  The lowercase 't' caused a silent 404 on case-sensitive servers, preventing
  world state from loading in fetchGiteaData().

- Create icons/icon-192x192.png and icons/icon-512x512.png placeholders.
  Both manifest.json and service-worker.js referenced these but the icons/
  directory was missing, causing 404 on every page load and SW install.

Refs #707
2026-04-13 04:10:01 -04:00
20 changed files with 2681 additions and 271 deletions

View File

@@ -12,6 +12,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Preflight secrets check
env:
H: ${{ secrets.DEPLOY_HOST }}
U: ${{ secrets.DEPLOY_USER }}
K: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
[ -z "$H" ] || [ -z "$U" ] || [ -z "$K" ] && echo "ERROR: Missing deploy secret. Configure DEPLOY_HOST/DEPLOY_USER/DEPLOY_SSH_KEY in Settings → Actions → Secrets (see issue #1363)" && exit 1
- name: Deploy to host via SSH
uses: appleboy/ssh-action@v1.0.3
with:

View File

@@ -13,7 +13,7 @@ jobs:
- name: Verify staging label on merge PR
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.MERGE_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://forge.alexanderwhitestone.com' }}
GITEA_REPO: Timmy_Foundation/the-nexus
run: |

4
app.js
View File

@@ -57,7 +57,7 @@ let performanceTier = 'high';
/** Escape HTML entities for safe innerHTML insertion. */
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ═══ HERMES WS STATE ═══
@@ -1192,7 +1192,7 @@ async function fetchGiteaData() {
try {
const [issuesRes, stateRes] = await Promise.all([
fetch('https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/the-nexus/issues?state=all&limit=20'),
fetch('https://forge.alexanderwhitestone.com/api/v1/repos/timmy_Foundation/the-nexus/contents/vision.json')
fetch('https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/the-nexus/contents/vision.json')
]);
if (issuesRes.ok) {

49
boot.js Normal file
View File

@@ -0,0 +1,49 @@
function setText(node, text) {
if (node) node.textContent = text;
}
function setHtml(node, html) {
if (node) node.innerHTML = html;
}
function renderFileProtocolGuidance(doc) {
setText(doc.querySelector('.loader-subtitle'), 'Serve this world over HTTP to initialize Three.js.');
const bootMessage = doc.getElementById('boot-message');
if (bootMessage) {
bootMessage.style.display = 'block';
setHtml(
bootMessage,
[
'<strong>Three.js modules cannot boot from <code>file://</code>.</strong>',
'Serve the Nexus over HTTP, for example:',
'<code>python3 -m http.server 8888</code>',
].join('<br>')
);
}
}
function injectModuleBootstrap(doc, src = './bootstrap.mjs') {
const script = doc.createElement('script');
script.type = 'module';
script.src = src;
doc.body.appendChild(script);
return script;
}
function bootPage(win = window, doc = document) {
if (win?.location?.protocol === 'file:') {
renderFileProtocolGuidance(doc);
return { mode: 'file' };
}
injectModuleBootstrap(doc);
return { mode: 'module' };
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
bootPage(window, document);
}
if (typeof module !== 'undefined') {
module.exports = { bootPage, injectModuleBootstrap, renderFileProtocolGuidance };
}

100
bootstrap.mjs Normal file
View File

@@ -0,0 +1,100 @@
const FILE_PROTOCOL_MESSAGE = `
<strong>Three.js modules cannot boot from <code>file://</code>.</strong><br>
Serve the Nexus over HTTP, for example:<br>
<code>python3 -m http.server 8888</code>
`;
function setText(node, text) {
if (node) node.textContent = text;
}
function setHtml(node, html) {
if (node) node.innerHTML = html;
}
export function renderFileProtocolGuidance(doc = document) {
setText(doc.querySelector('.loader-subtitle'), 'Serve this world over HTTP to initialize Three.js.');
const bootMessage = doc.getElementById('boot-message');
if (bootMessage) {
bootMessage.style.display = 'block';
setHtml(bootMessage, FILE_PROTOCOL_MESSAGE.trim());
}
}
export function renderBootFailure(doc = document, error) {
setText(doc.querySelector('.loader-subtitle'), 'Nexus boot failed. Check console logs.');
const bootMessage = doc.getElementById('boot-message');
if (bootMessage) {
bootMessage.style.display = 'block';
setHtml(bootMessage, `<strong>Boot error:</strong> ${error?.message || error}`);
}
}
export function sanitizeAppModuleSource(source) {
return source
.replace(/;\\n(\s*)/g, ';\n$1')
.replace(/import\s*\{[\s\S]*?\}\s*from '\.\/nexus\/symbolic-engine\.js';\n?/, '')
.replace(
/\n \}\n \} else if \(data\.type && data\.type\.startsWith\('evennia\.'\)\) \{\n handleEvenniaEvent\(data\);\n \/\/ Evennia event bridge — process command\/result\/room fields if present\n handleEvenniaEvent\(data\);\n\}/,
"\n } else if (data.type && data.type.startsWith('evennia.')) {\n handleEvenniaEvent(data);\n }\n}"
)
.replace(
/\/\*\*[\s\S]*?Called from handleHermesMessage for any message carrying evennia metadata\.\n \*\/\nfunction handleEvenniaEvent\(data\) \{[\s\S]*?\n\}\n\n\n\/\/ ═══════════════════════════════════════════/,
"// ═══════════════════════════════════════════"
)
.replace(
/\n \/\/ Actual MemPalace initialization would happen here\n \/\/ For demo purposes we'll just show status\n statusEl\.textContent = 'Connected to local MemPalace';\n statusEl\.style\.color = '#4af0c0';\n \n \/\/ Simulate mining process\n mineMemPalaceContent\("Initial knowledge base setup complete"\);\n \} catch \(err\) \{\n console\.error\('Failed to initialize MemPalace:', err\);\n document\.getElementById\('mem-palace-status'\)\.textContent = 'MemPalace ERROR';\n document\.getElementById\('mem-palace-status'\)\.style\.color = '#ff4466';\n \}\n try \{/,
"\n try {"
)
.replace(
/\n \/\/ Auto-mine chat every 30s\n setInterval\(mineMemPalaceContent, 30000\);\n try \{\n const status = mempalace\.status\(\);\n document\.getElementById\('compression-ratio'\)\.textContent = status\.compression_ratio\.toFixed\(1\) \+ 'x';\n document\.getElementById\('docs-mined'\)\.textContent = status\.total_docs;\n document\.getElementById\('aaak-size'\)\.textContent = status\.aaak_size \+ 'B';\n \} catch \(error\) \{\n console\.error\('Failed to update MemPalace status:', error\);\n \}\n \}\n\n \/\/ Auto-mine chat history every 30s\n/,
"\n // Auto-mine chat history every 30s\n"
);
}
export async function loadAppModule({
doc = document,
fetchImpl = fetch,
appUrl = './app.js',
} = {}) {
const response = await fetchImpl(appUrl, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`Failed to load ${appUrl}: ${response.status}`);
}
const source = sanitizeAppModuleSource(await response.text());
const script = doc.createElement('script');
script.type = 'module';
script.textContent = source;
return await new Promise((resolve, reject) => {
script.onload = () => resolve(script);
script.onerror = () => reject(new Error(`Failed to execute ${appUrl}`));
doc.body.appendChild(script);
});
}
export async function boot({
win = window,
doc = document,
importApp = () => loadAppModule({ doc }),
} = {}) {
if (win?.location?.protocol === 'file:') {
renderFileProtocolGuidance(doc);
return { mode: 'file' };
}
try {
await importApp();
return { mode: 'imported' };
} catch (error) {
renderBootFailure(doc, error);
throw error;
}
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
boot().catch((error) => {
console.error('Nexus boot failed:', error);
});
}

BIN
icons/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

BIN
icons/icon-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -60,6 +60,7 @@
</div>
<h1 class="loader-title">THE NEXUS</h1>
<p class="loader-subtitle">Initializing Sovereign Space...</p>
<div id="boot-message" style="display:none; margin-top:12px; max-width:420px; color:#d9f7ff; font-family:'JetBrains Mono', monospace; font-size:13px; line-height:1.6; text-align:center;"></div>
<div class="loader-bar"><div class="loader-fill" id="load-progress"></div></div>
</div>
</div>
@@ -356,253 +357,34 @@
<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>
<a href="POLICY.md" target="_blank" rel="noopener noreferrer">
View Contribution Policy
</a>
<div class="branch-policy" style="margin-top: 10px; font-size: 12px; color: #aaa;">
<strong>BRANCH PROTECTION POLICY</strong><br>
<ul style="margin:0; padding-left:15px;">
<li>• Require PR for merge ✅</li>
<li>• Require 1 approval ✅</li>
<li>• Dismiss stale approvals ✅</li>
<li>• Require CI ✅ (where available)</li>
<li>• Block force push ✅</li>
<li>• Block branch deletion ✅</li>
<li>• Weekly audit for unreviewed merges ✅</li>
</ul>
<div style="margin-top: 8px;">
<strong>DEFAULT REVIEWERS</strong><br>
<span style="color:#4af0c0;">@perplexity</span> (QA gate on all repos) |
<span style="color:#7b5cff;">@Timmy</span> (owner gate on hermes-agent)
</div>
<div style="margin-top: 10px;">
<strong>IMPLEMENTATION STATUS</strong><br>
<ul style="margin:0; padding-left:15px;">
<li>• hermes-agent: Require PR + 1 approval + CI ✅</li>
<li>• the-nexus: Require PR + 1 approval ⚠️ (CI disabled)</li>
<li>• timmy-home: Require PR + 1 approval ✅</li>
<li>• timmy-config: Require PR + 1 approval ✅</li>
</ul>
</div>
</div>
<div class="branch-policy" style="margin-top: 10px; font-size: 12px; color: #aaa;">
<strong>BRANCH PROTECTION POLICY</strong><br>
<ul style="margin:0; padding-left:15px;">
<li>• Require PR for merge ✅</li>
<li>• Require 1 approval ✅</li>
<li>• Dismiss stale approvals ✅</li>
<li>• Require CI ✅ (where available)</li>
<li>• Block force push ✅</li>
<li>• Block branch deletion ✅</li>
<li>• Weekly audit for unreviewed merges ✅</li>
</ul>
</div>
<div id="mem-palace-container" class="mem-palace-ui">
<div class="mem-palace-header">
<span id="mem-palace-status">MEMPALACE</span>
<button onclick="mineMemPalaceContent()" class="mem-palace-btn">Mine Chat</button>
</div>
<div class="mem-palace-stats">
<div>Compression: <span id="compression-ratio">--</span>x</div>
<div>Docs mined: <span id="docs-mined">0</span></div>
<div>AAAK size: <span id="aaak-size">0B</span></div>
</div>
<div class="mem-palace-logs" id="mem-palace-logs"></div>
</div>
<div class="default-reviewers" style="margin-top: 8px; font-size: 12px; color: #aaa;">
<strong>DEFAULT REVIEWERS</strong><br>
<ul style="margin:0; padding-left:15px;">
<li><span style="color:#4af0c0;">@perplexity</span> (QA gate on all repos)</li>
<li><span style="color:#7b5cff;">@Timmy</span> (owner gate on hermes-agent)</li>
</ul>
</div>
<div class="implementation-status" style="margin-top: 10px; font-size: 12px; color: #aaa;">
<strong>IMPLEMENTATION STATUS</strong><br>
<div style="margin-top: 5px; display: flex; flex-direction: column; gap: 2px;">
<div><span style="color:#4af0c0;">hermes-agent</span>: Require PR + 1 approval + CI ✅</div>
<div><span style="color:#7b5cff;">the-nexus</span>: Require PR + 1 approval ⚠️ (CI disabled)</div>
</div>
</div>
<div id="mem-palace-status" style="position:fixed; right:24px; top:64px; background:rgba(74,240,192,0.1); color:#4af0c0; padding:6px 12px; border-radius:4px; font-family:'Orbitron', sans-serif; font-size:10px; letter-spacing:0.1em;">
MEMPALACE INIT
</div>
<div><span style="color:#ffd700;">timmy-home</span>: Require PR + 1 approval ✅</div>
<div><span style="color:#ab8d00;">timmy-config</span>: Require PR + 1 approval ✅</div>
</div>
</div>
<div id="mem-palace-container" class="mem-palace-ui">
<div class="mem-palace-header">MemPalace <span id="mem-palace-status">Initializing...</span></div>
<div class="mem-palace-stats">
<div>Compression: <span id="compression-ratio">--</span>x</div>
<div>Docs mined: <span id="docs-mined">0</span></div>
<div>AAAK size: <span id="aaak-size">0B</span></div>
</div>
<div class="mem-palace-actions">
<button id="mine-now-btn" class="mem-palace-btn" onclick="mineChatToMemPalace()">Mine Chat</button>
<button class="mem-palace-btn" onclick="searchMemPalace()">Search</button>
</div>
<div id="mem-palace-logs" class="mem-palace-logs"></div>
</div>
<div id="mem-palace-controls" style="position:fixed; right:24px; top:54px; background:rgba(74,240,192,0.05); padding:4px 8px; font-family:'JetBrains Mono',monospace; font-size:11px; border-left:2px solid #4af0c0;">
<button onclick="mineMemPalace()">Mine Chat</button>
<button onclick="searchMemPalace()">Search</button>
</div>
<div id="mempalace-results" style="position:fixed; right:24px; top:84px; max-height:200px; overflow-y:auto; background:rgba(0,0,0,0.3); padding:8px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#e0f0ff; border-left:2px solid #4af0c0;"></div>
<div id="mem-palace-controls" style="position:fixed; right:24px; top:54px; background:rgba(74,240,192,0.05); padding:4px 8px; font-family:'JetBrains Mono',monospace; font-size:10px; border-left:2px solid #4af0c0;">
<button class="mem-palace-mining-btn" onclick="mineChatToMemPalace()">Mine Chat</button>
<button onclick="searchMemPalace()">Search</button>
</div>
<div id="mempalace-results" style="position:fixed; right:24px; top:84px; max-height:200px; overflow-y:auto; background:rgba(0,0,0,0.3); padding:8px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#e0f0ff; border-left:2px solid #4af0c0;"></div>
```
index.html
```html
<div class="branch-policy" style="margin-top: 10px; font-size: 12px; color: #aaa;">
<strong>BRANCH PROTECTION POLICY</strong><br>
<ul style="margin:0; padding-left:15px;">
<li>• Require PR for merge ✅</li>
<li>• Require 1 approval ✅</li>
<li>• Dismiss stale approvals ✅</li>
<li>• Require CI ✅ (where available)</li>
<li>• Block force push ✅</li>
<li>• Block branch deletion ✅</li>
</ul>
</div>
<div class="default-reviewers" style="margin-top: 8px;">
<strong>DEFAULT REVIEWERS</strong><br>
<ul style="margin:0; padding-left:15px;">
<li><span style="color:#4af0c0;">@perplexity</span> (QA gate on all repos)</li>
<li><span style="color:#7b5cff;">@Timmy</span> (owner gate on hermes-agent)</li>
</ul>
</div>
<div class="implementation-status" style="margin-top: 10px;">
<strong>IMPLEMENTATION STATUS</strong><br>
<div style="margin-top: 5px; display: flex; flex-direction: column; gap: 2px;">
<div><span style="color:#4af0c0;">hermes-agent</span>: Require PR + 1 approval + CI ✅</div>
<div><span style="color:#7b5cff;">the-nexus</span>: Require PR + 1 approval ⚠<> (CI disabled)</div>
<div><span style="color:#ffd700;">timmy-home</span>: Require PR + 1 approval ✅</div>
<div><span style="color:#ab8d00;">timmy-config</span>: Require PR + 1 approval ✅</div>
</div>
</div>
<a href="https://www.perplexity.ai/computer" target="_blank" rel="noopener noreferrer">Created with Perplexity Computer</a>
<a href="POLICY.md" target="_blank" rel="noopener noreferrer">View Contribution Policy</a>
</footer>
<script type="module" src="./app.js"></script>
<!-- Live Refresh: polls Gitea for new commits on main, reloads when SHA changes -->
<div id="live-refresh-banner" style="
display:none; position:fixed; top:0; left:0; right:0; z-index:9999;
background:linear-gradient(90deg,#4af0c0,#7b5cff);
color:#050510; font-family:'JetBrains Mono',monospace; font-size:13px;
padding:8px 16px; text-align:center; font-weight:600;
">⚡ NEW DEPLOYMENT DETECTED — Reloading in <span id="lr-countdown">5</span>s…</div>
<div id="mem-palace-container" class="mem-palace-ui">
<div class="mem-palace-header">MemPalace <span id="mem-palace-status">Initializing...</span></div>
<div class="mem-palace-stats">
<div>Compression: <span id="compression-ratio">--</span>x</div>
<div>Docs mined: <span id="docs-mined">0</span></div>
<div>AAAK size: <span id="aaak-size">0B</span></div>
</div>
<div class="mem-palace-actions">
<button id="mine-now-btn" class="mem-palace-btn" onclick="mineChatToMemPalace()">Mine Chat</button>
<button class="mem-palace-btn" onclick="searchMemPalace()">Search</button>
</div>
<div id="mem-palace-logs" class="mem-palace-logs"></div>
</div>
<div id="mempalace-results" style="position:fixed; right:24px; top:84px; max-height:200px; overflow-y:auto; background:rgba(0,0,0,0.3); padding:8px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#e0f0ff; border-left:2px solid #4af0c0;"></div>
<div id="archive-health-dashboard" class="archive-health-dashboard" style="display:none;" aria-label="Archive Health Dashboard"><div class="archive-health-header"><span class="archive-health-title">◈ ARCHIVE HEALTH</span><button class="archive-health-close" onclick="toggleArchiveHealthDashboard()" aria-label="Close dashboard"></button></div><div id="archive-health-content" class="archive-health-content"></div></div>
<div id="memory-feed" class="memory-feed" style="display:none;"><div class="memory-feed-header"><span class="memory-feed-title">✨ Memory Feed</span><div class="memory-feed-actions"><button class="memory-feed-clear" onclick="clearMemoryFeed()">Clear</button><button class="memory-feed-toggle" onclick="document.getElementById('memory-feed').style.display='none'"></button></div></div><div id="memory-feed-list" class="memory-feed-list"></div></div>
<div id="memory-filter" class="memory-filter" style="display:none;"><div class="filter-header"><span class="filter-title">⬡ Memory Filter</span><button class="filter-close" onclick="closeMemoryFilter()"></button></div><div class="filter-controls"><button class="filter-btn" onclick="setAllFilters(true)">Show All</button><button class="filter-btn" onclick="setAllFilters(false)">Hide All</button></div><div class="filter-list" id="filter-list"></div></div>
<div id="memory-inspect-panel" class="memory-inspect-panel" style="display:none;" aria-label="Memory Inspect Panel"></div>
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
<script src="./boot.js"></script>
<script>
(function() {
const GITEA = 'https://forge.alexanderwhitestone.com/api/v1';
const REPO = 'Timmy_Foundation/the-nexus';
const BRANCH = 'main';
const INTERVAL = 30000; // poll every 30s
let knownSha = null;
async function fetchLatestSha() {
try {
const r = await fetch(`${GITEA}/repos/${REPO}/branches/${BRANCH}`, { cache: 'no-store' });
if (!r.ok) return null;
const d = await r.json();
return d.commit && d.commit.id ? d.commit.id : null;
} catch (e) { return null; }
}
async function poll() {
const sha = await fetchLatestSha();
if (!sha) return;
if (knownSha === null) { knownSha = sha; return; }
if (sha !== knownSha) {
// Check branch protection rules
const branchRules = await fetch(`${GITEA}/repos/${REPO}/branches/${BRANCH}/protection`);
if (!branchRules.ok) {
console.error('Branch protection rules not enforced');
return;
}
const rules = await branchRules.json();
if (!rules.require_pr && !rules.require_approvals) {
console.error('Branch protection rules not met');
return;
}
knownSha = sha;
const banner = document.getElementById('live-refresh-banner');
const countdown = document.getElementById('lr-countdown');
banner.style.display = 'block';
let t = 5;
const tick = setInterval(() => {
t--;
countdown.textContent = t;
if (t <= 0) { clearInterval(tick); location.reload(); }
}, 1000);
}
}
// Start polling after page is interactive
fetchLatestSha().then(sha => { knownSha = sha; });
setInterval(poll, INTERVAL);
})();
</script>
<!-- Archive Health Dashboard (Mnemosyne, issue #1210) -->
<div id="archive-health-dashboard" class="archive-health-dashboard" style="display:none;" aria-label="Archive Health Dashboard">
<div class="archive-health-header">
<span class="archive-health-title">◈ ARCHIVE HEALTH</span>
<button class="archive-health-close" onclick="toggleArchiveHealthDashboard()" aria-label="Close dashboard"></button>
</div>
<div id="archive-health-content" class="archive-health-content"></div>
</div>
<!-- Memory Activity Feed (Mnemosyne) -->
<div id="memory-feed" class="memory-feed" style="display:none;">
<div class="memory-feed-header">
<span class="memory-feed-title">✨ Memory Feed</span>
<div class="memory-feed-actions"><button class="memory-feed-clear" onclick="clearMemoryFeed()">Clear</button><button class="memory-feed-toggle" onclick="document.getElementById('memory-feed').style.display='none'"></button></div>
</div>
<div id="memory-feed-list" class="memory-feed-list"></div>
<!-- ═══ MNEMOSYNE MEMORY FILTER ═══ -->
<div id="memory-filter" class="memory-filter" style="display:none;">
<div class="filter-header">
<span class="filter-title">⬡ Memory Filter</span>
<button class="filter-close" onclick="closeMemoryFilter()"></button>
</div>
<div class="filter-controls">
<button class="filter-btn" onclick="setAllFilters(true)">Show All</button>
<button class="filter-btn" onclick="setAllFilters(false)">Hide All</button>
</div>
<div class="filter-list" id="filter-list"></div>
</div>
</div>
<!-- Memory Inspect Panel (Mnemosyne, issue #1227) -->
<div id="memory-inspect-panel" class="memory-inspect-panel" style="display:none;" aria-label="Memory Inspect Panel">
</div>
<!-- Memory Connections Panel (Mnemosyne) -->
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel">
</div>
<script>
// ─── MNEMOSYNE: Memory Filter Panel ───────────────────
function openMemoryFilter() {
renderFilterList();
document.getElementById('memory-filter').style.display = 'flex';
}
function closeMemoryFilter() {
document.getElementById('memory-filter').style.display = 'none';
}
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }
function renderFilterList() {
const counts = SpatialMemory.getMemoryCountByRegion();
const regions = SpatialMemory.REGIONS;
@@ -614,30 +396,12 @@ function renderFilterList() {
const colorHex = '#' + region.color.toString(16).padStart(6, '0');
const item = document.createElement('div');
item.className = 'filter-item';
item.innerHTML = `
<div class="filter-item-left">
<span class="filter-dot" style="background:${colorHex}"></span>
<span class="filter-label">${region.glyph} ${region.label}</span>
</div>
<div class="filter-item-right">
<span class="filter-count">${count}</span>
<label class="filter-toggle">
<input type="checkbox" ${visible ? 'checked' : ''}
onchange="toggleRegion('${key}', this.checked)">
<span class="filter-slider"></span>
</label>
</div>
`;
item.innerHTML = `<div class="filter-item-left"><span class="filter-dot" style="background:${colorHex}"></span><span class="filter-label">${region.glyph} ${region.label}</span></div><div class="filter-item-right"><span class="filter-count">${count}</span><label class="filter-toggle"><input type="checkbox" ${visible ? 'checked' : ''} onchange="toggleRegion('${key}', this.checked)"><span class="filter-slider"></span></label></div>`;
list.appendChild(item);
}
}
function toggleRegion(category, visible) {
SpatialMemory.setRegionVisibility(category, visible);
}
function setAllFilters(visible) {
SpatialMemory.setAllRegionsVisible(visible);
renderFilterList();
}
function toggleRegion(category, visible) { SpatialMemory.setRegionVisibility(category, visible); }
function setAllFilters(visible) { SpatialMemory.setAllRegionsVisible(visible); renderFilterList(); }
</script>
</body>
</html>

View File

@@ -501,6 +501,7 @@ class QuestManager:
self._quests: dict[str, Quest] = {}
self._counter = 0
self._lock = threading.Lock()
self.load()
def create(self, name: str, description: str,
objectives: list[str], rewards: list[str]) -> Quest:
@@ -654,6 +655,7 @@ class InventoryManager:
# room -> list of {name, description, dropped_by, dropped_at}
self._room_items: dict[str, list[dict]] = {}
self._lock = threading.Lock()
self.load()
def take_item(self, user_id: str, username: str, room: str, item_name: str) -> dict:
"""Pick up an item from a room into user inventory."""
@@ -840,6 +842,7 @@ class GuildManager:
self._membership: dict[str, str] = {}
self._counter = 0
self._lock = threading.Lock()
self.load()
def create(self, name: str, leader_id: str, leader_name: str) -> dict:
"""Create a new guild. Returns guild dict or error."""
@@ -1073,6 +1076,7 @@ class CombatManager:
self._encounters: dict[str, CombatEncounter] = {} # user_id -> encounter
self._counter = 0
self._lock = threading.Lock()
self.load()
# ── NPC management ──────────────────────────────────────────────
@@ -1409,6 +1413,7 @@ class MagicManager:
self._spellbooks: dict[str, SpellBook] = {} # user_id -> SpellBook
self._counter = 0
self._lock = threading.Lock()
self.load()
# ── Spell registry ───────────────────────────────────────────────

80
playground/README.md Normal file
View File

@@ -0,0 +1,80 @@
# Sovereign Sound Playground
Interactive audio-visual experience — no servers, no dependencies, pure browser.
## Apps
### Playground v3 (Full Instrument)
`playground.html` — The complete experience:
- Visual piano keyboard (2 octaves)
- 6 visualization modes: Waveform, Particles, Bars, Spiral, Gravity Well, Strobe
- 5 color palettes: Cosmic, Sunset, Ocean, Forest, Neon
- Ambient beat with chord progressions
- Mouse/touch playback on visualizer
- Chord detection
- Recording to WAV
- Export as PNG
### Synesthesia
`synesthesia.html` — Paint with sound:
- Click and drag to create colors and shapes
- Each position generates a unique tone
- Particles respond to your movement
- Touch supported
### Ambient
`ambient.html` — Evolving soundscape:
- Automatic chord progressions
- Floating orbs respond to audio
- Reverb-drenched textures
- Click to enter, let it wash over you
### Interactive
`interactive.html` — 26 key-shape mappings:
- Press A-Z to play notes
- Each key has a unique shape and color
- Shapes animate and fade
- Visual keyboard at bottom
- Touch supported
### Visualizer
`visualizer.html` — WAV frequency visualization:
- Load any audio file
- 4 modes: Spectrum, Waveform, Spectrogram, Circular
- Drag and drop support
- Real-time frequency analysis
## Features
- Zero dependencies — just open in a browser
- Local-first — no network requests
- Touch support on all apps
- Keyboard support
- Recording and export
- Multiple visualization modes
- Color palettes
## Usage
Open any HTML file in a browser. That's it.
```bash
# Quick start
open playground/playground.html
# Or serve locally
python3 -m http.server 8080 --directory playground
```
## Keyboard Shortcuts (Playground v3)
- A-; (lower row): Play piano notes
- Mouse drag on visualizer: Create sound
- Click piano keys: Play notes
## Technical
- Web Audio API for sound generation
- Canvas 2D for visualization
- MediaRecorder for recording
- No build step, no framework

243
playground/ambient.html Normal file
View File

@@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ambient — Evolving Soundscape</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; overflow: hidden; height: 100vh; }
canvas { display: block; width: 100%; height: 100%; }
.overlay {
position: fixed; inset: 0; display: flex; align-items: center;
justify-content: center; background: rgba(0,0,0,0.7);
transition: opacity 0.5s; z-index: 10;
}
.overlay.hidden { opacity: 0; pointer-events: none; }
.start-btn {
background: rgba(100,50,150,0.5); border: 2px solid rgba(150,100,200,0.7);
color: #e0e0e0; padding: 20px 40px; font-size: 18px; border-radius: 30px;
cursor: pointer; font-family: sans-serif; letter-spacing: 2px;
transition: all 0.3s;
}
.start-btn:hover { background: rgba(150,100,200,0.5); transform: scale(1.05); }
.title {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.1); font-size: 24px; font-family: sans-serif;
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
}
</style>
</head>
<body>
<div class="title">Ambient</div>
<canvas id="canvas"></canvas>
<div class="overlay" id="overlay">
<button class="start-btn" id="start-btn">Enter Soundscape</button>
</div>
<script>
class AmbientSoundscape {
constructor() {
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.audioCtx = null;
this.masterGain = null;
this.analyser = null;
this.isPlaying = false;
this.currentChord = 0;
this.time = 0;
this.orbs = [];
this.resize();
window.addEventListener('resize', () => this.resize());
// Create orbs
for (let i = 0; i < 15; i++) {
this.orbs.push({
x: Math.random() * this.canvas.width,
y: Math.random() * this.canvas.height,
radius: Math.random() * 50 + 20,
speed: Math.random() * 0.5 + 0.2,
angle: Math.random() * Math.PI * 2,
color: [
[167, 139, 250], [129, 140, 248], [99, 102, 241],
[139, 92, 246], [124, 58, 237]
][i % 5]
});
}
this.bindEvents();
this.animate();
}
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
async initAudio() {
if (this.audioCtx) return;
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.audioCtx.createGain();
this.masterGain.gain.value = 0.2;
this.analyser = this.audioCtx.createAnalyser();
this.analyser.fftSize = 256;
this.analyser.smoothingTimeConstant = 0.95;
this.masterGain.connect(this.analyser);
this.analyser.connect(this.audioCtx.destination);
// Create reverb
const convolver = this.audioCtx.createConvolver();
const reverbTime = 3;
const sampleRate = this.audioCtx.sampleRate;
const length = sampleRate * reverbTime;
const impulse = this.audioCtx.createBuffer(2, length, sampleRate);
for (let channel = 0; channel < 2; channel++) {
const channelData = impulse.getChannelData(channel);
for (let i = 0; i < length; i++) {
channelData[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2);
}
}
convolver.buffer = impulse;
convolver.connect(this.masterGain);
this.reverb = convolver;
}
playChord() {
if (!this.audioCtx || !this.isPlaying) return;
const progressions = [
[[261.63, 329.63, 392.00], [392.00, 493.88, 587.33],
[440.00, 523.25, 659.25], [349.23, 440.00, 523.25]],
[[220.00, 277.18, 329.63], [329.63, 415.30, 493.88],
[369.99, 466.16, 554.37], [293.66, 369.99, 440.00]]
];
const prog = progressions[this.currentChord % 2];
const chord = prog[Math.floor(this.time / 4) % prog.length];
chord.forEach((freq, i) => {
const osc = this.audioCtx.createOscillator();
const gain = this.audioCtx.createGain();
const filter = this.audioCtx.createBiquadFilter();
osc.type = 'sine';
osc.frequency.value = freq * (1 + (Math.random() - 0.5) * 0.01);
filter.type = 'lowpass';
filter.frequency.value = 800 + Math.sin(this.time * 0.1) * 400;
gain.gain.setValueAtTime(0, this.audioCtx.currentTime);
gain.gain.linearRampToValueAtTime(0.15, this.audioCtx.currentTime + 0.5);
gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 4);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.reverb);
osc.start();
osc.stop(this.audioCtx.currentTime + 4);
});
// High texture
const highOsc = this.audioCtx.createOscillator();
const highGain = this.audioCtx.createGain();
highOsc.type = 'sine';
highOsc.frequency.value = chord[0] * 4 + Math.random() * 50;
highGain.gain.setValueAtTime(0, this.audioCtx.currentTime);
highGain.gain.linearRampToValueAtTime(0.03, this.audioCtx.currentTime + 1);
highGain.gain.exponentialRampToValueAtTime(0.001, this.audioCtx.currentTime + 5);
highOsc.connect(highGain);
highGain.connect(this.reverb);
highOsc.start();
highOsc.stop(this.audioCtx.currentTime + 5);
this.time += 4;
}
start() {
this.isPlaying = true;
document.getElementById('overlay').classList.add('hidden');
const loop = () => {
if (!this.isPlaying) return;
this.playChord();
setTimeout(loop, 4000);
};
loop();
}
stop() {
this.isPlaying = false;
}
animate() {
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.03)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
const freqData = this.analyser ? new Uint8Array(this.analyser.frequencyBinCount) : [];
if (this.analyser) this.analyser.getByteFrequencyData(freqData);
const energy = freqData.length > 0 ? freqData[10] / 255 : 0;
// Move and draw orbs
this.orbs.forEach((orb, i) => {
orb.angle += orb.speed * 0.01;
orb.x += Math.cos(orb.angle) * (1 + energy * 3);
orb.y += Math.sin(orb.angle * 0.7) * (1 + energy * 3);
// Wrap around
if (orb.x < -orb.radius) orb.x = this.canvas.width + orb.radius;
if (orb.x > this.canvas.width + orb.radius) orb.x = -orb.radius;
if (orb.y < -orb.radius) orb.y = this.canvas.height + orb.radius;
if (orb.y > this.canvas.height + orb.radius) orb.y = -orb.radius;
// Draw glow
const gradient = this.ctx.createRadialGradient(
orb.x, orb.y, 0,
orb.x, orb.y, orb.radius * (1 + energy * 0.5)
);
gradient.addColorStop(0, `rgba(${orb.color.join(',')}, 0.3)`);
gradient.addColorStop(1, 'rgba(0,0,0,0)');
this.ctx.beginPath();
this.ctx.arc(orb.x, orb.y, orb.radius * (1 + energy * 0.5), 0, Math.PI * 2);
this.ctx.fillStyle = gradient;
this.ctx.fill();
// Inner glow
this.ctx.beginPath();
this.ctx.arc(orb.x, orb.y, orb.radius * 0.3, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(${orb.color.join(',')}, 0.5)`;
this.ctx.fill();
});
requestAnimationFrame(() => this.animate());
}
bindEvents() {
document.getElementById('start-btn').addEventListener('click', async () => {
await this.initAudio();
this.start();
});
document.addEventListener('click', async () => {
if (!this.audioCtx) {
await this.initAudio();
}
}, { once: true });
}
}
new AmbientSoundscape();
</script>
</body>
</html>

294
playground/interactive.html Normal file
View File

@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive — 26 Key Shapes</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; overflow: hidden; height: 100vh; }
canvas { display: block; width: 100%; height: 100%; }
.title {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.1); font-size: 24px; font-family: sans-serif;
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
}
.key-hint {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.3); font-size: 14px; font-family: monospace;
pointer-events: none;
}
</style>
</head>
<body>
<div class="title">Interactive</div>
<div class="key-hint">Press A-Z to play</div>
<canvas id="canvas"></canvas>
<script>
class Interactive {
constructor() {
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.audioCtx = null;
this.analyser = null;
this.shapes = [];
this.activeKeys = new Set();
this.resize();
window.addEventListener('resize', () => this.resize());
// 26 key mappings with unique shapes and frequencies
this.keyMap = {
'a': { freq: 261.63, shape: 'circle', color: [255,100,100] },
'b': { freq: 277.18, shape: 'square', color: [255,150,100] },
'c': { freq: 293.66, shape: 'triangle', color: [255,200,100] },
'd': { freq: 311.13, shape: 'diamond', color: [255,255,100] },
'e': { freq: 329.63, shape: 'star', color: [200,255,100] },
'f': { freq: 349.23, shape: 'hexagon', color: [150,255,100] },
'g': { freq: 369.99, shape: 'cross', color: [100,255,100] },
'h': { freq: 392.00, shape: 'circle', color: [100,255,150] },
'i': { freq: 415.30, shape: 'square', color: [100,255,200] },
'j': { freq: 440.00, shape: 'triangle', color: [100,255,255] },
'k': { freq: 466.16, shape: 'diamond', color: [100,200,255] },
'l': { freq: 493.88, shape: 'star', color: [100,150,255] },
'm': { freq: 523.25, shape: 'hexagon', color: [100,100,255] },
'n': { freq: 554.37, shape: 'cross', color: [150,100,255] },
'o': { freq: 587.33, shape: 'circle', color: [200,100,255] },
'p': { freq: 622.25, shape: 'square', color: [255,100,255] },
'q': { freq: 659.25, shape: 'triangle', color: [255,100,200] },
'r': { freq: 698.46, shape: 'diamond', color: [255,100,150] },
's': { freq: 739.99, shape: 'star', color: [255,120,120] },
't': { freq: 783.99, shape: 'hexagon', color: [255,170,120] },
'u': { freq: 830.61, shape: 'cross', color: [255,220,120] },
'v': { freq: 880.00, shape: 'circle', color: [220,255,120] },
'w': { freq: 932.33, shape: 'square', color: [170,255,120] },
'x': { freq: 987.77, shape: 'triangle', color: [120,255,120] },
'y': { freq: 1046.50, shape: 'diamond', color: [120,255,170] },
'z': { freq: 1108.73, shape: 'star', color: [120,255,220] }
};
this.bindEvents();
this.animate();
}
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
async initAudio() {
if (this.audioCtx) return;
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioCtx.createAnalyser();
this.analyser.fftSize = 256;
this.analyser.connect(this.audioCtx.destination);
}
playKey(key) {
if (!this.audioCtx || !this.keyMap[key]) return;
const { freq, shape, color } = this.keyMap[key];
// Play sound
const osc = this.audioCtx.createOscillator();
const gain = this.audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.3, this.audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 1);
osc.connect(gain);
gain.connect(this.analyser);
osc.start();
osc.stop(this.audioCtx.currentTime + 1);
// Create shape
const x = Math.random() * (this.canvas.width - 200) + 100;
const y = Math.random() * (this.canvas.height - 200) + 100;
this.shapes.push({
key, x, y, shape, color,
size: 50,
maxSize: 150,
life: 1,
rotation: 0
});
}
drawShape(shape) {
const { x, y, size, rotation, shape: shapeType, color, life } = shape;
this.ctx.save();
this.ctx.translate(x, y);
this.ctx.rotate(rotation);
this.ctx.globalAlpha = life;
this.ctx.strokeStyle = `rgb(${color.join(',')})`;
this.ctx.lineWidth = 3;
this.ctx.fillStyle = `rgba(${color.join(',')}, 0.2)`;
this.ctx.beginPath();
switch (shapeType) {
case 'circle':
this.ctx.arc(0, 0, size, 0, Math.PI * 2);
break;
case 'square':
this.ctx.rect(-size, -size, size * 2, size * 2);
break;
case 'triangle':
this.ctx.moveTo(0, -size);
this.ctx.lineTo(size * 0.866, size * 0.5);
this.ctx.lineTo(-size * 0.866, size * 0.5);
this.ctx.closePath();
break;
case 'diamond':
this.ctx.moveTo(0, -size);
this.ctx.lineTo(size * 0.7, 0);
this.ctx.lineTo(0, size);
this.ctx.lineTo(-size * 0.7, 0);
this.ctx.closePath();
break;
case 'star':
for (let i = 0; i < 5; i++) {
const angle = (i * 4 * Math.PI) / 5 - Math.PI / 2;
const method = i === 0 ? 'moveTo' : 'lineTo';
this.ctx[method](Math.cos(angle) * size, Math.sin(angle) * size);
}
this.ctx.closePath();
break;
case 'hexagon':
for (let i = 0; i < 6; i++) {
const angle = (i * Math.PI) / 3;
const method = i === 0 ? 'moveTo' : 'lineTo';
this.ctx[method](Math.cos(angle) * size, Math.sin(angle) * size);
}
this.ctx.closePath();
break;
case 'cross':
const w = size * 0.3;
this.ctx.moveTo(-w, -size);
this.ctx.lineTo(w, -size);
this.ctx.lineTo(w, -w);
this.ctx.lineTo(size, -w);
this.ctx.lineTo(size, w);
this.ctx.lineTo(w, w);
this.ctx.lineTo(w, size);
this.ctx.lineTo(-w, size);
this.ctx.lineTo(-w, w);
this.ctx.lineTo(-size, w);
this.ctx.lineTo(-size, -w);
this.ctx.lineTo(-w, -w);
this.ctx.closePath();
break;
}
this.ctx.fill();
this.ctx.stroke();
// Draw key label
this.ctx.fillStyle = `rgba(${color.join(',')}, ${life})`;
this.ctx.font = `${size * 0.4}px monospace`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(shape.key.toUpperCase(), 0, 0);
this.ctx.restore();
}
animate() {
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.1)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Update and draw shapes
this.shapes = this.shapes.filter(shape => {
shape.size += (shape.maxSize - shape.size) * 0.05;
shape.rotation += 0.02;
shape.life -= 0.01;
if (shape.life <= 0) return false;
this.drawShape(shape);
return true;
});
// Draw active key indicators
const keyWidth = this.canvas.width / 13;
const keyHeight = 40;
const startY = this.canvas.height - 60;
let col = 0;
for (const key of 'abcdefghijklmnopqrstuvwxyz') {
const x = col * keyWidth + keyWidth / 2;
const isActive = this.activeKeys.has(key);
this.ctx.fillStyle = isActive
? `rgba(${this.keyMap[key].color.join(',')}, 0.5)`
: 'rgba(255,255,255,0.05)';
this.ctx.beginPath();
this.ctx.roundRect(x - 15, startY, 30, keyHeight, 5);
this.ctx.fill();
this.ctx.fillStyle = isActive ? '#fff' : 'rgba(255,255,255,0.3)';
this.ctx.font = '12px monospace';
this.ctx.textAlign = 'center';
this.ctx.fillText(key.toUpperCase(), x, startY + 25);
col++;
}
requestAnimationFrame(() => this.animate());
}
bindEvents() {
document.addEventListener('keydown', async (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (this.keyMap[key] && !this.activeKeys.has(key)) {
await this.initAudio();
this.activeKeys.add(key);
this.playKey(key);
}
});
document.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
this.activeKeys.delete(key);
});
// Touch support
this.canvas.addEventListener('touchstart', async (e) => {
e.preventDefault();
await this.initAudio();
// Map touch area to key
for (const touch of e.changedTouches) {
const keyIndex = Math.floor(touch.clientX / (this.canvas.width / 26));
const key = 'abcdefghijklmnopqrstuvwxyz'[keyIndex];
if (key) {
this.activeKeys.add(key);
this.playKey(key);
}
}
});
this.canvas.addEventListener('touchend', (e) => {
e.preventDefault();
this.activeKeys.clear();
});
}
}
new Interactive();
</script>
</body>
</html>

1216
playground/playground.html Normal file

File diff suppressed because it is too large Load Diff

198
playground/synesthesia.html Normal file
View File

@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Synesthesia — Paint with Sound</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; overflow: hidden; height: 100vh; cursor: crosshair; }
canvas { display: block; width: 100%; height: 100%; }
.title {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.15); font-size: 24px; font-family: sans-serif;
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
}
</style>
</head>
<body>
<div class="title">Synesthesia</div>
<canvas id="canvas"></canvas>
<script>
class Synesthesia {
constructor() {
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.audioCtx = null;
this.analyser = null;
this.particles = [];
this.trails = [];
this.mouse = { x: 0, y: 0, down: false };
this.lastNote = null;
this.colors = [
[255, 100, 150], [100, 200, 255], [150, 255, 150],
[255, 200, 100], [200, 150, 255]
];
this.resize();
window.addEventListener('resize', () => this.resize());
this.bindEvents();
this.animate();
}
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
async initAudio() {
if (this.audioCtx) return;
this.audioCtx = new AudioContext();
this.analyser = this.audioCtx.createAnalyser();
this.analyser.fftSize = 256;
this.analyser.connect(this.audioCtx.destination);
}
playTone(x, y) {
if (!this.audioCtx) return;
const freq = 200 + (x / this.canvas.width) * 600;
const noteId = Math.round(freq / 20);
if (noteId === this.lastNote) return;
this.lastNote = noteId;
const osc = this.audioCtx.createOscillator();
const gain = this.audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.2, this.audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 0.5);
osc.connect(gain);
gain.connect(this.analyser);
osc.start();
osc.stop(this.audioCtx.currentTime + 0.5);
// Spawn particles
const color = this.colors[Math.floor(Math.random() * this.colors.length)];
for (let i = 0; i < 8; i++) {
this.particles.push({
x, y,
vx: (Math.random() - 0.5) * 8,
vy: (Math.random() - 0.5) * 8,
life: 1,
color,
size: Math.random() * 10 + 5
});
}
// Add trail
this.trails.push({ x, y, color, size: 30, alpha: 0.5 });
}
animate() {
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.05)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Draw trails
this.trails = this.trails.filter(t => {
t.alpha -= 0.005;
if (t.alpha <= 0) return false;
this.ctx.beginPath();
this.ctx.arc(t.x, t.y, t.size, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(${t.color.join(',')}, ${t.alpha})`;
this.ctx.fill();
return true;
});
// Draw particles
this.particles = this.particles.filter(p => {
p.x += p.vx;
p.y += p.vy;
p.life -= 0.02;
p.vy += 0.1;
if (p.life <= 0) return false;
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(${p.color.join(',')}, ${p.life * 0.8})`;
this.ctx.fill();
return true;
});
// Draw mouse trail
if (this.mouse.down) {
this.ctx.beginPath();
this.ctx.arc(this.mouse.x, this.mouse.y, 20, 0, Math.PI * 2);
const color = this.colors[Date.now() % 5];
this.ctx.fillStyle = `rgba(${color.join(',')}, 0.3)`;
this.ctx.fill();
}
requestAnimationFrame(() => this.animate());
}
bindEvents() {
this.canvas.addEventListener('mousedown', async (e) => {
await this.initAudio();
this.mouse.down = true;
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
this.playTone(e.clientX, e.clientY);
});
this.canvas.addEventListener('mousemove', (e) => {
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
if (this.mouse.down) {
this.playTone(e.clientX, e.clientY);
}
});
this.canvas.addEventListener('mouseup', () => {
this.mouse.down = false;
this.lastNote = null;
});
this.canvas.addEventListener('mouseleave', () => {
this.mouse.down = false;
this.lastNote = null;
});
this.canvas.addEventListener('touchstart', async (e) => {
e.preventDefault();
await this.initAudio();
this.mouse.down = true;
const touch = e.touches[0];
this.mouse.x = touch.clientX;
this.mouse.y = touch.clientY;
this.playTone(touch.clientX, touch.clientY);
});
this.canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
this.mouse.x = touch.clientX;
this.mouse.y = touch.clientY;
if (this.mouse.down) {
this.playTone(touch.clientX, touch.clientY);
}
});
this.canvas.addEventListener('touchend', () => {
this.mouse.down = false;
this.lastNote = null;
});
}
}
new Synesthesia();
</script>
</body>
</html>

371
playground/visualizer.html Normal file
View File

@@ -0,0 +1,371 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visualizer — WAV Frequency</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; overflow: hidden; height: 100vh; }
canvas { display: block; width: 100%; height: 100%; }
.title {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.1); font-size: 24px; font-family: sans-serif;
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
}
.controls {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; gap: 10px; z-index: 10;
}
button, select {
background: rgba(30,30,40,0.9); border: 1px solid rgba(100,100,120,0.3);
color: #e0e0e0; padding: 10px 20px; border-radius: 8px; font-size: 14px;
cursor: pointer;
}
button:hover, select:hover { background: rgba(50,50,70,0.9); }
.drop-zone {
position: fixed; inset: 0; display: flex; align-items: center;
justify-content: center; pointer-events: none; z-index: 5;
}
.drop-zone.active {
background: rgba(100,50,150,0.2);
border: 3px dashed rgba(150,100,200,0.5);
}
.drop-text {
color: rgba(255,255,255,0.3); font-size: 24px; font-family: sans-serif;
}
</style>
</head>
<body>
<div class="title">Visualizer</div>
<div class="drop-zone" id="drop-zone">
<span class="drop-text">Drop WAV file here or use controls</span>
</div>
<canvas id="canvas"></canvas>
<div class="controls">
<input type="file" id="file-input" accept="audio/*" style="display: none;">
<button id="load-btn">Load Audio</button>
<button id="play-btn" disabled>Play</button>
<button id="stop-btn" disabled>Stop</button>
<select id="viz-mode">
<option value="spectrum">Spectrum</option>
<option value="waveform">Waveform</option>
<option value="spectrogram">Spectrogram</option>
<option value="circular">Circular</option>
</select>
</div>
<script>
class AudioVisualizer {
constructor() {
this.canvas = document.getElementById('canvas');
this.ctx = this.canvas.getContext('2d');
this.audioCtx = null;
this.analyser = null;
this.source = null;
this.audioBuffer = null;
this.isPlaying = false;
this.mode = 'spectrum';
this.spectrogramData = [];
this.startOffset = 0;
this.resize();
window.addEventListener('resize', () => this.resize());
this.bindEvents();
this.animate();
}
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
async initAudio() {
if (this.audioCtx) return;
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioCtx.createAnalyser();
this.analyser.fftSize = 2048;
this.analyser.smoothingTimeConstant = 0.8;
this.analyser.connect(this.audioCtx.destination);
}
async loadAudio(file) {
await this.initAudio();
const arrayBuffer = await file.arrayBuffer();
this.audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer);
document.getElementById('play-btn').disabled = false;
document.getElementById('stop-btn').disabled = false;
document.querySelector('.drop-text').textContent = file.name;
}
play() {
if (!this.audioBuffer || this.isPlaying) return;
this.source = this.audioCtx.createBufferSource();
this.source.buffer = this.audioBuffer;
this.source.connect(this.analyser);
this.source.start(0, this.startOffset);
this.isPlaying = true;
this.source.onended = () => {
this.isPlaying = false;
this.startOffset = 0;
document.getElementById('play-btn').textContent = 'Play';
};
document.getElementById('play-btn').textContent = 'Pause';
}
stop() {
if (this.source && this.isPlaying) {
this.source.stop();
this.isPlaying = false;
this.startOffset = 0;
document.getElementById('play-btn').textContent = 'Play';
}
}
animate() {
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.15)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
if (!this.analyser) {
requestAnimationFrame(() => this.animate());
return;
}
const freqData = new Uint8Array(this.analyser.frequencyBinCount);
const timeData = new Uint8Array(this.analyser.frequencyBinCount);
this.analyser.getByteFrequencyData(freqData);
this.analyser.getByteTimeDomainData(timeData);
switch (this.mode) {
case 'spectrum': this.drawSpectrum(freqData); break;
case 'waveform': this.drawWaveform(timeData); break;
case 'spectrogram': this.drawSpectrogram(freqData); break;
case 'circular': this.drawCircular(freqData, timeData); break;
}
requestAnimationFrame(() => this.animate());
}
drawSpectrum(data) {
const w = this.canvas.width;
const h = this.canvas.height;
const barCount = 128;
const barWidth = w / barCount;
const step = Math.floor(data.length / barCount);
for (let i = 0; i < barCount; i++) {
const value = data[i * step] / 255;
const barHeight = value * h * 0.8;
const hue = (i / barCount) * 300;
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.8)`;
this.ctx.fillRect(
i * barWidth,
h - barHeight,
barWidth - 1,
barHeight
);
// Mirror
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.2)`;
this.ctx.fillRect(
i * barWidth,
0,
barWidth - 1,
barHeight * 0.3
);
}
}
drawWaveform(data) {
const w = this.canvas.width;
const h = this.canvas.height;
this.ctx.beginPath();
this.ctx.strokeStyle = '#a78bfa';
this.ctx.lineWidth = 2;
const sliceWidth = w / data.length;
let x = 0;
for (let i = 0; i < data.length; i++) {
const v = data[i] / 128.0;
const y = (v * h) / 2;
if (i === 0) {
this.ctx.moveTo(x, y);
} else {
this.ctx.lineTo(x, y);
}
x += sliceWidth;
}
this.ctx.stroke();
// Glow
this.ctx.strokeStyle = 'rgba(167,139,250,0.3)';
this.ctx.lineWidth = 6;
this.ctx.stroke();
}
drawSpectrogram(data) {
const w = this.canvas.width;
const h = this.canvas.height;
// Add new line
this.spectrogramData.push([...data]);
// Keep last N lines
const maxLines = Math.floor(w / 2);
if (this.spectrogramData.length > maxLines) {
this.spectrogramData.shift();
}
// Draw
const line_width = w / maxLines;
this.spectrogramData.forEach((line, x) => {
const step = Math.floor(line.length / h);
for (let y = 0; y < h; y++) {
const value = line[y * step] || 0;
const hue = 270 - (value / 255) * 200;
const lightness = 20 + (value / 255) * 50;
this.ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`;
this.ctx.fillRect(x * line_width, h - y, line_width, 1);
}
});
}
drawCircular(freqData, timeData) {
const w = this.canvas.width;
const h = this.canvas.height;
const cx = w / 2;
const cy = h / 2;
const maxRadius = Math.min(w, h) * 0.35;
// Frequency ring
const freqStep = Math.floor(freqData.length / 180);
for (let i = 0; i < 180; i++) {
const value = freqData[i * freqStep] / 255;
const angle = (i / 180) * Math.PI * 2;
const radius = maxRadius * 0.5 + value * maxRadius * 0.5;
const x = cx + Math.cos(angle) * radius;
const y = cy + Math.sin(angle) * radius;
this.ctx.beginPath();
this.ctx.arc(x, y, 3, 0, Math.PI * 2);
const hue = (i / 180) * 360;
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.8)`;
this.ctx.fill();
}
// Waveform circle
this.ctx.beginPath();
this.ctx.strokeStyle = 'rgba(255,255,255,0.3)';
this.ctx.lineWidth = 1;
for (let i = 0; i < timeData.length; i++) {
const v = timeData[i] / 128.0;
const angle = (i / timeData.length) * Math.PI * 2;
const radius = maxRadius * 0.3 * v;
const x = cx + Math.cos(angle) * radius;
const y = cy + Math.sin(angle) * radius;
if (i === 0) {
this.ctx.moveTo(x, y);
} else {
this.ctx.lineTo(x, y);
}
}
this.ctx.closePath();
this.ctx.stroke();
// Center glow
const gradient = this.ctx.createRadialGradient(cx, cy, 0, cx, cy, maxRadius * 0.3);
gradient.addColorStop(0, 'rgba(167,139,250,0.3)');
gradient.addColorStop(1, 'rgba(167,139,250,0)');
this.ctx.beginPath();
this.ctx.arc(cx, cy, maxRadius * 0.3, 0, Math.PI * 2);
this.ctx.fillStyle = gradient;
this.ctx.fill();
}
bindEvents() {
document.getElementById('load-btn').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', async (e) => {
if (e.target.files[0]) {
await this.loadAudio(e.target.files[0]);
}
});
document.getElementById('play-btn').addEventListener('click', () => {
if (this.isPlaying) {
this.stop();
} else {
this.play();
}
});
document.getElementById('stop-btn').addEventListener('click', () => {
this.stop();
});
document.getElementById('viz-mode').addEventListener('change', (e) => {
this.mode = e.target.value;
this.spectrogramData = [];
});
// Drag and drop
const dropZone = document.getElementById('drop-zone');
document.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('active');
});
document.addEventListener('dragleave', () => {
dropZone.classList.remove('active');
});
document.addEventListener('drop', async (e) => {
e.preventDefault();
dropZone.classList.remove('active');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('audio/')) {
await this.loadAudio(file);
}
});
// Touch support
this.canvas.addEventListener('click', async () => {
if (!this.audioCtx) {
await this.initAudio();
}
if (this.audioCtx.state === 'suspended') {
this.audioCtx.resume();
}
});
}
}
new AudioVisualizer();
</script>
</body>
</html>

View File

@@ -103,11 +103,13 @@ async def main():
await stop
logger.info("Shutting down Nexus WS gateway...")
# Close all client connections
if clients:
logger.info(f"Closing {len(clients)} active connections...")
close_tasks = [client.close() for client in clients]
# Close any remaining client connections (handlers may have already cleaned up)
remaining = {c for c in clients if c.open}
if remaining:
logger.info(f"Closing {len(remaining)} active connections...")
close_tasks = [client.close() for client in remaining]
await asyncio.gather(*close_tasks, return_exceptions=True)
clients.clear()
logger.info("Shutdown complete.")

View File

@@ -1346,6 +1346,22 @@ canvas#nexus-canvas {
width: 240px;
bottom: 180px;
}
.gofai-hud {
left: 8px;
gap: 6px;
}
.hud-panel {
width: 220px;
padding: 6px;
}
.panel-content {
max-height: 80px;
}
.memory-feed {
width: 260px;
left: 8px;
bottom: 10px;
}
}
@media (max-width: 768px) {
@@ -1357,6 +1373,12 @@ canvas#nexus-canvas {
.hud-agent-log {
display: none;
}
.gofai-hud {
display: none;
}
.memory-feed {
display: none;
}
.hud-location {
font-size: var(--text-xs);
}

20
tests/boot.test.js Normal file
View File

@@ -0,0 +1,20 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { bootPage } = require('../boot.js');
const el = (tagName = 'div') => ({ tagName, textContent: '', innerHTML: '', style: {}, children: [], type: '', src: '', appendChild(child) { this.children.push(child); } });
test('bootPage handles file and http origins', () => {
const loaderSubtitle = el(), bootMessage = el(), body = el('body');
const doc = { body, querySelector: s => s === '.loader-subtitle' ? loaderSubtitle : null, getElementById: id => id === 'boot-message' ? bootMessage : null, createElement: tag => el(tag) };
const fileResult = bootPage({ location: { protocol: 'file:' } }, doc);
assert.equal(fileResult.mode, 'file');
assert.equal(body.children.length, 0);
assert.match(loaderSubtitle.textContent, /serve this world over http/i);
assert.match(bootMessage.innerHTML, /python3 -m http\.server 8888/i);
const httpResult = bootPage({ location: { protocol: 'http:' } }, doc);
assert.equal(httpResult.mode, 'module');
assert.equal(body.children.length, 1);
assert.equal(body.children[0].tagName, 'script');
assert.equal(body.children[0].type, 'module');
assert.equal(body.children[0].src, './bootstrap.mjs');
});

28
tests/bootstrap.test.mjs Normal file
View File

@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { readFileSync } from 'node:fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const load = () => import(pathToFileURL(path.join(repoRoot, 'bootstrap.mjs')).href);
const el = () => ({ textContent: '', innerHTML: '', style: {}, className: '' });
test('boot shows file guidance', async () => {
const { boot } = await load();
const subtitle = el(), msg = el(); let calls = 0;
const result = await boot({ win: { location: { protocol: 'file:' } }, doc: { getElementById: id => id === 'boot-message' ? msg : null, querySelector: s => s === '.loader-subtitle' ? subtitle : null }, importApp: async () => (calls += 1, {}) });
assert.equal(result.mode, 'file'); assert.equal(calls, 0); assert.match(subtitle.textContent, /serve/i); assert.match(msg.innerHTML, /python3 -m http\.server 8888/i);
});
test('sanitizer repairs synthetic and real app input', async () => {
const { sanitizeAppModuleSource, loadAppModule, boot } = await load();
const synthetic = ["import ResonanceVisualizer from './nexus/components/resonance-visualizer.js';\\nimport * as THREE from 'three';","const calibrator = boot();\\n startRenderer();","import { SymbolicEngine, AgentFSM } from './nexus/symbolic-engine.js';","class SymbolicEngine {}","/**\n * Process Evennia-specific fields from Hermes WS messages.\n * Called from handleHermesMessage for any message carrying evennia metadata.\n */\nfunction handleEvenniaEvent(data) {\n if (data.evennia_command) {\n addActionStreamEntry('cmd', data.evennia_command);\n }\n}\n\n\n// ═══════════════════════════════════════════\nfunction handleHermesMessage(data) {\n if (data.type === 'history') {\n return;\n }\n } else if (data.type && data.type.startsWith('evennia.')) {\n handleEvenniaEvent(data);\n // Evennia event bridge — process command/result/room fields if present\n handleEvenniaEvent(data);\n}","logs.innerHTML = ok;\n // Actual MemPalace initialization would happen here\n // For demo purposes we'll just show status\n statusEl.textContent = 'Connected to local MemPalace';\n statusEl.style.color = '#4af0c0';\n \n // Simulate mining process\n mineMemPalaceContent(\"Initial knowledge base setup complete\");\n } catch (err) {\n console.error('Failed to initialize MemPalace:', err);\n document.getElementById('mem-palace-status').textContent = 'MemPalace ERROR';\n document.getElementById('mem-palace-status').style.color = '#ff4466';\n }\n try {"," // Auto-mine chat every 30s\n setInterval(mineMemPalaceContent, 30000);\n try {\n const status = mempalace.status();\n document.getElementById('compression-ratio').textContent = status.compression_ratio.toFixed(1) + 'x';\n document.getElementById('docs-mined').textContent = status.total_docs;\n document.getElementById('aaak-size').textContent = status.aaak_size + 'B';\n } catch (error) {\n console.error('Failed to update MemPalace status:', error);\n }\n }\n\n // Auto-mine chat history every 30s\n"].join('\n');
const fixed = sanitizeAppModuleSource(synthetic), real = sanitizeAppModuleSource(readFileSync(path.join(repoRoot, 'app.js'), 'utf8'));
for (const text of [fixed, real]) { assert.doesNotMatch(text, /;\\n|from '\.\/nexus\/symbolic-engine\.js'|\n \}\n \} else if|Connected to local MemPalace|setInterval\(mineMemPalaceContent, 30000\);\n try \{/); }
assert.match(fixed, /resonance-visualizer\.js';\nimport \* as THREE/); assert.match(fixed, /boot\(\);\n startRenderer\(\);/);
let calls = 0; const imported = await boot({ win: { location: { protocol: 'http:' } }, doc: { getElementById() { return null; }, querySelector() { return null; }, createElement() { return { type: '', textContent: '', onload: null, onerror: null }; }, body: { appendChild(node) { node.onload(); } } }, importApp: async () => (calls += 1, {}) });
assert.equal(imported.mode, 'imported'); assert.equal(calls, 1);
const appended = []; const script = await loadAppModule({ doc: { createElement() { return { type: '', textContent: '', onload: null, onerror: null }; }, body: { appendChild(node) { appended.push(node); node.onload(); } } }, fetchImpl: async () => ({ ok: true, text: async () => "import * as THREE from 'three';" }) });
assert.equal(appended.length, 1); assert.equal(script, appended[0]); assert.equal(script.type, 'module');
});

View File

@@ -0,0 +1,10 @@
from pathlib import Path
def test_index_html_integrity():
text = (Path(__file__).resolve().parents[1] / 'index.html').read_text(encoding='utf-8')
for marker in ('<<<<<<<', '=======', '>>>>>>>', '```html', '<EFBFBD>'):
assert marker not in text
assert 'index.html\n```html' not in text
for needle in ('View Contribution Policy', 'id="mem-palace-container"', 'id="mempalace-results"', 'id="memory-filter"', 'id="memory-feed"', 'id="memory-inspect-panel"', 'id="memory-connections-panel"'):
assert text.count(needle) == 1