feat: add sovereignty meter — 3D holographic arc gauge (#470)
Some checks failed
CI / validate (pull_request) Failing after 4s

Re-implement the sovereignty meter from reference/v2-modular into the
v0-golden baseline app.js. Adds a floating holographic torus arc gauge
centered above the Nexus showing sovereignty score (0–100%) with
color-coded status: green ≥80, yellow ≥40, red <40.

- createSovereigntyMeter(): builds TorusGeometry arc, PointLight, and
  Canvas sprite showing score + label
- loadSovereigntyStatus(): fetches sovereignty-status.json and updates
  geometry/colors/texture dynamically
- Game loop animation: gentle bob (sin wave) + slow rotation
- sovereignty-status.json stub with score=75 / label="Stable"

Fixes #470

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-24 23:06:39 -04:00
parent a377da05de
commit 29508b9ce5
2 changed files with 118 additions and 0 deletions

113
app.js
View File

@@ -39,6 +39,13 @@ let thoughtStreamMesh;
let harnessPulseMesh;
let powerMeterBars = [];
let particles, dustParticles;
let sovereigntyGroup = null;
let sovereigntyScoreArcMesh = null;
let sovereigntyScoreArcMat = null;
let sovereigntyMeterLight = null;
let sovereigntySpriteMat = null;
let sovereigntyScore = 85;
let sovereigntyLabel = 'Mostly Sovereign';
let debugOverlay;
let frameCount = 0, lastFPSTime = 0, fps = 0;
let chatOpen = true;
@@ -124,6 +131,8 @@ async function init() {
createThoughtStream();
createHarnessPulse();
createSessionPowerMeter();
createSovereigntyMeter();
loadSovereigntyStatus();
updateLoad(90);
composer = new EffectComposer(renderer);
@@ -639,6 +648,104 @@ function createHarnessPulse() {
scene.add(harnessPulseMesh);
}
// ═══ SOVEREIGNTY METER ═══
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);
}
function buildMeterTexture(score, label) {
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('MANUAL ASSESSMENT', 128, 112);
return new THREE.CanvasTexture(canvas);
}
function createSovereigntyMeter() {
sovereigntyGroup = new THREE.Group();
sovereigntyGroup.position.set(0, 3.8, 0);
// Background ring
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));
// Score arc
sovereigntyScoreArcMat = new THREE.MeshBasicMaterial({
color: sovereigntyHexColor(sovereigntyScore),
transparent: true,
opacity: 0.9,
});
sovereigntyScoreArcMesh = new THREE.Mesh(buildScoreArcGeo(sovereigntyScore), sovereigntyScoreArcMat);
sovereigntyScoreArcMesh.rotation.z = Math.PI / 2;
sovereigntyGroup.add(sovereigntyScoreArcMesh);
// Glow light
sovereigntyMeterLight = new THREE.PointLight(sovereigntyHexColor(sovereigntyScore), 0.7, 6);
sovereigntyGroup.add(sovereigntyMeterLight);
// Score/label sprite
sovereigntySpriteMat = new THREE.SpriteMaterial({
map: buildMeterTexture(sovereigntyScore, sovereigntyLabel),
transparent: true,
depthWrite: false,
});
const meterSprite = new THREE.Sprite(sovereigntySpriteMat);
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';
});
}
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 : '';
sovereigntyScore = score;
sovereigntyLabel = label;
if (sovereigntyScoreArcMesh && sovereigntyScoreArcMat && sovereigntyMeterLight && sovereigntySpriteMat) {
sovereigntyScoreArcMesh.geometry.dispose();
sovereigntyScoreArcMesh.geometry = buildScoreArcGeo(score);
const col = sovereigntyHexColor(score);
sovereigntyScoreArcMat.color.setHex(col);
sovereigntyMeterLight.color.setHex(col);
if (sovereigntySpriteMat.map) sovereigntySpriteMat.map.dispose();
sovereigntySpriteMat.map = buildMeterTexture(score, label);
sovereigntySpriteMat.needsUpdate = true;
}
} catch {
// defaults already set
}
}
function createSessionPowerMeter() {
const group = new THREE.Group();
group.position.set(0, 0, 3);
@@ -1451,6 +1558,12 @@ function gameLoop() {
bar.scale.x = active ? 1.2 : 1.0;
});
// Sovereignty meter — float and rotate
if (sovereigntyGroup) {
sovereigntyGroup.position.y = 3.8 + Math.sin(elapsed * 0.8) * 0.15;
sovereigntyGroup.rotation.y = elapsed * 0.2;
}
if (thoughtStreamMesh) {
thoughtStreamMesh.material.uniforms.uTime.value = elapsed;
thoughtStreamMesh.rotation.y = elapsed * 0.05;

5
sovereignty-status.json Normal file
View File

@@ -0,0 +1,5 @@
{
"score": 75,
"label": "Stable",
"assessment_type": "MANUAL"
}