Compare commits

...

2 Commits

Author SHA1 Message Date
03d795cd54 Merge branch 'main' into fix/1539-1776226200
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 12s
CI / test (pull_request) Failing after 1m14s
CI / validate (pull_request) Failing after 1m21s
2026-04-22 01:16:36 +00:00
Timmy
d779f180ee feat(#1539): Portal health check — auto-disable broken portals
Some checks failed
CI / test (pull_request) Failing after 1m14s
CI / validate (pull_request) Failing after 1m11s
Review Approval Gate / verify-review (pull_request) Failing after 7s
Adds PortalHealthCheck module that:
  - Checks portal status every 5 minutes
  - Dims offline/blocked portals (opacity fade)
  - Shows "OFFLINE" label overlay
  - Auto-re-enables when status returns to online
  - Slows/stops swirl animation on offline portals
  - Dims ring emissive intensity

Integrated:
  - init() called after createPortals()
  - update() called each render frame
  - Script tag added to index.html

Fixes #1539
2026-04-17 02:11:01 -04:00
3 changed files with 174 additions and 1 deletions

6
app.js
View File

@@ -734,6 +734,8 @@ async function init() {
const response = await fetch('./portals.json');
const portalData = await response.json();
createPortals(portalData);
// Initialize portal health checks (#1539)
if (window.PortalHealthCheck) window.PortalHealthCheck.init(portals);
} catch (e) {
console.error('Failed to load portals.json:', e);
addChatMessage('error', 'Portal registry offline. Check logs.');
@@ -3572,7 +3574,9 @@ function gameLoop() {
animateMemoryOrbs(delta);
}
updatePortalTunnel(delta, elapsed);
updatePortalTunnel(delta, elapsed);
// Update portal health visuals (#1539)
if (window.PortalHealthCheck) window.PortalHealthCheck.update(portals);
if (workshopScanMat) workshopScanMat.uniforms.uTime.value = clock.getElapsedTime();
if (activePortal !== lastFocusedPortal) {

View File

@@ -396,6 +396,7 @@
<script src="./boot.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./portal-health-check.js"></script>
<script src="./lod-system.js"></script>
<script>
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }

168
portal-health-check.js Normal file
View File

@@ -0,0 +1,168 @@
/**
* Portal Health Check Module (#1539)
*
* Monitors portal status and updates visuals:
* - Dims offline/blocked portals
* - Shows "Offline" tooltip
* - Auto-re-enables when status changes to online
* - Runs check every 5 minutes
*/
(function () {
'use strict';
const CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes
const DIM_OPACITY = 0.15;
const NORMAL_OPACITY = 1.0;
const FADE_SPEED = 0.02;
let portalHealthStates = {}; // portal_id -> { healthy, fading, currentOpacity, targetOpacity }
let checkTimer = null;
// ═══ Health Check ═══════════════════════════════════════════
function checkPortalHealth(portals, wsConnected) {
for (const portal of portals) {
const id = portal.config.id;
const status = portal.config.status || 'unknown';
const blocked = portal.config.blocked_reason;
const interactionReady = portal.config.interaction_ready !== false;
// Determine health
const healthy = status === 'online' && !blocked && interactionReady && wsConnected;
if (!portalHealthStates[id]) {
portalHealthStates[id] = { healthy: true, currentOpacity: NORMAL_OPACITY, targetOpacity: NORMAL_OPACITY };
}
const state = portalHealthStates[id];
const wasHealthy = state.healthy;
state.healthy = healthy;
state.targetOpacity = healthy ? NORMAL_OPACITY : DIM_OPACITY;
// Log state changes
if (wasHealthy !== healthy) {
const label = portal.config.name || id;
if (healthy) {
console.log(`[PortalHealth] ${label} — back online`);
} else {
const reason = blocked || (!wsConnected ? 'bridge offline' : `status: ${status}`);
console.log(`[PortalHealth] ${label} — offline (${reason})`);
}
}
}
}
// ═══ Visual Update ══════════════════════════════════════════
function updatePortalVisuals(portals) {
for (const portal of portals) {
const id = portal.config.id;
const state = portalHealthStates[id];
if (!state) continue;
// Smooth fade toward target opacity
if (Math.abs(state.currentOpacity - state.targetOpacity) > 0.01) {
state.currentOpacity += (state.targetOpacity - state.currentOpacity) * FADE_SPEED;
} else {
state.currentOpacity = state.targetOpacity;
}
const opacity = state.currentOpacity;
const dimmed = opacity < NORMAL_OPACITY * 0.5;
// Apply to portal meshes
if (portal.ring) {
portal.ring.material.opacity = opacity;
portal.ring.material.transparent = true;
portal.ring.material.emissiveIntensity = dimmed ? 0.2 : 1.5;
}
if (portal.swirl) {
portal.swirl.material.uniforms.uTime.value += dimmed ? 0.001 : 0.016; // Slow/stop swirl when offline
portal.swirl.material.opacity = opacity;
}
if (portal.pSystem) {
portal.pSystem.material.opacity = opacity * 0.6;
}
if (portal.light) {
portal.light.intensity = dimmed ? 0.3 : 2;
}
// Update label to show offline status
if (portal.labelMesh && dimmed) {
updatePortalLabel(portal, state);
}
}
}
function updatePortalLabel(portal, state) {
// Re-render label canvas with offline indicator
const labelMesh = portal.labelMesh;
if (!labelMesh || !labelMesh.material.map) return;
// We can't easily re-render a canvas texture each frame,
// so we'll use a separate overlay sprite for offline status
if (!portal.offlineLabel) {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(255, 68, 102, 0.8)';
ctx.roundRect(0, 0, 256, 64, 8);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 20px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('⚠ OFFLINE', 128, 32);
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
const sprite = new THREE.Sprite(material);
sprite.scale.set(2, 0.5, 1);
sprite.position.y = 8.5;
portal.group.add(sprite);
portal.offlineLabel = sprite;
}
portal.offlineLabel.visible = state.currentOpacity < NORMAL_OPACITY * 0.5;
}
// ═══ Public API ═════════════════════════════════════════════
window.PortalHealthCheck = {
init(portalsRef) {
// Initial check
this.check(portalsRef, true);
// Periodic checks
if (checkTimer) clearInterval(checkTimer);
checkTimer = setInterval(() => this.check(portalsRef, true), CHECK_INTERVAL);
console.log('[PortalHealth] Initialized — checking every 5 minutes');
},
check(portalsRef, wsConnected) {
checkPortalHealth(portalsRef, wsConnected);
},
update(portalsRef) {
updatePortalVisuals(portalsRef);
},
getStatus() {
return { ...portalHealthStates };
},
isHealthy(portalId) {
return portalHealthStates[portalId]?.healthy !== false;
},
destroy() {
if (checkTimer) {
clearInterval(checkTimer);
checkTimer = null;
}
},
};
})();