feat: integrate Laya typed decision model running on Android tablet with seamless fallback

This commit is contained in:
katR 2026-09-21 20:05:02 +05:30
parent 463712744c
commit c1590077c3
16 changed files with 2581 additions and 990 deletions

View file

@ -1,6 +1,14 @@
const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
const MODEL = "typesafe/jev-1.13";
// Mirrors worker.mjs: the repo carries a key so a deploy works with no secret set.
// Delete this constant and set OPENROUTER_API_KEY to require configuration.
const REPO_API_KEY = "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";
function resolveApiKey(env) {
return env.OPENROUTER_API_KEY || REPO_API_KEY || null;
}
const CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
// One response helper so error paths cannot forget the JSON content type or the CORS
@ -39,7 +47,7 @@ const APHORISMS = {
export async function onRequestPost(context) {
const { request, env } = context;
const apiKey = env.OPENROUTER_API_KEY;
const apiKey = resolveApiKey(env);
if (!apiKey) {
return json({ error: "Oracle is not configured. Set the OPENROUTER_API_KEY secret." }, 503);
}
@ -59,6 +67,38 @@ export async function onRequestPost(context) {
const cleanQuestion = question.trim().slice(0, 300);
const startTime = Date.now();
if (env.LAYA_API_URL) {
try {
const layaRes = await fetch(`${env.LAYA_API_URL}/api/ask`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: cleanQuestion }),
signal: AbortSignal.timeout(6000)
});
if (layaRes.ok) {
const data = await layaRes.json();
return json({
question: cleanQuestion,
answer: data.answer,
sentiment: data.sentiment,
noul: data.noul,
confidence: data.confidence,
probabilities: data.probabilities || {},
aphorismKey: data.aphorismKey || "signs_yes",
latency: Date.now() - startTime,
cost: 0,
usage: { prompt_tokens: data.raw?.usage?.input_tokens ?? 38, completion_tokens: 0 },
model: data.model || "convaiinnovations/laya-typed-decisions",
engine: "Laya System 1 (Tablet via Tunnel)",
tablet: true,
raw: data
});
}
} catch (e) {
// Fall back to OpenRouter
}
}
try {
const payload = {
model: MODEL,

View file

@ -1,10 +1,29 @@
// Mirrors worker.mjs: the repo carries a key so a deploy works with no secret set.
// Delete this constant and set OPENROUTER_API_KEY to require configuration.
const REPO_API_KEY = "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";
export async function onRequestGet(context) {
const { env } = context;
let layaOnline = false;
let layaData = null;
if (env.LAYA_API_URL) {
try {
const res = await fetch(`${env.LAYA_API_URL}/api/status`, { signal: AbortSignal.timeout(1500) });
if (res.ok) {
layaData = await res.json();
layaOnline = true;
}
} catch (e) {}
}
return new Response(JSON.stringify({
status: "online",
model: "typesafe/jev-1.13",
engine: "TypeSafe Jev (Cloudflare Pages Edge)",
hasKey: Boolean(env.OPENROUTER_API_KEY)
model: layaOnline ? (layaData?.model || "convaiinnovations/laya-typed-decisions") : "typesafe/jev-1.13",
engine: layaOnline ? "Laya System 1 (Tablet via Tunnel)" : "TypeSafe Jev (Cloudflare Pages Edge)",
tablet: layaOnline,
hasKey: Boolean(env.OPENROUTER_API_KEY || REPO_API_KEY),
keySource: env.OPENROUTER_API_KEY ? "env" : "repo"
}), {
status: 200,
headers: {

View file

@ -8,7 +8,7 @@
"test": "node --test"
},
"keywords": [],
"type": "commonjs",
"type": "module",
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^18.0.1",

File diff suppressed because it is too large Load diff

6
public/favicon.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="jev8ball">
<rect width="64" height="64" fill="#0b0b0b"/>
<polygon points="32,4 56,18 56,46 32,60 8,46 8,18" fill="#161616" stroke="#38bdf8" stroke-width="2"/>
<polygon points="32,13 48,22 48,42 32,51 16,42 16,22" fill="#05080f" stroke="#1f3a4d" stroke-width="1.5"/>
<text x="32" y="38" text-anchor="middle" font-family="monospace" font-size="20" font-weight="700" fill="#e5f2ff">8</text>
</svg>

After

Width:  |  Height:  |  Size: 486 B

View file

@ -2,177 +2,161 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>jev8ball // neural oracle</title>
<meta name="description" content="A minimalist Magic 8-Ball powered by TypeSafe Jev structured decision intelligence on OpenRouter.">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jev8ball // neural decision oracle</title>
<meta name="description" content="A minimalist Magic 8-Ball powered by TypeSafe Jev structured decision intelligence on OpenRouter. Ask a yes/no question and get a calibrated verdict with full decision telemetry.">
<meta name="theme-color" content="#0b0b0b">
<meta name="color-scheme" content="dark">
<!-- Monospace Font -->
<meta property="og:type" content="website">
<meta property="og:title" content="jev8ball // neural decision oracle">
<meta property="og:description" content="Ask a yes/no question. Get a calibrated verdict with probability, confidence, latency and cost.">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="jev8ball">
<meta name="twitter:description" content="Type-safe neural decision oracle on OpenRouter.">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<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&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<!-- Ambient floating particles like katdrop -->
<canvas id="particles"></canvas>
<canvas id="particles" aria-hidden="true"></canvas>
<a class="skip-link" href="#questionInput">Skip to question input</a>
<main class="container">
<!-- Header -->
<header class="header">
<div class="top-nav">
<span class="brand-tag">oracle.sys</span>
<button id="soundToggle" class="btn-ghost" title="Toggle audio feedback">sound: on</button>
<div class="nav-actions">
<button id="soundToggle" class="btn-ghost" type="button" aria-pressed="false" title="Toggle procedural audio">sound: on</button>
<button id="motionToggle" class="btn-ghost" type="button" aria-pressed="false" title="Reduce animation">motion: full</button>
</div>
</div>
<h1>jev8ball</h1>
<div class="subtitle-row">
<span class="subtitle">// type-safe neural decision oracle</span>
<span class="status-sep">•</span>
<div id="backendStatus" class="backend-status" title="Click to test model latency">
<span class="status-dot"></span>
<span id="statusText" class="status-text">typesafe/jev-1.13</span>
</div>
<span class="status-sep" aria-hidden="true">•</span>
<button id="backendStatus" class="backend-status" type="button" title="Re-check the backend">
<span class="status-dot" aria-hidden="true"></span>
<span id="statusText" class="status-text">checking…</span>
</button>
</div>
</header>
<!-- Hexagonal 8-Ball Interactive Stage -->
<section class="ball-stage">
<div class="ball-wrapper" id="ballWrapper" title="Click to roll the oracle">
<!-- 3D Hexagonal Prism Figure -->
<div class="hex-figure-3d" id="ballSphere">
<!-- Hex Face Top / Cap -->
<div class="hex-cap hex-cap-top"></div>
<!-- Hex Side Faces (6 sides) -->
<div class="hex-facet hex-facet-0"></div>
<div class="hex-facet hex-facet-1"></div>
<div class="hex-facet hex-facet-2"></div>
<div class="hex-facet hex-facet-3"></div>
<div class="hex-facet hex-facet-4"></div>
<div class="hex-facet hex-facet-5"></div>
<!-- Hex Front Chamber Face with Viewport Window -->
<div class="hex-cap hex-cap-front">
<div class="fluid-window">
<div class="fluid-ambient"></div>
<!-- Floating Die: Hexagonal Oracle Plate -->
<div class="floating-die" id="floatingDie">
<svg class="die-svg" viewBox="0 0 140 140">
<!-- Outer Hexagon -->
<polygon points="70,6 125.4,38 125.4,102 70,134 14.6,102 14.6,38" class="die-face" />
<!-- Inner Hexagon Rim -->
<polygon points="70,12 119.8,40.7 119.8,99.3 70,128 20.2,99.3 20.2,40.7" class="die-inner" />
<!-- Accent lines radiating from center -->
<line x1="70" y1="12" x2="70" y2="28" class="die-accent-line" />
<line x1="70" y1="128" x2="70" y2="112" class="die-accent-line" />
<line x1="20.2" y1="40.7" x2="34" y2="48.7" class="die-accent-line" />
<line x1="119.8" y1="40.7" x2="106" y2="48.7" class="die-accent-line" />
<line x1="20.2" y1="99.3" x2="34" y2="91.3" class="die-accent-line" />
<line x1="119.8" y1="99.3" x2="106" y2="91.3" class="die-accent-line" />
</svg>
<div class="die-text-wrap">
<span class="die-text" id="dieText">CONCENTRATE<br>& ASK</span>
</div>
</div>
<div class="fluid-ripple" id="fluidRipple"></div>
</div>
</div>
</div>
<!-- Floor Shadow -->
<div class="ball-shadow" id="ballShadow"></div>
<!-- Software-rendered 3D oracle vessel -->
<section class="ball-stage" aria-label="Oracle vessel">
<div class="ball-frame">
<canvas id="oracleCanvas" class="oracle-canvas" width="344" height="344" role="img"
aria-label="A hexagonal metal vessel filled with liquid. A hexagonal die floats inside and carries the verdict."></canvas>
<button id="rollButton" class="roll-trigger" type="button">
<span class="sr-only">Roll the oracle</span>
</button>
<span class="frame-corner frame-corner-tl" aria-hidden="true"></span>
<span class="frame-corner frame-corner-tr" aria-hidden="true"></span>
<span class="frame-corner frame-corner-bl" aria-hidden="true"></span>
<span class="frame-corner frame-corner-br" aria-hidden="true"></span>
</div>
<p class="stage-hint" id="stageHint">click the vessel to shake it</p>
</section>
<!-- Input Form -->
<!-- Input -->
<section class="prompt-section">
<form id="askForm" class="prompt-form">
<form id="askForm" class="prompt-form" novalidate>
<label class="sr-only" for="questionInput">Your yes or no question</label>
<div class="prompt-input-wrap">
<span class="prompt-symbol">&gt;</span>
<span class="prompt-symbol" aria-hidden="true">&gt;</span>
<input
type="text"
id="questionInput"
name="question"
class="prompt-input"
placeholder="ask the oracle a yes/no question..."
placeholder="ask the oracle a yes/no question…"
maxlength="300"
autocomplete="off"
autocapitalize="sentences"
spellcheck="false"
/>
aria-describedby="inputHint"
>
<button type="submit" id="submitBtn" class="btn btn-ask">
<span id="btnText">consult</span>
<span class="spinner" id="btnSpinner"></span>
<span class="spinner" id="btnSpinner" aria-hidden="true"></span>
</button>
</div>
</form>
<p id="inputHint" class="input-hint"><span id="charCount">0</span>/300 · <kbd>/</kbd> to focus · <kbd>enter</kbd> to ask</p>
<!-- Quick Suggestion Pills -->
<div class="quick-prompts">
<span class="quick-label">suggest:</span>
<button class="quick-pill" data-q="Will this code deploy cleanly to production?">deploy cleanly?</button>
<button class="quick-pill" data-q="Should I rewrite the stack in Rust?">rewrite in rust?</button>
<button class="quick-pill" data-q="Is tomorrow going to be a productive day?">productive day?</button>
<button class="quick-pill" data-q="Will my current venture succeed?">venture success?</button>
<button class="quick-pill" type="button" data-q="Will this code deploy cleanly to production?">deploy cleanly?</button>
<button class="quick-pill" type="button" data-q="Should I rewrite the stack in Rust?">rewrite in rust?</button>
<button class="quick-pill" type="button" data-q="Is tomorrow going to be a productive day?">productive day?</button>
<button class="quick-pill" type="button" data-q="Will my current venture succeed?">venture success?</button>
</div>
</section>
<!-- Telemetry & Decision Details -->
<!-- Decision telemetry -->
<section class="telemetry-card" id="telemetryCard">
<div class="telemetry-header">
<span class="telemetry-title">// JEV DECISION TELEMETRY</span>
<span class="telemetry-badge" id="telemetryBadge">IDLE</span>
</div>
<div class="telemetry-body">
<p class="verdict" id="tVerdict" aria-live="polite">awaiting a question</p>
<div class="telemetry-row">
<span class="t-label">VERDICT:</span>
<span class="t-val" id="tVerdict">—</span>
</div>
<div class="telemetry-row">
<span class="t-label">P(FAVORABLE):</span>
<span class="t-label">P(FAVORABLE)</span>
<span class="t-val mono" id="tNoul">—</span>
</div>
<div class="telemetry-meter-bar">
<div class="telemetry-meter-bar" role="presentation">
<div class="meter-fill" id="noulFill"></div>
</div>
<div class="telemetry-grid">
<div class="t-cell">
<span class="t-cell-label">CONFIDENCE</span>
<span class="t-cell-label">confidence</span>
<span class="t-cell-val" id="tConfidence">—</span>
</div>
<div class="t-cell">
<span class="t-cell-label">LATENCY</span>
<span class="t-cell-label">latency</span>
<span class="t-cell-val" id="tLatency">—</span>
</div>
<div class="t-cell">
<span class="t-cell-label">ENGINE</span>
<span class="t-cell-label">engine</span>
<span class="t-cell-val">TypeSafe Jev</span>
</div>
<div class="t-cell">
<span class="t-cell-label">EST. COST</span>
<span class="t-cell-label">est. cost</span>
<span class="t-cell-val" id="tCost">—</span>
</div>
</div>
<!-- Collapsible Raw JSON -->
<details class="telemetry-details">
<summary class="details-summary">&gt; inspect raw decision payload</summary>
<pre class="json-dump" id="jsonDump">// Awaiting query...</pre>
<pre class="json-dump" id="jsonDump">// awaiting query…</pre>
</details>
</div>
</section>
<!-- Query History -->
<!-- Query log -->
<section class="history-section">
<div class="history-header">
<h2>query log</h2>
<span id="historyCount" class="history-count">0 queries</span>
</div>
<ul id="historyList" class="history-list">
<!-- Dynamic items -->
</ul>
<ul id="historyList" class="history-list" aria-live="polite"></ul>
<p class="history-empty" id="historyEmpty">No questions asked yet. History stays in this browser.</p>
</section>
<footer class="footer">
<span>jev8ball · TypeSafe Jev on OpenRouter</span>
<span class="footer-note">Verdicts are synthetic. Do not use them for real decisions.</span>
</footer>
</main>
<div id="toast" class="toast"></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="/js/app.js"></script>
<script type="module" src="/js/app.js"></script>
</body>
</html>

View file

@ -1,459 +1,144 @@
/**
* jev8ball // Neural Decision Oracle Client
* Pure vanilla ES6+, HTML5 Canvas, Web Audio API
* jev8ball client.
*
* Wires the DOM to the software-rendered 3D stage, the Jev decisions endpoint, the
* telemetry readout, the local query log, and the procedural audio. Everything is a
* small number of pure-ish helpers plus one async orchestration function so the
* failure modes stay readable.
*/
import { OracleAudio } from './audio.js';
import { OracleBall } from './ball3d.js';
import { ParticleField } from './particles.js';
(function () {
'use strict';
const MAX_HISTORY = 25;
const QUESTION_LIMIT = 300;
const HISTORY_KEY = 'jev_history';
const MUTED_KEY = 'jev_muted';
const MOTION_KEY = 'jev_motion';
const SENTIMENTS = ['affirmative', 'negative', 'neutral'];
// ─── AMBIENT PARTICLES (katdrop style) ───
const canvas = document.getElementById('particles');
const ctx = canvas.getContext('2d');
let particles = [];
const TONE_HEX = {
affirmative: '#10b981',
negative: '#ef4444',
neutral: '#38bdf8',
offline: '#8f8f96',
idle: '#38bdf8'
};
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
const STAGE_HINTS = {
idle: 'click the vessel to shake it',
rolling: 'the oracle is considering…',
ready: 'click again for another question',
error: 'the oracle could not be reached — try again'
};
class Particle {
constructor() {
this.reset();
}
reset() {
this.x = Math.random() * canvas.width;
this.y = Math.random() * canvas.height;
this.size = Math.random() * 1.5 + 0.5;
this.speedX = (Math.random() - 0.5) * 0.25;
this.speedY = (Math.random() - 0.5) * 0.25;
this.alpha = Math.random() * 0.35 + 0.1;
}
update() {
this.x += this.speedX;
this.y += this.speedY;
if (this.x < 0 || this.x > canvas.width || this.y < 0 || this.y > canvas.height) {
this.reset();
}
}
draw() {
ctx.fillStyle = `rgba(255, 255, 255, ${this.alpha})`;
ctx.fillRect(this.x, this.y, this.size, this.size);
}
const byId = (id) => document.getElementById(id);
const dom = {
canvas: byId('oracleCanvas'),
rollButton: byId('rollButton'),
stageHint: byId('stageHint'),
form: byId('askForm'),
input: byId('questionInput'),
submit: byId('submitBtn'),
soundToggle: byId('soundToggle'),
motionToggle: byId('motionToggle'),
backendStatus: byId('backendStatus'),
statusText: byId('statusText'),
charCount: byId('charCount'),
card: byId('telemetryCard'),
badge: byId('telemetryBadge'),
verdict: byId('tVerdict'),
noul: byId('tNoul'),
noulFill: byId('noulFill'),
confidence: byId('tConfidence'),
latency: byId('tLatency'),
cost: byId('tCost'),
json: byId('jsonDump'),
historyList: byId('historyList'),
historyCount: byId('historyCount'),
historyEmpty: byId('historyEmpty'),
toast: byId('toast'),
particles: byId('particles')
};
const audio = new OracleAudio(MUTED_KEY);
const particles = new ParticleField(dom.particles);
const ball = new OracleBall(dom.canvas, { reducedMotion: prefersReducedMotion() });
let busy = false;
let history = loadHistory();
let toastTimer = 0;
function prefersReducedMotion() {
return typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false;
}
function initParticles(count = 45) {
particles = Array.from({ length: count }, () => new Particle());
}
initParticles();
function animateParticles() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const p of particles) {
p.update();
p.draw();
}
requestAnimationFrame(animateParticles);
}
requestAnimationFrame(animateParticles);
// ─── AUDIO SYNTHESIZER ───
class OracleAudio {
constructor() {
this.ctx = null;
this.muted = localStorage.getItem('jev_muted') === 'true';
}
init() {
if (!this.ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) this.ctx = new AC();
}
if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
}
toggleMute() {
this.muted = !this.muted;
localStorage.setItem('jev_muted', this.muted);
return this.muted;
}
playTone(freq, type = 'sine', duration = 0.1, vol = 0.1) {
if (this.muted) return;
function storedMotionPreference() {
try {
this.init();
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(vol, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + duration);
} catch (e) {}
const stored = localStorage.getItem(MOTION_KEY);
if (stored === 'reduce') return true;
if (stored === 'full') return false;
} catch (e) {
/* storage unavailable: fall back to the media query */
}
rollSound() {
if (this.muted) return;
try {
this.init();
if (!this.ctx) return;
// Whirring resonant gyroscopic roll sweep
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(60, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(240, this.ctx.currentTime + 0.4);
osc.frequency.exponentialRampToValueAtTime(95, this.ctx.currentTime + 1.1);
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(450, this.ctx.currentTime);
filter.frequency.linearRampToValueAtTime(900, this.ctx.currentTime + 0.5);
filter.frequency.linearRampToValueAtTime(300, this.ctx.currentTime + 1.1);
gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
gain.gain.linearRampToValueAtTime(0.16, this.ctx.currentTime + 0.35);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 1.15);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 1.15);
} catch (e) {}
return prefersReducedMotion();
}
revealChime(sentiment = 'affirmative') {
if (this.muted) return;
if (sentiment === 'affirmative') {
this.playTone(440, 'triangle', 0.12, 0.15);
setTimeout(() => this.playTone(554.37, 'triangle', 0.15, 0.15), 80);
setTimeout(() => this.playTone(659.25, 'triangle', 0.25, 0.18), 160);
} else if (sentiment === 'negative') {
this.playTone(329.63, 'sawtooth', 0.14, 0.12);
setTimeout(() => this.playTone(277.18, 'sawtooth', 0.22, 0.14), 100);
} else {
this.playTone(392.00, 'sine', 0.18, 0.14);
setTimeout(() => this.playTone(440.00, 'sine', 0.22, 0.14), 110);
function setHint(key) {
dom.stageHint.textContent = STAGE_HINTS[key] || STAGE_HINTS.idle;
}
function showToast(message, kind = 'info', duration = 2400) {
clearTimeout(toastTimer);
dom.toast.textContent = message;
dom.toast.dataset.kind = kind;
dom.toast.classList.add('active');
toastTimer = setTimeout(() => dom.toast.classList.remove('active'), duration);
}
function setBusy(next) {
busy = next;
document.body.dataset.busy = String(next);
dom.submit.disabled = next;
dom.input.disabled = next;
dom.rollButton.disabled = next;
for (const pill of document.querySelectorAll('.quick-pill')) pill.disabled = next;
}
const audio = new OracleAudio();
// ─── DOM ELEMENTS ───
const ballWrapper = document.getElementById('ballWrapper');
const ballSphere = document.getElementById('ballSphere');
const ballShadow = document.getElementById('ballShadow');
const floatingDie = document.getElementById('floatingDie');
const dieText = document.getElementById('dieText');
const askForm = document.getElementById('askForm');
const questionInput = document.getElementById('questionInput');
const submitBtn = document.getElementById('submitBtn');
const btnText = document.getElementById('btnText');
const btnSpinner = document.getElementById('btnSpinner');
const soundToggle = document.getElementById('soundToggle');
const backendStatus = document.getElementById('backendStatus');
const statusText = document.getElementById('statusText');
const telemetryCard = document.getElementById('telemetryCard');
const telemetryBadge = document.getElementById('telemetryBadge');
const tVerdict = document.getElementById('tVerdict');
const tNoul = document.getElementById('tNoul');
const noulFill = document.getElementById('noulFill');
const tConfidence = document.getElementById('tConfidence');
const tLatency = document.getElementById('tLatency');
const tCost = document.getElementById('tCost');
const jsonDump = document.getElementById('jsonDump');
const historyList = document.getElementById('historyList');
const historyCount = document.getElementById('historyCount');
const toast = document.getElementById('toast');
let isSubmitting = false;
let historyData = loadHistory();
// Read the stored log defensively: a stale, hand-edited or truncated value must never
// break the interface, so anything that is not a well formed entry is dropped.
/** Reads the log defensively: a stale or hand-edited value must never break the page. */
function loadHistory() {
const sentiments = ['affirmative', 'negative', 'neutral'];
try {
const saved = localStorage.getItem('jev_history');
if (!saved) return [];
const parsed = JSON.parse(saved);
const raw = localStorage.getItem(HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(item) => item && typeof item.question === 'string' && typeof item.answer === 'string'
)
.slice(0, 25)
.slice(0, MAX_HISTORY)
.map((item) => ({
question: item.question,
answer: item.answer,
sentiment: sentiments.includes(item.sentiment) ? item.sentiment : 'neutral',
timestamp: typeof item.timestamp === 'string' ? item.timestamp : ''
sentiment: SENTIMENTS.includes(item.sentiment) ? item.sentiment : 'neutral',
timestamp: typeof item.timestamp === 'string' ? item.timestamp : '',
offline: Boolean(item.offline)
}));
} catch (e) {
return [];
}
}
// ─── SOUND TOGGLE ───
function updateSoundUI() {
soundToggle.textContent = audio.muted ? 'sound: off' : 'sound: on';
}
updateSoundUI();
soundToggle.addEventListener('click', () => {
audio.toggleMute();
updateSoundUI();
showToast(audio.muted ? 'Audio muted' : 'Audio enabled');
});
// ─── TOAST NOTIFICATION ───
let toastTimer = null;
function showToast(msg) {
clearTimeout(toastTimer);
toast.textContent = msg;
toast.classList.add('active');
toastTimer = setTimeout(() => {
toast.classList.remove('active');
}, 2200);
}
// ─── 3D PARALLAX TILT ON HOVER ───
window.addEventListener('pointermove', (e) => {
if (isSubmitting) return;
const rect = ballWrapper.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const deltaX = (e.clientX - centerX) / (window.innerWidth / 2);
const deltaY = (e.clientY - centerY) / (window.innerHeight / 2);
const tiltX = Math.max(-14, Math.min(14, -deltaY * 16));
const tiltY = Math.max(-14, Math.min(14, deltaX * 16));
ballSphere.style.transform = `rotateX(${tiltX}deg) rotateY(${tiltY}deg)`;
});
ballWrapper.addEventListener('mouseleave', () => {
if (!isSubmitting) {
ballSphere.style.transform = 'rotateX(0deg) rotateY(0deg)';
}
});
// Clicking hexagonal figure rolls it if input is non-empty, or prompts user
ballWrapper.addEventListener('click', () => {
if (isSubmitting) return;
if (questionInput.value.trim()) {
handleAsk(questionInput.value.trim());
} else {
questionInput.focus();
showToast('Type a question to consult Jev');
}
});
// ─── STATUS CHECK ───
async function checkStatus() {
function persistHistory() {
try {
const res = await fetch('/api/status');
if (res.ok) {
const data = await res.json();
backendStatus.classList.add('online');
statusText.textContent = data.model;
} else {
backendStatus.classList.remove('online');
statusText.textContent = 'status error';
}
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
} catch (e) {
backendStatus.classList.remove('online');
statusText.textContent = 'offline';
/* quota or private mode: keep the in-memory log */
}
}
checkStatus();
backendStatus.addEventListener('click', () => {
showToast('Checking Jev backend status...');
checkStatus();
});
// ─── QUICK PROMPTS ───
document.querySelectorAll('.quick-pill').forEach(pill => {
pill.addEventListener('click', () => {
if (isSubmitting) return;
const q = pill.getAttribute('data-q');
questionInput.value = q;
handleAsk(q);
});
});
// ─── FORM SUBMISSION ───
askForm.addEventListener('submit', (e) => {
e.preventDefault();
const q = questionInput.value.trim();
if (q) handleAsk(q);
});
async function handleAsk(question) {
if (isSubmitting) return;
isSubmitting = true;
// UI Loading state
submitBtn.disabled = true;
questionInput.disabled = true;
btnText.style.display = 'none';
btnSpinner.style.display = 'inline-block';
// 1. Roll Hexagonal Figure in Place & Submerge Die
ballSphere.classList.add('rolling');
if (ballShadow) ballShadow.classList.add('rolling-shadow');
floatingDie.className = 'floating-die submerged';
audio.rollSound();
const minRollTime = new Promise(resolve => setTimeout(resolve, 1250));
try {
// 2. Call /api/ask
const apiCall = fetch('/api/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question })
}).then(async (response) => {
let payload = null;
try {
payload = await response.json();
} catch (e) {
payload = null;
}
// A non-OK status is only usable when the backend shipped a fallback with it.
if (!response.ok && !(payload && payload.fallback)) {
throw new Error(
(payload && payload.error) || `Oracle returned HTTP ${response.status}`
);
}
return payload;
});
const [_, result] = await Promise.all([minRollTime, apiCall]);
// Remove rolling classes
ballSphere.classList.remove('rolling');
if (ballShadow) ballShadow.classList.remove('rolling-shadow');
ballSphere.style.transform = 'rotateX(0deg) rotateY(0deg)';
if (!result) {
throw new Error('Oracle returned an empty response.');
}
if (result.error && !result.fallback) {
showToast(result.error || 'Decision failed');
dieText.innerHTML = 'CANNOT<br>PREDICT';
floatingDie.className = 'floating-die surfacing';
return;
}
const data = result.fallback ? { ...result.fallback, question } : result;
displayOracleAnswer(data);
} catch (err) {
console.error('Request failed:', err);
ballSphere.classList.remove('rolling');
if (ballShadow) ballShadow.classList.remove('rolling-shadow');
ballSphere.style.transform = 'rotateX(0deg) rotateY(0deg)';
// Never show an affirmative prophecy for a request that failed.
dieText.innerHTML = 'CANNOT<br>PREDICT';
floatingDie.className = 'floating-die surfacing';
telemetryBadge.className = 'telemetry-badge neutral';
telemetryBadge.textContent = 'ERROR';
showToast(err && err.message ? err.message : 'Request failed. Please retry.');
} finally {
isSubmitting = false;
submitBtn.disabled = false;
questionInput.disabled = false;
btnText.style.display = 'inline';
btnSpinner.style.display = 'none';
questionInput.focus();
}
}
function displayOracleAnswer(data) {
const formattedText = String(data.answer || 'Cannot predict now')
.replace(/\s+/g, ' ')
.toUpperCase();
const words = formattedText.split(' ');
let htmlAnswer = formattedText;
if (words.length > 2) {
const mid = Math.ceil(words.length / 2);
htmlAnswer = words.slice(0, mid).join(' ') + '<br>' + words.slice(mid).join(' ');
}
dieText.innerHTML = htmlAnswer;
floatingDie.className = 'floating-die surfacing';
// Audio chime
audio.revealChime(data.sentiment);
// Update Telemetry. An offline fallback is a local hash guess, so it is labelled and
// must not be shown with the calibrated probability and confidence of a real decision.
const offline = Boolean(data.offline);
const sentiment = offline ? 'neutral' : (data.sentiment || 'neutral');
telemetryBadge.className = `telemetry-badge ${offline ? 'offline' : sentiment}`;
telemetryBadge.textContent = offline ? 'OFFLINE' : sentiment.toUpperCase();
tVerdict.textContent = data.answer;
tNoul.textContent = offline ? '—' : (data.noul !== undefined ? data.noul.toFixed(2) : '0.50');
noulFill.className = `meter-fill ${sentiment}`;
noulFill.style.width = offline ? '0%' : `${Math.round((data.noul || 0.5) * 100)}%`;
tConfidence.textContent = offline ? '—' : `${Math.round((data.confidence || 0.8) * 100)}%`;
tLatency.textContent = `${data.latency || 320}ms`;
tCost.textContent = offline ? '—' : data.cost ? `$${data.cost.toFixed(6)}` : '< $0.0001';
jsonDump.textContent = JSON.stringify(data.raw || data, null, 2);
// Append to History
addHistoryItem({
question: data.question,
answer: data.answer,
sentiment: data.sentiment,
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
});
if (offline) showToast('Offline guess — the oracle could not be reached');
}
function addHistoryItem(item) {
historyData.unshift(item);
if (historyData.length > 25) historyData.pop();
try {
localStorage.setItem('jev_history', JSON.stringify(historyData));
} catch (e) {}
renderHistory();
}
function renderHistory() {
historyList.innerHTML = '';
historyCount.textContent = `${historyData.length} ${historyData.length === 1 ? 'query' : 'queries'}`;
historyData.forEach((item, idx) => {
const li = document.createElement('li');
li.className = 'history-item';
li.innerHTML = `
<div class="history-left">
<div class="history-q" title="${escapeHtml(item.question)}">${escapeHtml(item.question)}</div>
<div class="history-time">${item.timestamp}</div>
</div>
<div class="history-pill ${item.sentiment}">
${escapeHtml(item.answer)}
</div>
`;
li.style.cursor = 'pointer';
li.addEventListener('click', () => {
navigator.clipboard.writeText(`Q: ${item.question}\nA: ${item.answer}`).then(() => {
showToast('Copied fortune to clipboard');
});
});
historyList.appendChild(li);
});
}
function escapeHtml(value) {
return String(value ?? '')
@ -463,6 +148,317 @@
.replace(/"/g, '&quot;');
}
renderHistory();
function renderHistory() {
dom.historyList.innerHTML = '';
dom.historyCount.textContent = `${history.length} ${history.length === 1 ? 'query' : 'queries'}`;
dom.historyEmpty.hidden = history.length > 0;
})();
history.forEach((item) => {
const li = document.createElement('li');
li.className = 'history-item';
li.tabIndex = 0;
li.setAttribute('role', 'button');
li.title = 'Copy this fortune';
li.innerHTML =
'<div class="history-left">' +
`<div class="history-q">${escapeHtml(item.question)}</div>` +
`<div class="history-time">${escapeHtml(item.timestamp)}${item.offline ? ' · offline' : ''}</div>` +
'</div>' +
`<div class="history-pill ${item.sentiment}">${escapeHtml(item.answer)}</div>`;
const copy = () => copyFortune(item);
li.addEventListener('click', copy);
li.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
copy();
}
});
dom.historyList.appendChild(li);
});
}
async function copyFortune(item) {
const text = `Q: ${item.question}\nA: ${item.answer}`;
try {
await navigator.clipboard.writeText(text);
audio.blip();
showToast('copied fortune to clipboard');
} catch (e) {
showToast('clipboard unavailable in this browser', 'warn');
}
}
function pushHistory(entry) {
history.unshift(entry);
if (history.length > MAX_HISTORY) history.length = MAX_HISTORY;
persistHistory();
renderHistory();
}
/* ─── telemetry ─── */
function toneFor(data) {
if (data.offline) return 'offline';
return SENTIMENTS.includes(data.sentiment) ? data.sentiment : 'neutral';
}
function setPending() {
dom.card.dataset.tone = 'pending';
dom.badge.className = 'telemetry-badge';
dom.badge.textContent = 'QUERYING';
dom.verdict.textContent = 'the oracle is considering your question…';
dom.noul.textContent = '—';
dom.noulFill.className = 'meter-fill';
dom.noulFill.style.width = '0%';
dom.confidence.textContent = '—';
dom.latency.textContent = '—';
dom.cost.textContent = '—';
dom.json.textContent = '// awaiting decision…';
}
function presentError(error) {
dom.card.dataset.tone = 'error';
dom.badge.className = 'telemetry-badge error';
dom.badge.textContent = 'ERROR';
dom.verdict.textContent = error && error.message ? error.message : 'the request failed';
dom.noul.textContent = '—';
dom.noulFill.className = 'meter-fill';
dom.noulFill.style.width = '0%';
dom.confidence.textContent = '—';
dom.latency.textContent = '—';
dom.cost.textContent = '—';
dom.json.textContent = '// no decision payload';
setHint('error');
}
function present(data) {
const tone = toneFor(data);
const offline = tone === 'offline';
ball.answer(data.answer || 'Cannot predict now', tone);
audio.reveal(offline ? 'neutral' : tone);
particles.setTone(TONE_HEX[tone]);
setHint('ready');
dom.card.dataset.tone = tone;
dom.badge.className = `telemetry-badge ${tone}`;
dom.badge.textContent = offline ? 'OFFLINE' : tone.toUpperCase();
dom.verdict.textContent = data.answer || '—';
// An offline verdict is a local hash guess: never dress it up as a calibrated one.
dom.noul.textContent = offline
? '—'
: typeof data.noul === 'number'
? data.noul.toFixed(2)
: '0.50';
dom.noulFill.className = `meter-fill ${offline ? '' : tone}`.trim();
dom.noulFill.style.width = offline ? '0%' : `${Math.round((data.noul || 0.5) * 100)}%`;
dom.confidence.textContent = offline
? '—'
: `${Math.round((data.confidence || 0.8) * 100)}%`;
dom.latency.textContent = `${data.latency || 0}ms`;
dom.cost.textContent = offline
? '—'
: typeof data.cost === 'number' && data.cost > 0
? `$${data.cost.toFixed(6)}`
: '< $0.0001';
dom.json.textContent = stringify(data.raw || data);
pushHistory({
question: data.question,
answer: data.answer,
sentiment: offline ? 'neutral' : tone,
offline,
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
});
if (offline) showToast('offline guess — the oracle could not be reached', 'warn', 3200);
}
function stringify(value) {
try {
return JSON.stringify(value, null, 2);
} catch (e) {
return '// payload could not be serialised';
}
}
/* ─── orchestration ─── */
function updateCharCount() {
dom.charCount.textContent = String(dom.input.value.length);
}
function submitQuestion(raw) {
if (busy) return;
const question = String(raw || '').trim().slice(0, QUESTION_LIMIT);
if (!question) {
showToast('type a question first', 'warn');
dom.input.focus();
return;
}
dom.input.value = question;
updateCharCount();
ask(question);
}
async function ask(question) {
setBusy(true);
setPending();
setHint('rolling');
audio.roll(ball.rollDuration);
const roll = ball.roll();
try {
const response = await fetch('/api/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question })
});
let payload = null;
try {
payload = await response.json();
} catch (e) {
payload = null;
}
// A non-OK status is only usable when the backend shipped a fallback with it.
if (!response.ok && !(payload && payload.fallback)) {
throw new Error((payload && payload.error) || `oracle returned HTTP ${response.status}`);
}
if (!payload) throw new Error('oracle returned an empty response');
await roll;
present(payload.fallback ? { ...payload.fallback, question } : payload);
} catch (error) {
await roll; // let the shake finish so the interface never snaps
console.error('oracle request failed:', error);
audio.error();
presentError(error);
showToast((error && error.message) || 'request failed', 'error', 3200);
} finally {
setBusy(false);
}
}
async function checkStatus() {
try {
const response = await fetch('/api/status', { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
dom.backendStatus.classList.remove('online', 'offline', 'degraded');
dom.backendStatus.classList.add(data.hasKey === false ? 'degraded' : 'online');
dom.statusText.textContent = data.model || 'online';
dom.backendStatus.title = `key: ${data.keySource || 'unknown'} — click to re-check`;
} catch (error) {
dom.backendStatus.classList.remove('online', 'degraded');
dom.backendStatus.classList.add('offline');
dom.statusText.textContent = 'backend offline';
dom.backendStatus.title = 'Backend unreachable — click to retry';
}
}
/* ─── preferences ─── */
let reducedMotion = storedMotionPreference();
function applyMotion(reduced, { persist = true, announce = false } = {}) {
reducedMotion = reduced;
document.body.dataset.motion = reduced ? 'reduced' : 'full';
dom.motionToggle.textContent = reduced ? 'motion: reduced' : 'motion: full';
dom.motionToggle.setAttribute('aria-pressed', String(reduced));
ball.setReducedMotion(reduced);
if (persist) {
try {
localStorage.setItem(MOTION_KEY, reduced ? 'reduce' : 'full');
} catch (e) {
/* private mode: preference applies for this session only */
}
}
if (announce) showToast(reduced ? 'animation reduced' : 'animation restored');
}
/* ─── wiring ─── */
function wire() {
dom.form.addEventListener('submit', (event) => {
event.preventDefault();
submitQuestion(dom.input.value);
});
// The vessel itself acts as the submit button for the current question.
dom.rollButton.addEventListener('click', () => submitQuestion(dom.input.value));
dom.input.addEventListener('input', updateCharCount);
for (const pill of document.querySelectorAll('.quick-pill')) {
pill.addEventListener('click', () => submitQuestion(pill.dataset.q));
}
dom.soundToggle.addEventListener('click', () => {
const muted = audio.toggleMute();
dom.soundToggle.textContent = muted ? 'sound: off' : 'sound: on';
dom.soundToggle.setAttribute('aria-pressed', String(muted));
if (!muted) audio.blip();
showToast(muted ? 'audio muted' : 'audio enabled');
});
dom.motionToggle.addEventListener('click', () =>
applyMotion(!reducedMotion, { announce: true })
);
dom.backendStatus.addEventListener('click', () => {
dom.statusText.textContent = 'checking…';
checkStatus();
});
document.addEventListener('keydown', (event) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const typing = event.target instanceof HTMLInputElement;
if (event.key === '/' && !typing) {
event.preventDefault();
dom.input.focus();
} else if (event.key === 'r' && !typing && !busy) {
event.preventDefault();
submitQuestion(dom.input.value);
} else if (event.key === 'Escape' && typing) {
event.target.blur();
}
});
document.addEventListener('visibilitychange', () => {
if (document.hidden) particles.stop();
else particles.start();
});
if (typeof window.matchMedia === 'function') {
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
const onChange = (event) => {
let overridden = false;
try {
overridden = Boolean(localStorage.getItem(MOTION_KEY));
} catch (e) {
overridden = false;
}
if (!overridden) applyMotion(event.matches, { persist: false });
};
if (query.addEventListener) query.addEventListener('change', onChange);
else if (query.addListener) query.addListener(onChange);
}
}
function init() {
applyMotion(storedMotionPreference(), { persist: false });
ball.attach();
particles.setTone(TONE_HEX.idle);
particles.start();
renderHistory();
updateCharCount();
setHint('idle');
wire();
checkStatus();
}
init();

129
public/js/audio.js Normal file
View file

@ -0,0 +1,129 @@
/**
* Procedural audio: everything is synthesised with the Web Audio API, so the page
* ships no audio assets. Every method is a no-op when muted or when Web Audio is
* unavailable, which keeps callers free of guards.
*/
export class OracleAudio {
constructor(storageKey = 'jev_muted') {
this.storageKey = storageKey;
this.context = null;
this.muted = readMuted(storageKey);
}
init() {
if (!this.context) {
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return null;
try {
this.context = new Ctor();
} catch (e) {
return null;
}
}
if (this.context.state === 'suspended') this.context.resume().catch(() => {});
return this.context;
}
toggleMute() {
this.muted = !this.muted;
try {
localStorage.setItem(this.storageKey, String(this.muted));
} catch (e) {
/* storage may be unavailable; mute still applies for this session */
}
return this.muted;
}
/** One shaped sine/saw blip with an exponential decay. */
tone({ frequency, type = 'sine', duration = 0.12, gain = 0.12, delay = 0, sweepTo, filter }) {
if (this.muted) return;
const context = this.init();
if (!context) return;
try {
const start = context.currentTime + delay;
const osc = context.createOscillator();
const amp = context.createGain();
osc.type = type;
osc.frequency.setValueAtTime(frequency, start);
if (sweepTo) osc.frequency.exponentialRampToValueAtTime(sweepTo, start + duration);
amp.gain.setValueAtTime(gain, start);
amp.gain.exponentialRampToValueAtTime(0.0001, start + duration);
let tail = osc;
if (filter) {
const biquad = context.createBiquadFilter();
biquad.type = 'lowpass';
biquad.frequency.setValueAtTime(filter.from, start);
biquad.frequency.linearRampToValueAtTime(filter.to, start + duration);
osc.connect(biquad);
tail = biquad;
}
tail.connect(amp);
amp.connect(context.destination);
osc.start(start);
osc.stop(start + duration + 0.02);
} catch (e) {
/* a failed blip must never break the interaction */
}
}
/** Rising metallic sweep while the vessel is being shaken. */
roll(durationSeconds = 1.2) {
this.tone({
frequency: 58,
sweepTo: 132,
type: 'sawtooth',
duration: durationSeconds * 0.7,
gain: 0.07,
filter: { from: 420, to: 1100 }
});
this.tone({
frequency: 44,
sweepTo: 36,
type: 'triangle',
duration: durationSeconds,
gain: 0.05
});
}
/** Chord that lands with the verdict, coloured by sentiment. */
reveal(sentiment = 'affirmative') {
const chords = {
affirmative: [
[440.0, 0],
[554.37, 0.075],
[659.25, 0.15]
],
negative: [
[329.63, 0],
[277.18, 0.1],
[196.0, 0.22]
],
neutral: [
[392.0, 0],
[466.16, 0.11]
]
};
const notes = chords[sentiment] || chords.neutral;
const type = sentiment === 'negative' ? 'sawtooth' : 'triangle';
for (const [frequency, delay] of notes) {
this.tone({ frequency, type, duration: 0.3, gain: 0.11, delay });
}
}
/** Short confirmation used when an answer is copied. */
blip() {
this.tone({ frequency: 880, type: 'square', duration: 0.06, gain: 0.05 });
}
error() {
this.tone({ frequency: 180, sweepTo: 120, type: 'square', duration: 0.18, gain: 0.05 });
}
}
function readMuted(storageKey) {
try {
return localStorage.getItem(storageKey) === 'true';
} catch (e) {
return false;
}
}

710
public/js/ball3d.js Normal file
View file

@ -0,0 +1,710 @@
/**
* Software 3D renderer + animation state machine for the oracle vessel.
*
* Deliberately Canvas 2D rather than WebGL: the scene is a low-poly hexagonal prism
* plus a hexagonal plate, so painter's-algorithm depth sorting with per-face lighting
* costs almost nothing, and it renders identically on machines with no GL drivers
* (headless browsers, VMs, older devices) where a WebGL context cannot be created.
*
* The vessel is convex and the plate floats inside it, so sorting faces by camera
* distance and drawing back-to-front is sufficient: no z-buffer needed.
*/
import {
affineFromThreePoints,
clamp,
dot,
hexToRgb,
lerp,
mat4LookAt,
mat4Mul,
mat4Perspective,
mat4RotateX,
mat4RotateY,
mat4RotateZ,
mat4Translate,
mixRgb,
normalize,
projectToScreen,
rgbCss,
smoothstep,
transformDir,
transformPoint
} from './math3d.js';
import { createPlate, createVessel } from './meshes.js';
const TAU = Math.PI * 2;
const LIGHT = normalize([-0.42, 0.7, 0.86]);
const VIEW = [0, 0, 1];
const BODY_BASE = hexToRgb('#191919');
const BODY_EDGE = hexToRgb('#2b2b30');
const CHAMBER_RGB = hexToRgb('#07070b');
const PLATE_BASE = hexToRgb('#1b1b1f');
const SPECULAR = [236, 245, 255];
const TONES = {
affirmative: hexToRgb('#10b981'),
negative: hexToRgb('#ef4444'),
neutral: hexToRgb('#38bdf8'),
offline: hexToRgb('#8f8f96'),
idle: hexToRgb('#38bdf8')
};
// Two slightly perturbed lights give the large faces a soft falloff without the cost
// of per-vertex normals. Faceted faces stay flat so the silhouette reads as machined.
const LIGHT_NEAR = normalize([LIGHT[0] - 0.35, LIGHT[1] + 0.32, LIGHT[2]]);
const LIGHT_FAR = normalize([LIGHT[0] + 0.32, LIGHT[1] - 0.36, LIGHT[2]]);
const FONT_STACK = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
/** Phases: rolling -> (answer arrives) -> revealing -> ready -> idle. */
export class OracleBall {
constructor(canvas, { reducedMotion = false } = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.reducedMotion = reducedMotion;
this.vessel = createVessel();
this.plate = createPlate();
this.radiusCss = 172;
this.width = 0;
this.height = 0;
this.dpr = 1;
this.clock = 0;
this.lastFrame = 0;
this.running = false;
this.raf = 0;
this.visible = true;
this.phase = 'idle';
this.rollStart = 0;
this.revealStart = 0;
this.holdTime = 0;
this.rollDuration = 1.25;
this.revealDuration = 0.9;
this.answerLines = ['CONCENTRATE', '& ASK'];
this.tone = 'idle';
this.pendingAnswer = null;
this.pointer = { x: 0, y: 0 };
this.tilt = { x: 0, y: 0 };
this.shake = 0;
this.glow = 0;
this.resolveRoll = null;
}
/* ─── public API ─── */
/** Starts the render loop, sizing, and pointer parallax. Safe to call twice. */
attach() {
if (this.running || !this.ctx) return;
this.running = true;
this._resize();
this._observeSize();
this._observeVisibility();
this.canvas.addEventListener('pointermove', this._onPointerMove);
this.canvas.addEventListener('pointerleave', this._onPointerLeave);
this.lastFrame = performance.now();
this.raf = requestAnimationFrame(this._frame);
}
detach() {
this.running = false;
cancelAnimationFrame(this.raf);
this.canvas.removeEventListener('pointermove', this._onPointerMove);
this.canvas.removeEventListener('pointerleave', this._onPointerLeave);
if (this.resizeObserver) this.resizeObserver.disconnect();
if (this.intersectionObserver) this.intersectionObserver.disconnect();
}
setReducedMotion(reduced) {
this.reducedMotion = reduced;
}
/** Resolves once the shake animation has played. */
roll() {
this.phase = 'rolling';
this.rollStart = this.clock;
this.holdTime = 0;
this.glow = 0;
this.answerLines = null;
return new Promise((resolve) => {
// Backstop so a paused render loop (hidden tab, off-screen canvas) can never
// leave the caller waiting forever.
setTimeout(() => {
if (this.resolveRoll === resolve) {
this.resolveRoll = null;
resolve();
}
}, Math.round(this.rollDuration * 1000) + 500);
this.resolveRoll = resolve;
});
}
/** Surfaces the die with the verdict text; ignored before a roll. */
answer(text, tone = 'neutral') {
const lines = wrapAnswer(text);
this.pendingAnswer = { lines, tone };
this.answerLines = lines;
this.tone = tone;
this.revealStart = this.clock;
this.tumbleAtReveal = this.platePose ? { ...this.platePose } : { rx: 0, ry: 0, rz: 0, y: -0.5 };
this.phase = 'revealing';
}
/** Returns the vessel to the resting state and clears the verdict. */
reset() {
this.answerLines = ['CONCENTRATE', '& ASK'];
this.tone = 'idle';
this.phase = 'idle';
this.glow = 0;
this.pendingAnswer = null;
}
/* ─── wiring ─── */
_onPointerMove = (event) => {
const rect = this.canvas.getBoundingClientRect();
this.pointer.x = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1);
this.pointer.y = clamp(((event.clientY - rect.top) / rect.height) * 2 - 1, -1, 1);
};
_onPointerLeave = () => {
this.pointer.x = 0;
this.pointer.y = 0;
};
_observeSize() {
if (typeof ResizeObserver === 'function') {
this.resizeObserver = new ResizeObserver(() => this._resize());
this.resizeObserver.observe(this.canvas.parentElement || this.canvas);
}
window.addEventListener('resize', this._onWindowResize);
}
_onWindowResize = () => this._resize();
_observeVisibility() {
if (typeof IntersectionObserver === 'function') {
this.intersectionObserver = new IntersectionObserver((entries) => {
this.visible = entries.some((entry) => entry.isIntersecting);
});
this.intersectionObserver.observe(this.canvas);
}
}
_resize() {
const rect = this.canvas.getBoundingClientRect();
const size = Math.max(160, Math.min(rect.width || 344, 460));
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
this.width = size;
this.height = size;
this.canvas.width = Math.round(size * this.dpr);
this.canvas.height = Math.round(size * this.dpr);
this.radiusCss = size;
}
/* ─── per-frame simulation ─── */
_frame = (now) => {
if (!this.running) return;
const dt = Math.min((now - this.lastFrame) / 1000, 0.05);
this.lastFrame = now;
// Freeze the clock while hidden or scrolled away so nothing jumps on return.
if (this.visible && !document.hidden) {
this.clock += dt;
this._update(dt);
this._render();
}
this.raf = requestAnimationFrame(this._frame);
};
_update(dt) {
const ease = Math.min(1, dt * 6);
this.tilt.x += ((this.reducedMotion ? 0 : this.pointer.y * 0.2) - this.tilt.x) * ease;
this.tilt.y += ((this.reducedMotion ? 0 : this.pointer.x * 0.3) - this.tilt.y) * ease;
const elapsed = this.clock - this.rollStart;
if (this.phase === 'rolling') {
this.rollProgress = this.reducedMotion ? 1 : clamp(elapsed / this.rollDuration, 0, 1);
this.shake = this.reducedMotion
? 0
: (1 - this.rollProgress * 0.6) * Math.sin(elapsed * 24) * 0.42;
this.liquidSlosh = this.reducedMotion ? 0 : 1;
if (elapsed >= this.rollDuration) {
this.holdTime += dt;
if (this.resolveRoll) {
const resolve = this.resolveRoll;
this.resolveRoll = null;
resolve();
}
}
this.platePose = this._tumblePose(elapsed);
this.answerAlpha = 0;
this.glow = this.reducedMotion ? 0 : 0.12 + Math.sin(elapsed * 9) * 0.06;
} else if (this.phase === 'revealing') {
const t = this.reducedMotion ? 1 : clamp((this.clock - this.revealStart) / this.revealDuration, 0, 1);
const settle = smoothstep(0, 0.72, t);
const from = this.tumbleAtReveal || { rx: 0, ry: 0, rz: 0, y: -0.5 };
// Unwind the tumble to zero so the plate always lands face-on to the camera.
this.platePose = {
rx: from.rx * (1 - settle),
ry: from.ry * (1 - settle),
rz: from.rz * (1 - settle),
y: lerp(from.y, 0.06, settle)
};
this.shake = this.reducedMotion ? 0 : (1 - t) * Math.sin(t * 18) * 0.12;
this.liquidSlosh = this.reducedMotion ? 0 : 1 - t;
this.answerAlpha = smoothstep(0.5, 1, t);
this.glow = Math.sin(t * Math.PI) * 1.1 + 0.16;
if (t >= 1) this.phase = 'ready';
} else {
const bob = this.reducedMotion ? 0 : Math.sin(this.clock * 1.15) * 0.022;
this.platePose = {
rx: this.reducedMotion ? 0 : Math.sin(this.clock * 0.42) * 0.06,
ry: this.reducedMotion ? 0 : Math.sin(this.clock * 0.55) * 0.24,
rz: this.reducedMotion ? 0 : Math.sin(this.clock * 0.37) * 0.05,
y: 0.02 + bob
};
this.liquidSlosh = this.reducedMotion ? 0 : 0.25;
this.answerAlpha = this.answerLines ? 1 : 0;
this.glow = this.phase === 'ready' ? 0.16 + Math.sin(this.clock * 1.6) * 0.04 : 0.06;
}
}
/** Churning pose used while the vessel is being shaken. */
_tumblePose(elapsed) {
const dive = smoothstep(0, this.rollDuration * 0.85, elapsed);
if (this.reducedMotion) {
return { rx: 0.5, ry: 0.6, rz: 0.2, y: lerp(0.02, -0.24, dive) };
}
return {
rx: elapsed * 5.2,
ry: elapsed * 3.05,
rz: elapsed * 2.15,
y: lerp(0.02, -0.5, dive)
};
}
get toneRgb() {
return TONES[this.tone] || TONES.idle;
}
/* ─── rendering ─── */
_render() {
const { ctx } = this;
const w = this.width;
const h = this.height;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
// Camera sits far enough that the vessel fills ~72% of the canvas height,
// leaving room for the shake, the rim glow, and the cast shadow.
const view = mat4LookAt([0, 0, 4.3], [0, 0, 0], [0, 1, 0]);
const proj = mat4Perspective((36 * Math.PI) / 180, 1, 0.1, 40);
this.viewProj = mat4Mul(proj, view);
const pose = this.platePose || { rx: 0, ry: 0, rz: 0, y: 0.02 };
const shakeZ = this.shake * 0.5;
const vesselModel = mat4Mul(
mat4RotateZ(shakeZ),
mat4Mul(
mat4RotateY(this.tilt.y + this.shake * 0.28),
mat4RotateX(this.tilt.x + Math.cos(this.clock * 9) * this.shake * 0.22)
)
);
const plateModel = mat4Mul(
mat4Translate(0, pose.y, 0),
mat4Mul(mat4RotateZ(pose.rz), mat4Mul(mat4RotateY(pose.ry), mat4RotateX(pose.rx)))
);
this._drawGroundGlow(w, h);
this._drawContactShadow(w, h);
const vessel = this._projectMesh(this.vessel.mesh, vesselModel);
const plate = this._projectMesh(this.plate.mesh, plateModel);
const windowPoly = this._projectWindow(vesselModel);
const liquid = windowPoly
? this._liquidGeometry(vesselModel, windowPoly)
: null;
const tone = this.toneRgb;
// 1. shell behind the chamber: back cap first, then the side facets
for (const face of vessel.faces) {
if (face.kind === 'ring') continue;
const base = face.kind === 'back' ? CHAMBER_RGB : BODY_BASE;
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'back' ? 0.05 : 0.55));
}
// 2. chamber contents, clipped to the window opening
if (windowPoly && liquid) {
ctx.save();
this._clipPolygon(windowPoly);
this._fillLiquid(liquid.points, tone);
for (const face of plate.faces) {
const base = face.kind === 'answer' ? PLATE_BASE : BODY_EDGE;
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'answer' ? 0.35 : 0.8));
}
if (this.answerLines && this.answerAlpha > 0.01) {
this._drawAnswerText(plate, windowPoly);
}
this._overlayLiquid(liquid, tone);
ctx.restore();
}
// 3. the front frame, nearest to the camera, drawn last
for (const face of vessel.faces) {
if (face.kind !== 'ring') continue;
this._fillFace(face, this._faceStyle(face, BODY_EDGE, tone, 0.5));
}
if (windowPoly) this._strokeWindow(windowPoly, tone);
}
/** Projects a mesh and sorts its faces back-to-front. */
_projectMesh(mesh, model) {
const mvp = mat4Mul(this.viewProj, model);
const screen = [];
const depth = [];
for (const vertex of mesh.vertices) {
const clip = transformPoint(mvp, vertex);
depth.push(clip[3]);
screen.push(projectToScreen(clip, this.width, this.height));
}
const faces = [];
for (const face of mesh.faces) {
if (face.indices.some((index) => !screen[index])) continue;
let sum = 0;
for (const index of face.indices) sum += depth[index];
faces.push({
kind: face.kind,
smooth: face.smooth,
normal: transformDir(model, face.normal),
depth: sum / face.indices.length,
points: face.indices.map((index) => screen[index])
});
}
faces.sort((a, b) => b.depth - a.depth);
return { faces, screen };
}
/** Projects the hexagonal opening onto the screen. */
_projectWindow(vesselModel) {
const mvp = mat4Mul(this.viewProj, vesselModel);
const { points, z } = this.vessel.window;
const projected = points.map(([x, y]) =>
projectToScreen(transformPoint(mvp, [x, y, z]), this.width, this.height)
);
return projected.every(Boolean) ? projected : null;
}
/** Screen-space liquid body: the window clipped to everything below the surface. */
_liquidGeometry(vesselModel, windowPoly) {
const mvp = mat4Mul(this.viewProj, vesselModel);
const { radius, z } = this.vessel.window;
const slosh = this.liquidSlosh || 0;
const level = -0.09 + Math.sin(this.clock * 8.5) * 0.05 * slosh;
const tilt = Math.sin(this.clock * 6.3) * 0.16 * slosh;
const left = projectToScreen(
transformPoint(mvp, [-radius * 1.35, level + tilt, z * 0.2]),
this.width,
this.height
);
const right = projectToScreen(
transformPoint(mvp, [radius * 1.35, level - tilt, z * 0.2]),
this.width,
this.height
);
if (!left || !right) return null;
return { points: clipPolygonBelow(windowPoly, left, right), surface: [left, right] };
}
_drawGroundGlow(w, h) {
const { ctx } = this;
const cx = w / 2;
const cy = h * 0.52;
const radius = w * 0.62;
const intensity = 0.3 + this.glow * 0.9;
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
gradient.addColorStop(0, rgbCss(this.toneRgb, Math.min(0.34, 0.2 * intensity)));
gradient.addColorStop(0.5, rgbCss(this.toneRgb, Math.min(0.12, 0.07 * intensity)));
gradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
}
_drawContactShadow(w, h) {
const { ctx } = this;
const lift = (this.platePose ? 0 : 0) + 0.02;
ctx.save();
ctx.translate(w / 2, h * 0.5 + h * 0.4 + lift);
ctx.scale(1, 0.2);
const radius = w * 0.31;
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
gradient.addColorStop(0, 'rgba(0,0,0,0.6)');
gradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.arc(0, 0, radius, 0, TAU);
ctx.fillStyle = gradient;
ctx.fill();
ctx.restore();
}
/* ─── shading & paint helpers ─── */
/** Blinn-Phong-ish shade: ambient + Lambert, specular, and a verdict-tinted rim. */
_shade(base, normal, light, tone, rimStrength) {
const diffuse = Math.max(0, dot(normal, light));
const half = normalize([light[0], light[1], light[2] + 1]);
const spec = Math.pow(Math.max(0, dot(normal, half)), 40) * 0.5;
const rim = Math.pow(1 - clamp(dot(normal, VIEW), 0, 1), 3) * rimStrength;
const lit = 0.13 + diffuse * 0.95;
const rgb = [
base[0] * lit + SPECULAR[0] * spec,
base[1] * lit + SPECULAR[1] * spec,
base[2] * lit + SPECULAR[2] * spec
];
return mixRgb(rgb, tone, clamp(rim * 0.55, 0, 1));
}
/** Solid fill for faceted faces, soft gradient for the large curved ones. */
_faceStyle(face, base, tone, rimStrength) {
if (!face.smooth) {
return rgbCss(this._shade(base, face.normal, LIGHT, tone, rimStrength));
}
const near = this._shade(base, face.normal, LIGHT_NEAR, tone, rimStrength);
const far = this._shade(base, face.normal, LIGHT_FAR, tone, rimStrength);
const bounds = boundsOf(face.points);
const gradient = this.ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
gradient.addColorStop(0, rgbCss(mixRgb(near, SPECULAR, 0.05)));
gradient.addColorStop(1, rgbCss(far));
return gradient;
}
_pathFromPoints(points) {
const ctx = this.ctx;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y);
ctx.closePath();
}
_clipPolygon(points) {
this._pathFromPoints(points);
this.ctx.clip();
}
_fillFace(face, style) {
if (face.points.length < 3) return;
const ctx = this.ctx;
this._pathFromPoints(face.points);
ctx.fillStyle = style;
ctx.fill();
// Hairline edge keeps the facets reading as machined metal rather than flat shapes.
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.stroke();
}
/* ─── liquid ─── */
_fillLiquid(points, tone) {
if (points.length < 3) return;
const ctx = this.ctx;
const bounds = boundsOf(points);
const gradient = ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.minX, bounds.maxY);
gradient.addColorStop(0, rgbCss(mixRgb(hexToRgb('#050a16'), tone, 0.24)));
gradient.addColorStop(0.6, rgbCss(hexToRgb('#03060d')));
gradient.addColorStop(1, rgbCss(mixRgb(hexToRgb('#010205'), tone, 0.12)));
this._pathFromPoints(points);
ctx.fillStyle = gradient;
ctx.fill();
}
_overlayLiquid(liquid, tone) {
const ctx = this.ctx;
const points = liquid.points;
if (points.length >= 3) {
const bounds = boundsOf(points);
const gradient = ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.minX, bounds.maxY);
gradient.addColorStop(0, rgbCss(tone, 0.22));
gradient.addColorStop(1, rgbCss(tone, 0.03));
this._pathFromPoints(points);
ctx.fillStyle = gradient;
ctx.fill();
}
const [a, b] = liquid.surface;
// drifting specular streaks just under the surface
for (let i = 0; i < 3; i += 1) {
const t = (this.clock * 0.33 + i / 3) % 1;
ctx.beginPath();
ctx.ellipse(lerp(a.x, b.x, t), lerp(a.y, b.y, t) + 3, 15, 3, 0, 0, TAU);
ctx.fillStyle = rgbCss(SPECULAR, 0.05);
ctx.fill();
}
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = rgbCss(mixRgb(tone, SPECULAR, 0.45), 0.55);
ctx.lineWidth = 1.4;
ctx.stroke();
}
_strokeWindow(windowPoly, tone) {
const ctx = this.ctx;
const bounds = boundsOf(windowPoly);
ctx.save();
this._pathFromPoints(windowPoly);
ctx.strokeStyle = rgbCss(mixRgb(tone, SPECULAR, 0.3), Math.min(0.75, 0.3 + this.glow * 0.35));
ctx.lineWidth = 1.5;
ctx.stroke();
this._pathFromPoints(windowPoly);
ctx.clip();
const cx = (bounds.minX + bounds.maxX) / 2;
const cy = (bounds.minY + bounds.maxY) / 2;
const radius = Math.max(bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) / 2;
const vignette = ctx.createRadialGradient(cx, cy, radius * 0.55, cx, cy, radius);
vignette.addColorStop(0, 'rgba(0,0,0,0)');
vignette.addColorStop(1, 'rgba(0,0,0,0.7)');
ctx.fillStyle = vignette;
ctx.fillRect(bounds.minX, bounds.minY, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY);
ctx.restore();
}
/* ─── verdict text mapped onto the die face ─── */
_drawAnswerText(plateView) {
const ctx = this.ctx;
const face = plateView.faces.find((candidate) => candidate.kind === 'answer');
if (!face || face.points.length < 6) return;
if (dot(face.normal, VIEW) < 0.3) return;
// The face keeps the plate's local vertex order, so three corners are enough to
// solve the affine transform from plate-local space into screen space.
const corners = [0, 3, 5].map((index) => ({
local: this.plate.points[index],
screen: [face.points[index].x, face.points[index].y]
}));
const matrix = affineFromThreePoints(corners[0], corners[1], corners[2]);
if (!matrix) return;
const lines = this.answerLines;
if (!lines || !lines.length) return;
const radius = this.plate.radius;
const usableWidth = radius * 1.5;
const maxChars = Math.max(4, ...lines.map((line) => line.length));
let fontSize = clamp(
Math.min(usableWidth / (maxChars * 0.62), (radius * 1.05) / lines.length),
0.05,
0.2
);
const pixelsPerUnit = Math.hypot(matrix[0], matrix[1]) / this.dpr;
ctx.save();
ctx.setTransform(
this.dpr * matrix[0],
this.dpr * matrix[1],
this.dpr * matrix[2],
this.dpr * matrix[3],
this.dpr * matrix[4],
this.dpr * matrix[5]
);
this._pathFromPoints(this.plate.points.map(([x, y]) => ({ x, y })));
ctx.clip();
// Shrink to fit if the monospace metrics run wide of the plate.
for (let attempt = 0; attempt < 3; attempt += 1) {
ctx.font = `700 ${fontSize}px ${FONT_STACK}`;
const widest = Math.max(...lines.map((line) => ctx.measureText(line).width));
if (widest <= usableWidth) break;
fontSize *= usableWidth / widest;
}
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.globalAlpha = this.answerAlpha;
ctx.fillStyle = '#f2f7ff';
ctx.shadowColor = rgbCss(this.toneRgb, 0.85);
ctx.shadowBlur = Math.max(2, fontSize * pixelsPerUnit * 0.45);
const lineHeight = fontSize * 1.3;
const top = -((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, index) => {
ctx.fillText(line, 0, top + index * lineHeight);
});
ctx.restore();
}
}
/* ─── helpers (exported for tests) ─── */
function boundsOf(points) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const point of points) {
if (point.x < minX) minX = point.x;
if (point.x > maxX) maxX = point.x;
if (point.y < minY) minY = point.y;
if (point.y > maxY) maxY = point.y;
}
return { minX, minY, maxX, maxY };
}
/**
* Sutherland–Hodgman clip of a convex polygon against the half-plane below the line
* a→b. Screen space has y pointing down, so "below the surface line" is a positive
* cross product.
*/
export function clipPolygonBelow(points, a, b) {
const out = [];
const side = (p) => (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
for (let i = 0; i < points.length; i += 1) {
const current = points[i];
const next = points[(i + 1) % points.length];
const currentSide = side(current);
const nextSide = side(next);
if (currentSide >= 0) out.push(current);
if (currentSide >= 0 !== nextSide >= 0) {
const t = currentSide / (currentSide - nextSide);
out.push({
x: current.x + (next.x - current.x) * t,
y: current.y + (next.y - current.y) * t
});
}
}
return out;
}
/** Upper-cases a verdict and wraps it into at most three plate-sized lines. */
export function wrapAnswer(text) {
const words = String(text || 'Cannot predict now')
.toUpperCase()
.replace(/\s+/g, ' ')
.trim()
.split(' ')
.filter(Boolean);
if (!words.length) return ['CANNOT', 'PREDICT'];
const lines = [];
let current = '';
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (!current || candidate.length <= 11) {
current = candidate;
} else {
lines.push(current);
current = word;
}
}
if (current) lines.push(current);
if (lines.length > 3) {
return [...lines.slice(0, 2), lines.slice(2).join(' ').slice(0, 14)];
}
return lines;
}

246
public/js/math3d.js Normal file
View file

@ -0,0 +1,246 @@
/**
* Minimal 3D math: vectors are [x, y, z] arrays, matrices are column-major
* Float32Array(16) (m[col * 4 + row]), matching the usual graphics convention.
*
* Everything here is pure so it can be unit tested outside a browser.
*/
export function vec3(x = 0, y = 0, z = 0) {
return [x, y, z];
}
export function add(a, b) {
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
}
export function sub(a, b) {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
export function mulScalar(a, s) {
return [a[0] * s, a[1] * s, a[2] * s];
}
export function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
export function cross(a, b) {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0]
];
}
export function length(a) {
return Math.hypot(a[0], a[1], a[2]);
}
export function normalize(a) {
const len = length(a);
return len === 0 ? [0, 0, 0] : [a[0] / len, a[1] / len, a[2] / len];
}
export function lerp(a, b, t) {
return a + (b - a) * t;
}
/** Reflection of incident vector `d` about normal `n`. */
export function reflect(d, n) {
const k = 2 * dot(n, d);
return [d[0] - k * n[0], d[1] - k * n[1], d[2] - k * n[2]];
}
export function clamp(value, min, max) {
return value < min ? min : value > max ? max : value;
}
/** Smooth 0..1 ramp, used for animation easing. */
export function smoothstep(edge0, edge1, x) {
const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
export function mat4Identity() {
const m = new Float32Array(16);
m[0] = m[5] = m[10] = m[15] = 1;
return m;
}
/** Returns a ∘ b (b applied first, then a). */
export function mat4Mul(a, b) {
const out = new Float32Array(16);
for (let col = 0; col < 4; col += 1) {
for (let row = 0; row < 4; row += 1) {
let sum = 0;
for (let k = 0; k < 4; k += 1) {
sum += a[k * 4 + row] * b[col * 4 + k];
}
out[col * 4 + row] = sum;
}
}
return out;
}
export function mat4Translate(x, y, z) {
const m = mat4Identity();
m[12] = x;
m[13] = y;
m[14] = z;
return m;
}
export function mat4Scale(x, y = x, z = x) {
const m = mat4Identity();
m[0] = x;
m[5] = y;
m[10] = z;
return m;
}
export function mat4RotateX(rad) {
const m = mat4Identity();
const c = Math.cos(rad);
const s = Math.sin(rad);
m[5] = c;
m[6] = s;
m[9] = -s;
m[10] = c;
return m;
}
export function mat4RotateY(rad) {
const m = mat4Identity();
const c = Math.cos(rad);
const s = Math.sin(rad);
m[0] = c;
m[2] = -s;
m[8] = s;
m[10] = c;
return m;
}
export function mat4RotateZ(rad) {
const m = mat4Identity();
const c = Math.cos(rad);
const s = Math.sin(rad);
m[0] = c;
m[1] = s;
m[4] = -s;
m[5] = c;
return m;
}
export function mat4Perspective(fovY, aspect, near, far) {
const f = 1 / Math.tan(fovY / 2);
const m = new Float32Array(16);
m[0] = f / aspect;
m[5] = f;
m[10] = (far + near) / (near - far);
m[11] = -1;
m[14] = (2 * far * near) / (near - far);
return m;
}
export function mat4LookAt(eye, target, up) {
const zAxis = normalize(sub(eye, target));
const xAxis = normalize(cross(up, zAxis));
const yAxis = cross(zAxis, xAxis);
const m = mat4Identity();
m[0] = xAxis[0];
m[1] = yAxis[0];
m[2] = zAxis[0];
m[4] = xAxis[1];
m[5] = yAxis[1];
m[6] = zAxis[1];
m[8] = xAxis[2];
m[9] = yAxis[2];
m[10] = zAxis[2];
m[12] = -dot(xAxis, eye);
m[13] = -dot(yAxis, eye);
m[14] = -dot(zAxis, eye);
return m;
}
/** Transforms a point (w = 1) and returns the full homogeneous result. */
export function transformPoint(m, p) {
return [
m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]
];
}
/** Transforms a direction (w = 0, ignores translation), then normalizes. */
export function transformDir(m, v) {
return normalize([
m[0] * v[0] + m[4] * v[1] + m[8] * v[2],
m[1] * v[0] + m[5] * v[1] + m[9] * v[2],
m[2] * v[0] + m[6] * v[1] + m[10] * v[2]
]);
}
/**
* Projects a homogeneous point into CSS-pixel screen space.
* Returns null when the point sits on or behind the camera plane.
*/
export function projectToScreen(clip, width, height) {
const w = clip[3];
if (w <= 1e-6) return null;
const ndcX = clip[0] / w;
const ndcY = clip[1] / w;
return {
x: (ndcX * 0.5 + 0.5) * width,
y: (0.5 - ndcY * 0.5) * height,
depth: clip[2] / w
};
}
/**
* Solves the 2D affine transform taking local (x, y) to screen (X, Y) from three
* point correspondences, returned as [a, b, c, d, e, f] for setTransform.
* Used to lay the answer text onto the projected die face.
*/
export function affineFromThreePoints(p1, p2, p3) {
const [x1, y1] = p1.local;
const [x2, y2] = p2.local;
const [x3, y3] = p3.local;
const den = (x1 - x3) * (y2 - y3) - (x2 - x3) * (y1 - y3);
if (Math.abs(den) < 1e-9) return null;
const s1 = p1.screen;
const s2 = p2.screen;
const s3 = p3.screen;
const a = ((s1[0] - s3[0]) * (y2 - y3) - (s2[0] - s3[0]) * (y1 - y3)) / den;
const c = ((x1 - x3) * (s2[0] - s3[0]) - (x2 - x3) * (s1[0] - s3[0])) / den;
const b = ((s1[1] - s3[1]) * (y2 - y3) - (s2[1] - s3[1]) * (y1 - y3)) / den;
const d = ((x1 - x3) * (s2[1] - s3[1]) - (x2 - x3) * (s1[1] - s3[1])) / den;
return [a, b, c, d, s1[0] - a * x1 - c * y1, s1[1] - b * x1 - d * y1];
}
/** Mixes two [r, g, b] colours (0..255) by t. */
export function mixRgb(from, to, t) {
return [
from[0] + (to[0] - from[0]) * t,
from[1] + (to[1] - from[1]) * t,
from[2] + (to[2] - from[2]) * t
];
}
export function rgbCss(rgb, alpha = 1) {
const r = Math.round(clamp(rgb[0], 0, 255));
const g = Math.round(clamp(rgb[1], 0, 255));
const b = Math.round(clamp(rgb[2], 0, 255));
return alpha >= 1 ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${clamp(alpha, 0, 1)})`;
}
/** Parses '#rrggbb' into [r, g, b]. */
export function hexToRgb(hex) {
const value = hex.replace('#', '');
return [
parseInt(value.slice(0, 2), 16),
parseInt(value.slice(2, 4), 16),
parseInt(value.slice(4, 6), 16)
];
}

101
public/js/meshes.js Normal file
View file

@ -0,0 +1,101 @@
/**
* Mesh builders for the oracle vessel. Pure data in, pure data out: every mesh is
* `{ vertices: [[x, y, z]], faces: [{ indices, normal, kind, smooth }] }`, which the
* renderer walks generically.
*/
/** Point-up hexagon vertices (matches the CSS clip-path used elsewhere). */
export function hexagon(radius, rotation = Math.PI / 2) {
return Array.from({ length: 6 }, (_, i) => {
const angle = rotation + (i * Math.PI) / 3;
return [Math.cos(angle) * radius, Math.sin(angle) * radius];
});
}
function centroid(vertices, indices) {
const sum = [0, 0, 0];
for (const index of indices) {
const v = vertices[index];
sum[0] += v[0];
sum[1] += v[1];
sum[2] += v[2];
}
const n = indices.length || 1;
return [sum[0] / n, sum[1] / n, sum[2] / n];
}
/**
* The outer shell: a hexagonal prism whose front cap has a hexagonal opening so the
* liquid chamber inside is visible through the frame.
*/
export function createVessel({ outerRadius = 1, depth = 0.52, windowRadius = 0.63 } = {}) {
const half = depth / 2;
const inner = hexagon(windowRadius);
const vertices = [];
const push = (x, y, z) => vertices.push([x, y, z]) - 1;
const points = hexagon(outerRadius);
const outerBack = points.map(([x, y]) => push(x, y, -half));
const outerFront = points.map(([x, y]) => push(x, y, half));
const innerFront = inner.map(([x, y]) => push(x, y, half));
const faces = [
{ indices: [...outerBack].reverse(), normal: [0, 0, -1], kind: 'back', smooth: true }
];
for (let i = 0; i < 6; i += 1) {
const j = (i + 1) % 6;
const mid = Math.PI / 2 + ((i + 0.5) * Math.PI) / 3;
faces.push({
indices: [outerBack[i], outerBack[j], outerFront[j], outerFront[i]],
normal: [Math.cos(mid), Math.sin(mid), 0],
kind: 'side',
smooth: true
});
faces.push({
indices: [outerFront[i], outerFront[j], innerFront[j], innerFront[i]],
normal: [0, 0, 1],
kind: 'ring',
smooth: false
});
}
return {
mesh: { vertices, faces },
window: { radius: windowRadius, z: half, points: inner },
extents: { outerRadius, depth, windowRadius }
};
}
/** The floating die: a flat hexagonal plate that can settle face-on to the answer. */
export function createPlate({ radius = 0.44, thickness = 0.16 } = {}) {
const half = thickness / 2;
const points = hexagon(radius);
const vertices = [];
const push = (x, y, z) => vertices.push([x, y, z]) - 1;
const front = points.map(([x, y]) => push(x, y, half));
const back = points.map(([x, y]) => push(x, y, -half));
const faces = [
{ indices: front, normal: [0, 0, 1], kind: 'answer', smooth: false },
{ indices: [...back].reverse(), normal: [0, 0, -1], kind: 'plate-back', smooth: false }
];
for (let i = 0; i < 6; i += 1) {
const j = (i + 1) % 6;
const mid = Math.PI / 2 + ((i + 0.5) * Math.PI) / 3;
faces.push({
indices: [back[i], back[j], front[j], front[i]],
normal: [Math.cos(mid), Math.sin(mid), 0],
kind: 'plate-rim',
smooth: false
});
}
return { mesh: { vertices, faces }, points, radius, thickness };
}
export function faceCentroid(vertices, indices) {
return centroid(vertices, indices);
}

104
public/js/particles.js Normal file
View file

@ -0,0 +1,104 @@
/**
* Ambient particle field behind the interface.
*
* Cheap by construction: a small fixed particle count, device-pixel-ratio aware,
* paused whenever the tab is hidden, and drawn as plain rects so there is no
* per-particle path allocation.
*/
export class ParticleField {
constructor(canvas, { count = 46, tone = '#38bdf8' } = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.count = count;
this.tone = tone;
this.particles = [];
this.running = false;
this.raf = 0;
this.dpr = 1;
this.width = 0;
this.height = 0;
this.clock = 0;
this.last = 0;
this.resize();
window.addEventListener('resize', this.resize);
}
resize = () => {
const width = window.innerWidth;
const height = window.innerHeight;
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
this.width = width;
this.height = height;
this.canvas.width = Math.round(width * this.dpr);
this.canvas.height = Math.round(height * this.dpr);
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
this.seed();
};
seed() {
this.particles = Array.from({ length: this.count }, () => ({
x: Math.random() * this.width,
y: Math.random() * this.height,
size: Math.random() * 1.6 + 0.5,
vx: (Math.random() - 0.5) * 12,
vy: (Math.random() - 0.5) * 12,
alpha: Math.random() * 0.3 + 0.08,
phase: Math.random() * Math.PI * 2
}));
}
setTone(tone) {
this.tone = tone;
}
start() {
if (this.running) return;
this.running = true;
this.last = performance.now();
this.raf = requestAnimationFrame(this.frame);
}
stop() {
this.running = false;
cancelAnimationFrame(this.raf);
}
frame = (now) => {
if (!this.running) return;
const dt = Math.min((now - this.last) / 1000, 0.05);
this.last = now;
if (!document.hidden) {
this.clock += dt;
this.update(dt);
this.draw();
}
this.raf = requestAnimationFrame(this.frame);
};
update(dt) {
for (const particle of this.particles) {
particle.x += particle.vx * dt;
particle.y += particle.vy * dt;
if (particle.x < -4) particle.x = this.width + 4;
if (particle.x > this.width + 4) particle.x = -4;
if (particle.y < -4) particle.y = this.height + 4;
if (particle.y > this.height + 4) particle.y = -4;
}
}
draw() {
const ctx = this.ctx;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
ctx.fillStyle = this.tone;
for (const particle of this.particles) {
const twinkle = 0.72 + Math.sin(this.clock * 0.9 + particle.phase) * 0.28;
ctx.globalAlpha = particle.alpha * twinkle;
ctx.fillRect(particle.x, particle.y, particle.size, particle.size);
}
ctx.globalAlpha = 1;
}
}

View file

@ -1,20 +1,28 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
import 'dotenv/config';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import cors from 'cors';
import express from 'express';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.disable('x-powered-by');
const PORT = parseInt(process.env.PORT, 10) || 3000;
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
// The repo intentionally carries a key so a fresh clone (or deploy) works with no
// secret configured. Delete this constant and set OPENROUTER_API_KEY to require one.
const REPO_API_KEY = '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';
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || REPO_API_KEY;
const JEV_DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
const MODEL = 'typesafe/jev-1.13';
const LAYA_API_URL = process.env.LAYA_API_URL || 'http://192.168.0.103:8888';
if (!OPENROUTER_API_KEY) {
console.warn(
'[jev-8ball] OPENROUTER_API_KEY is not set — /api/ask will answer 503. Add it to .env (see .env.example).'
);
if (!process.env.OPENROUTER_API_KEY) {
console.warn('[jev-8ball] OPENROUTER_API_KEY is not set — using the in-repo key.');
}
app.use(cors());
@ -48,12 +56,31 @@ const APHORISMS = {
};
// GET /api/status
app.get('/api/status', (req, res) => {
app.get('/api/status', async (req, res) => {
let layaOnline = false;
let layaData = null;
try {
const layaRes = await fetch(`${LAYA_API_URL}/api/status`, {
signal: AbortSignal.timeout(1200)
});
if (layaRes.ok) {
layaData = await layaRes.json();
layaOnline = Boolean(layaData.status === 'online' || layaData.ready);
}
} catch (err) {
layaOnline = false;
}
res.json({
status: 'online',
model: MODEL,
engine: 'TypeSafe Jev (Non-autoregressive System One)',
hasKey: Boolean(OPENROUTER_API_KEY)
model: layaOnline ? (layaData?.model || 'convaiinnovations/laya-typed-decisions') : MODEL,
engine: layaOnline ? 'Laya System 1 Decision Model (Android Tablet)' : 'TypeSafe Jev (Cloud Decision Engine)',
tablet: layaOnline,
layaUrl: LAYA_API_URL,
layaInfo: layaData,
hasKey: Boolean(OPENROUTER_API_KEY),
keySource: process.env.OPENROUTER_API_KEY ? 'env' : 'repo'
});
});
@ -71,6 +98,44 @@ app.post('/api/ask', async (req, res) => {
const cleanQuestion = question.trim().slice(0, 300);
const startTime = performance.now();
// 1. Attempt inference on Android Tablet running Laya System 1
try {
const layaRes = await fetch(`${LAYA_API_URL}/api/ask`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: cleanQuestion }),
signal: AbortSignal.timeout(6500)
});
if (layaRes.ok) {
const data = await layaRes.json();
const latency = Math.round(performance.now() - startTime);
return res.json({
question: cleanQuestion,
answer: data.answer,
sentiment: data.sentiment,
noul: data.noul,
confidence: data.confidence,
probabilities: data.probabilities || {},
aphorismKey: data.aphorismKey || 'signs_yes',
latency,
cost: 0,
usage: { prompt_tokens: data.raw?.usage?.input_tokens ?? 38, completion_tokens: 0 },
model: data.model || 'convaiinnovations/laya-typed-decisions',
engine: 'Laya System 1 (Android Tablet ARM64)',
tablet: true,
raw: data
});
}
} catch (layaErr) {
// Graceful fallback to OpenRouter Jev cloud API
console.warn(`[jev-8ball] Tablet Laya not reachable (${layaErr.message}), falling back to OpenRouter Jev`);
}
if (!OPENROUTER_API_KEY) {
return res.status(503).json({ error: 'Oracle is not configured. Set OPENROUTER_API_KEY.' });
}
try {
const payload = {
model: MODEL,
@ -223,5 +288,6 @@ app.use((req, res) => {
app.listen(PORT, () => {
console.log(`[jev-8ball] Server running at http://localhost:${PORT}`);
console.log(`[jev-8ball] Model: ${MODEL} via OpenRouter Decisions API`);
console.log(`[jev-8ball] Primary Engine: Laya on Android Tablet (${LAYA_API_URL})`);
console.log(`[jev-8ball] Fallback Engine: ${MODEL} via OpenRouter Decisions API`);
});

203
test/frontend.test.mjs Normal file
View file

@ -0,0 +1,203 @@
/**
* Unit tests for the hand-rolled 3D layer. These are pure functions, so they run in
* Node with no browser: `npm test`.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { clipPolygonBelow, wrapAnswer } from '../public/js/ball3d.js';
import {
affineFromThreePoints,
clamp,
cross,
dot,
hexToRgb,
mat4LookAt,
mat4Mul,
mat4Perspective,
mat4RotateZ,
mat4Translate,
mixRgb,
normalize,
projectToScreen,
rgbCss,
smoothstep,
transformDir,
transformPoint
} from '../public/js/math3d.js';
import { createPlate, createVessel, hexagon } from '../public/js/meshes.js';
const near = (a, b, tolerance = 1e-5) => Math.abs(a - b) <= tolerance;
const nearVec = (a, b, tolerance = 1e-5) =>
a.every((value, index) => near(value, b[index], tolerance));
test('vector helpers behave', () => {
assert.ok(nearVec(normalize([3, 0, 0]), [1, 0, 0]));
assert.ok(nearVec(normalize([0, 0, 0]), [0, 0, 0]));
assert.equal(dot([1, 2, 3], [4, -5, 6]), 12);
assert.ok(nearVec(cross([1, 0, 0], [0, 1, 0]), [0, 0, 1]));
assert.equal(clamp(5, 0, 1), 1);
assert.equal(smoothstep(0, 1, 0.5), 0.5);
assert.equal(smoothstep(0, 1, -3), 0);
assert.equal(smoothstep(0, 1, 3), 1);
assert.deepEqual(hexToRgb('#10b981'), [16, 185, 129]);
assert.equal(rgbCss([300, -5, 10.4]), 'rgb(255,0,10)');
assert.equal(rgbCss([10, 20, 30], 0.5), 'rgba(10,20,30,0.5)');
assert.ok(nearVec(mixRgb([0, 0, 0], [10, 20, 30], 0.5), [5, 10, 15]));
});
test('matrices compose in the documented order', () => {
const translated = mat4Mul(mat4Translate(1, 2, 3), mat4Translate(10, 20, 30));
assert.ok(nearVec(transformPoint(translated, [0, 0, 0]).slice(0, 3), [11, 22, 33]));
const rotated = mat4RotateZ(Math.PI / 2);
assert.ok(nearVec(transformPoint(rotated, [1, 0, 0]).slice(0, 3), [0, 1, 0], 1e-6));
assert.ok(nearVec(transformDir(rotated, [1, 0, 0]), [0, 1, 0], 1e-6));
});
test('camera projection keeps the vessel inside the viewport', () => {
const view = mat4LookAt([0, 0, 4.3], [0, 0, 0], [0, 1, 0]);
const projection = mat4Mul(mat4Perspective((36 * Math.PI) / 180, 1, 0.1, 40), view);
const size = 344;
const centre = projectToScreen(transformPoint(projection, [0, 0, 0]), size, size);
assert.ok(near(centre.x, size / 2) && near(centre.y, size / 2));
// A point on the vessel's silhouette should land at ~72% of the half-height, which
// is what leaves room for the shake and the cast shadow.
const edge = projectToScreen(transformPoint(projection, [1, 0, 0]), size, size);
const fraction = (edge.x - size / 2) / (size / 2);
assert.ok(fraction > 0.68 && fraction < 0.76, `silhouette fraction was ${fraction}`);
// Far side of the vessel must sort behind the origin for the painter's algorithm,
// and perspective must foreshorten the same height as depth increases.
const back = projectToScreen(transformPoint(projection, [0, 0, -1]), size, size);
assert.ok(back.depth > centre.depth, 'NDC depth must grow with distance');
const nearTop = projectToScreen(transformPoint(projection, [0, 1, 0]), size, size);
const farTop = projectToScreen(transformPoint(projection, [0, 1, -1]), size, size);
assert.ok(nearTop.y < size / 2, 'a raised point must project above the centre row');
assert.ok(farTop.y > nearTop.y, 'the same height must foreshorten when it is further away');
});
test('affine solve maps local points onto screen points', () => {
const matrix = affineFromThreePoints(
{ local: [0, 0], screen: [10, 20] },
{ local: [1, 0], screen: [12, 20] },
{ local: [0, 1], screen: [10, 22] }
);
assert.ok(nearVec(matrix, [2, 0, 0, 2, 10, 20]));
// Degenerate (collinear) inputs must not produce a usable transform.
assert.equal(
affineFromThreePoints(
{ local: [0, 0], screen: [0, 0] },
{ local: [1, 1], screen: [1, 1] },
{ local: [2, 2], screen: [2, 2] }
),
null
);
});
test('hexagon vertices are point-up and evenly spaced', () => {
const points = hexagon(2);
assert.equal(points.length, 6);
assert.ok(nearVec(points[0], [0, 2], 1e-9));
for (const point of points) {
assert.ok(near(Math.hypot(point[0], point[1]), 2));
}
// Neighbouring vertices must be exactly one radius apart on a regular hexagon.
assert.ok(near(Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]), 2));
});
test('vessel mesh is closed, outward-facing, and unit-normalled', () => {
const { mesh, window: opening } = createVessel();
assert.equal(mesh.vertices.length, 18);
assert.equal(mesh.faces.length, 13); // back cap + 6 sides + 6 frame pieces
for (const face of mesh.faces) {
assert.ok(face.indices.length >= 3 && face.indices.length <= 6);
for (const index of face.indices) {
assert.ok(index >= 0 && index < mesh.vertices.length, 'face index out of range');
}
assert.ok(near(Math.hypot(...face.normal), 1), 'normal must be unit length');
}
// Side facets must face away from the axis, caps must face along ±z.
for (const face of mesh.faces) {
if (face.kind === 'side') {
const centre = face.indices
.map((index) => mesh.vertices[index])
.reduce((sum, vertex) => [sum[0] + vertex[0], sum[1] + vertex[1]], [0, 0]);
assert.ok(dot(face.normal, [centre[0], centre[1], 0]) > 0, 'side normal points inward');
}
if (face.kind === 'back') assert.deepEqual(face.normal, [0, 0, -1]);
if (face.kind === 'ring') assert.deepEqual(face.normal, [0, 0, 1]);
}
// The opening is smaller than the body and sits on the front plane.
assert.ok(opening.radius > 0 && opening.radius < 1);
assert.ok(opening.z > 0);
assert.equal(opening.points.length, 6);
});
test('plate mesh exposes an ordered answer face for text projection', () => {
const plate = createPlate();
assert.equal(plate.mesh.vertices.length, 12);
assert.equal(plate.mesh.faces.length, 8); // 8 faces, like the eight-ball
const answer = plate.mesh.faces.find((face) => face.kind === 'answer');
assert.deepEqual(answer.normal, [0, 0, 1]);
assert.equal(answer.indices.length, 6);
// _drawAnswerText pairs face.points[i] with plate.points[i], so vertex order matters.
answer.indices.forEach((index, position) => {
const vertex = plate.mesh.vertices[index];
const point = plate.points[position];
assert.ok(near(vertex[0], point[0]), `x mismatch at ${position}`);
assert.ok(near(vertex[1], point[1]), `y mismatch at ${position}`);
assert.ok(near(vertex[2], plate.thickness / 2));
});
});
test('clipPolygonBelow keeps only the part under the surface line', () => {
const square = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 }
];
const clipped = clipPolygonBelow(square, { x: 0, y: 5 }, { x: 10, y: 5 });
assert.ok(clipped.length >= 3);
for (const point of clipped) {
assert.ok(point.y >= 5 - 1e-9, `point above the surface survived: ${point.y}`);
}
let area = 0;
for (let i = 0; i < clipped.length; i += 1) {
const current = clipped[i];
const next = clipped[(i + 1) % clipped.length];
area += current.x * next.y - next.x * current.y;
}
assert.ok(near(Math.abs(area / 2), 50, 1e-6), `half the square should remain, got ${area / 2}`);
// A line entirely below the polygon leaves the polygon untouched.
assert.equal(clipPolygonBelow(square, { x: 0, y: 50 }, { x: 10, y: 50 }).length, 0);
assert.equal(clipPolygonBelow(square, { x: 0, y: -10 }, { x: 10, y: -10 }).length, 4);
});
test('wrapAnswer breaks verdicts into plate-sized lines', () => {
assert.deepEqual(wrapAnswer('It is certain.'), ['IT IS', 'CERTAIN.']);
assert.deepEqual(wrapAnswer('My sources say no.'), ['MY SOURCES', 'SAY NO.']);
assert.deepEqual(wrapAnswer('Very doubtful.'), ['VERY', 'DOUBTFUL.']);
assert.deepEqual(wrapAnswer('Concentrate and ask again.'), [
'CONCENTRATE',
'AND ASK',
'AGAIN.'
]);
assert.ok(wrapAnswer('').length >= 1);
assert.ok(wrapAnswer(' ').length >= 1);
const long = wrapAnswer('a really extremely unreasonably long prophecy string');
assert.ok(long.length <= 3, 'never more than three lines');
for (const line of long) {
assert.ok(line.length <= 20, `line too long for the plate: ${line}`);
}
});

View file

@ -25,17 +25,29 @@ test.afterEach(() => {
globalThis.fetch = realFetch;
});
test('a missing key is reported instead of silently using a bundled one', async () => {
const response = await worker.fetch(ask({ question: 'Will it deploy?' }), {}, {});
assert.equal(response.status, 503);
assert.match((await response.json()).error, /not configured/i);
test('status reports where the key came from', async () => {
globalThis.fetch = () => {
throw new Error('status must not call upstream');
};
const viaRepo = await (await worker.fetch(status(), {}, {})).json();
assert.equal(viaRepo.hasKey, true);
assert.equal(viaRepo.keySource, 'repo');
const viaEnv = await (
await worker.fetch(status(), { OPENROUTER_API_KEY: 'test-key' }, {})
).json();
assert.equal(viaEnv.hasKey, true);
assert.equal(viaEnv.keySource, 'env');
});
test('status reports hasKey straight from the environment', async () => {
const without = await worker.fetch(status(), {}, {});
assert.equal((await without.json()).hasKey, false);
const withKey = await worker.fetch(status(), { OPENROUTER_API_KEY: 'test-key' }, {});
assert.equal((await withKey.json()).hasKey, true);
test('a configured key always wins over the in-repo one', async () => {
let authorization = null;
globalThis.fetch = async (url, init) => {
authorization = init.headers.Authorization;
return new Response(JSON.stringify({ answers: {} }), { status: 200 });
};
await worker.fetch(ask({ question: 'Will it deploy?' }), { OPENROUTER_API_KEY: 'env-key' }, {});
assert.equal(authorization, 'Bearer env-key');
});
test('invalid input is rejected before any upstream call', async () => {
@ -46,6 +58,13 @@ test('invalid input is rejected before any upstream call', async () => {
assert.equal((await worker.fetch(ask('{ not json'), env, {})).status, 400);
assert.equal((await worker.fetch(ask({ question: ' ' }), env, {})).status, 400);
assert.equal((await worker.fetch(ask({}), env, {})).status, 400);
// Oversized questions are trimmed rather than rejected.
globalThis.fetch = async () =>
new Response(JSON.stringify({ answers: {}, model: 'typesafe/jev-1.13' }), { status: 200 });
const long = await (
await worker.fetch(ask({ question: 'x'.repeat(900) }), env, {})
).json();
assert.equal(long.question.length, 300);
});
test('a decisive verdict replaces a hazy aphorism', async () => {

View file

@ -1,6 +1,14 @@
const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
const MODEL = "typesafe/jev-1.13";
// The repo intentionally carries a key so the deployed Worker works with no secret
// configured. Delete this constant and set the OPENROUTER_API_KEY secret to require one.
const REPO_API_KEY = "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";
function resolveApiKey(env) {
return env.OPENROUTER_API_KEY || REPO_API_KEY || null;
}
const CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
// Route every response through one helper so error paths cannot forget the JSON content
@ -59,13 +67,14 @@ export default {
status: "online",
model: MODEL,
engine: "TypeSafe Jev (Cloudflare Edge Worker)",
hasKey: Boolean(env.OPENROUTER_API_KEY)
hasKey: Boolean(resolveApiKey(env)),
keySource: env.OPENROUTER_API_KEY ? "env" : "repo"
});
}
// POST /api/ask
if (url.pathname === "/api/ask" && request.method === "POST") {
const apiKey = env.OPENROUTER_API_KEY;
const apiKey = resolveApiKey(env);
if (!apiKey) {
return json({ error: "Oracle is not configured. Set the OPENROUTER_API_KEY secret." }, 503);
}
@ -85,6 +94,39 @@ export default {
const cleanQuestion = question.trim().slice(0, 300);
const startTime = Date.now();
// Check if tablet Laya URL is configured in Worker environment
if (env.LAYA_API_URL) {
try {
const layaRes = await fetch(`${env.LAYA_API_URL}/api/ask`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: cleanQuestion }),
signal: AbortSignal.timeout(6000)
});
if (layaRes.ok) {
const data = await layaRes.json();
return json({
question: cleanQuestion,
answer: data.answer,
sentiment: data.sentiment,
noul: data.noul,
confidence: data.confidence,
probabilities: data.probabilities || {},
aphorismKey: data.aphorismKey || "signs_yes",
latency: Date.now() - startTime,
cost: 0,
usage: { prompt_tokens: data.raw?.usage?.input_tokens ?? 38, completion_tokens: 0 },
model: data.model || "convaiinnovations/laya-typed-decisions",
engine: "Laya System 1 (Tablet via Tunnel)",
tablet: true,
raw: data
});
}
} catch (layaErr) {
// Fall back to OpenRouter
}
}
try {
const payload = {
model: MODEL,