wolloseum/web/app.js

524 lines
22 KiB
JavaScript

const $ = (selector, root = document) => root.querySelector(selector);
const TOKEN_KEY = `wolloseum.session.${document.body?.dataset.page || 'app'}`;
const THEME_KEY = 'wolloseum.theme';
const encoder = new TextEncoder();
function currentTheme() {
return document.documentElement.dataset.theme
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
function setTheme(theme) {
document.documentElement.dataset.theme = theme;
localStorage.setItem(THEME_KEY, theme);
}
function bootTheme() {
const toggle = $('#theme-toggle');
if (!toggle) return;
const paint = () => { toggle.textContent = currentTheme() === 'dark' ? '\u2600' : '\u263D'; };
toggle.addEventListener('click', () => { setTheme(currentTheme() === 'dark' ? 'light' : 'dark'); paint(); });
paint();
}
function setMessage(element, text, kind = '') {
if (!element) return;
element.textContent = text;
element.className = `message ${kind}`.trim();
}
function fromBase64(value) {
return Uint8Array.from(atob(value), character => character.charCodeAt(0));
}
function readDerElement(bytes, offset) {
const start = offset;
const tag = bytes[offset++];
let length = bytes[offset++];
if (tag === undefined || length === undefined) return null;
if (length & 0x80) {
const lengthBytes = length & 0x7f;
if (!lengthBytes || lengthBytes > 4 || offset + lengthBytes > bytes.length) return null;
length = 0;
for (let index = 0; index < lengthBytes; index++) length = length * 256 + bytes[offset++];
}
const contentStart = offset;
const end = contentStart + length;
if (end > bytes.length) return null;
return { tag, start, contentStart, end };
}
function derLength(length) {
if (length < 0x80) return [length];
const bytes = [];
while (length) {
bytes.unshift(length & 0xff);
length >>>= 8;
}
return [0x80 | bytes.length, ...bytes];
}
function normalizeEd25519Pkcs8(bytes) {
const sequence = readDerElement(bytes, 0);
const version = sequence?.tag === 0x30 && sequence.end === bytes.length
? readDerElement(bytes, sequence.contentStart)
: null;
if (version?.tag !== 0x02 || version.end !== version.contentStart + 1) return bytes;
if (bytes[version.contentStart] === 0) return bytes;
if (bytes[version.contentStart] !== 1) return bytes;
const algorithm = readDerElement(bytes, version.end);
const privateKey = algorithm?.tag === 0x30 ? readDerElement(bytes, algorithm.end) : null;
const publicKey = privateKey?.tag === 0x04 ? readDerElement(bytes, privateKey.end) : null;
if (algorithm?.tag !== 0x30 || privateKey?.tag !== 0x04 || publicKey?.tag !== 0x81 || publicKey.end !== sequence.end) {
return bytes;
}
const body = new Uint8Array(3 + algorithm.end - algorithm.start + privateKey.end - privateKey.start);
body.set([0x02, 0x01, 0x00]);
body.set(bytes.subarray(algorithm.start, algorithm.end), 3);
body.set(bytes.subarray(privateKey.start, privateKey.end), 3 + algorithm.end - algorithm.start);
const encodedLength = derLength(body.length);
const normalized = new Uint8Array(1 + encodedLength.length + body.length);
normalized.set([0x30, ...encodedLength]);
normalized.set(body, 1 + encodedLength.length);
return normalized;
}
function toBase64(value) {
return btoa(String.fromCharCode(...new Uint8Array(value)));
}
function toPem(label, value) {
const base64 = toBase64(value).match(/.{1,64}/g).join('\n');
return `-----BEGIN ${label}-----\n${base64}\n-----END ${label}-----\n`;
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
const token = localStorage.getItem(TOKEN_KEY);
if (token) headers.set('Authorization', `Bearer ${token}`);
if (options.body && typeof options.body === 'string') headers.set('Content-Type', 'application/json');
const response = await fetch(path, { ...options, headers });
if (response.status === 204) return null;
const contentType = response.headers.get('content-type') || '';
const body = contentType.includes('application/json') ? await response.json() : await response.text();
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
return body;
}
async function readCredential(file) {
let bundle;
try { bundle = JSON.parse(await file.text()); }
catch (_) { throw new Error('Choose a valid JSON identity bundle.'); }
if (!bundle.client_id || !bundle.private_key_pem) {
throw new Error('This JSON file is not an identity bundle.');
}
return bundle;
}
async function signIn(file) {
if (!file) throw new Error('Choose your identity bundle.');
const credential = await readCredential(file);
const encodedKey = credential.private_key_pem.match(/-----BEGIN PRIVATE KEY-----([\s\S]*?)-----END PRIVATE KEY-----/);
if (!encodedKey) throw new Error('Expected an Ed25519 PKCS#8 private key.');
const pkcs8 = normalizeEd25519Pkcs8(fromBase64(encodedKey[1].replace(/\s/g, '')));
let privateKey;
try {
privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
} catch (error) {
if (error.name === 'NotSupportedError') {
throw new Error('This browser does not support Ed25519 Web Crypto. Update the browser and try again.');
}
throw new Error(`Ed25519 key import failed (${error.name}): ${error.message}. Choose data/keys/admin-private.pem or a generated identity JSON bundle.`);
}
const issued = await api('/api/auth/challenge', {
method: 'POST', body: JSON.stringify({ client_id: credential.client_id }),
});
const challenge = fromBase64(issued.challenge);
let signature;
try {
signature = await crypto.subtle.sign('Ed25519', privateKey, challenge);
} catch (error) {
throw new Error(`Ed25519 challenge signing failed (${error.name}): ${error.message}.`);
}
const session = await api('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ client_id: credential.client_id, challenge: issued.challenge, signature: toBase64(signature) }),
});
localStorage.setItem(TOKEN_KEY, session.token);
return api('/api/me');
}
function logout() {
localStorage.removeItem(TOKEN_KEY);
location.reload();
}
function connectEvents(onEvent, onConnection = () => { }) {
let reconnectDelay = 1000;
let socket;
let stopped = false;
const connect = () => {
if (stopped) return;
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(`${scheme}//${location.host}/api/events`);
socket.onopen = () => { reconnectDelay = 1000; onConnection(true); };
socket.onmessage = message => {
try { onEvent(JSON.parse(message.data)); } catch (_) { }
};
socket.onclose = () => {
onConnection(false);
if (!stopped) setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 15000);
};
socket.onerror = () => socket.close();
};
connect();
return () => { stopped = true; socket?.close(); };
}
function eventRow(event) {
const row = document.createElement('div');
row.className = 'event-row';
const sequence = document.createElement('span');
sequence.className = 'event-sequence';
sequence.textContent = `#${event.sequence}`;
const name = document.createElement('span');
name.className = 'event-name';
name.textContent = event.event;
const data = document.createElement('span');
data.className = 'event-data';
data.textContent = JSON.stringify(event.data ?? {});
row.append(sequence, name, data);
return row;
}
function bootViewer() {
const list = $('#event-list');
const empty = $('#event-empty');
const status = $('#socket-status');
const sequence = $('#latest-sequence');
const total = $('#event-count');
const search = $('#event-search');
let count = 0;
const events = [];
const redraw = () => {
list.replaceChildren();
const query = search.value.trim().toLowerCase();
const visible = events.filter(event => `${event.event} ${JSON.stringify(event.data)}`.toLowerCase().includes(query));
empty.classList.toggle('hidden', visible.length > 0);
visible.forEach(event => list.append(eventRow(event)));
};
search.addEventListener('input', redraw);
connectEvents(event => {
count += 1;
events.unshift(event);
if (events.length > 250) events.pop();
sequence.textContent = String(event.sequence);
total.textContent = String(count);
redraw();
}, connected => {
status.classList.toggle('live', connected);
status.classList.toggle('offline', !connected);
$('#socket-label').textContent = connected ? 'LIVE' : 'RECONNECTING';
});
}
function bootLogin() {
const form = $('#login-form');
const panel = $('#login-panel');
const message = $('#login-message');
const page = document.body.dataset.page;
const app = page === 'admin' ? $('#admin-app') : $('#user-app');
const account = $('#account-name');
const reveal = async identity => {
const requiredAdmin = page === 'admin';
if (requiredAdmin && !identity.is_admin && !identity.is_mod) {
throw new Error('This identity does not have moderator or administrator rights.');
}
window.currentIdentity = identity;
panel.classList.add('hidden');
app.classList.remove('hidden');
account.textContent = identity.username;
if (requiredAdmin) await bootAdmin(identity);
else await bootUser(identity);
};
form.addEventListener('submit', async event => {
event.preventDefault();
$('#identity-bundle').click();
});
$('#login-button').addEventListener('click', () => $('#identity-bundle').click());
$('#identity-bundle').addEventListener('change', async event => {
const fileInput = event.currentTarget;
const button = $('#login-button');
button.disabled = true;
setMessage(message, 'Verifying challenge...');
try { await reveal(await signIn(fileInput.files[0])); }
catch (error) { setMessage(message, error.message, 'error'); }
finally {
button.disabled = false;
fileInput.value = '';
}
});
$('#logout-button, #admin-logout').addEventListener('click', logout);
if (localStorage.getItem(TOKEN_KEY)) {
api('/api/me').then(reveal).catch(() => localStorage.removeItem(TOKEN_KEY));
}
}
async function bootUser(identity) {
$('#player-id').textContent = identity.client_id;
$('#agent-digest').textContent = identity.agent_sha256 || 'No agent uploaded';
$('#queued-agent-digest').textContent = identity.queued_agent_sha256 || 'None';
const status = await api('/api/status');
paintGameStatus(status.running);
$('#agent-form').addEventListener('submit', async event => {
event.preventDefault();
const file = $('#agent-file').files[0];
if (!file) return setMessage($('#agent-message'), 'Choose a compiled WASM module.', 'error');
const button = $('button[type="submit"]', event.currentTarget);
button.disabled = true;
setMessage($('#agent-message'), `Uploading ${file.name}...`);
try {
const result = await api('/api/update_agent', { method: 'POST', body: await file.arrayBuffer() });
$('#queued-agent-digest').textContent = result.sha256;
setMessage($('#agent-message'), `Agent queued | ${result.bytes.toLocaleString()} bytes`, 'success');
} catch (error) { setMessage($('#agent-message'), error.message, 'error'); }
finally { button.disabled = false; }
});
connectEvents(event => {
if (event.event === 'snapshot' || event.event === 'game_state_changed') paintGameStatus(event.data.running);
if (event.event === 'agents_activated') {
api('/api/me').then(identity => {
$('#agent-digest').textContent = identity.agent_sha256 || 'No agent uploaded';
$('#queued-agent-digest').textContent = identity.queued_agent_sha256 || 'None';
}).catch(error => setMessage($('#agent-message'), error.message, 'error'));
}
});
}
function paintGameStatus(running) {
const chip = $('#game-status');
if (!chip) return;
chip.classList.toggle('running', running);
chip.classList.toggle('paused', !running);
$('#game-status-label').textContent = running ? 'RUNNING' : 'PAUSED';
const copy = $('#game-status-copy');
if (copy) copy.textContent = running ? 'RUNNING' : 'PAUSED';
}
async function bootAdmin(identity) {
document.querySelectorAll('.admin-only').forEach(element => {
element.classList.toggle('hidden', !identity.is_admin);
});
if (!identity.is_admin) setMessage($('#admin-message'), 'Moderator access | user management only.');
const status = await api('/api/status');
paintGameStatus(status.running);
if (identity.is_admin) $('#game-control').addEventListener('click', async () => {
const running = $('#game-status-label').textContent === 'RUNNING';
const button = $('#game-control');
button.disabled = true;
try {
await api(running ? '/api/stop_game' : '/api/start_game', { method: 'POST', body: '' });
paintGameStatus(!running);
button.textContent = running ? 'Resume simulation' : 'Pause simulation';
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
finally { button.disabled = false; }
});
if ($('#game-control')) $('#game-control').textContent = status.running ? 'Pause simulation' : 'Resume simulation';
$('#user-form').addEventListener('submit', createUser);
if (identity.is_admin) {
$('#core-form').addEventListener('submit', uploadCore);
$('#simulation-settings-form').addEventListener('submit', saveSimulationSettings);
$('#reset-world').addEventListener('click', resetWorld);
$('#export-state').addEventListener('click', exportState);
$('#import-state').addEventListener('click', () => $('#state-file').click());
$('#state-file').addEventListener('change', importState);
await loadSimulationSettings();
}
await refreshUsers();
connectEvents(event => {
if (event.event === 'snapshot' || event.event === 'game_state_changed') {
paintGameStatus(event.data.running);
if ($('#game-control')) $('#game-control').textContent = event.data.running ? 'Pause simulation' : 'Resume simulation';
}
if (event.event === 'user_created' || event.event === 'user_deleted' || event.event === 'user_role_changed' || event.event === 'agent_queued' || event.event === 'agents_activated') refreshUsers();
});
}
async function loadSimulationSettings() {
try {
const settings = await api('/api/settings');
$('#ticks-per-day').value = settings.ticks_per_day;
$('#days-per-week').value = settings.days_per_week;
$('#days-per-month').value = settings.days_per_month;
$('#tick-interval-ms').value = settings.tick_interval_ms;
$('#agent-activation').value = settings.agent_activation;
} catch (error) { setMessage($('#simulation-settings-message'), error.message, 'error'); }
}
async function saveSimulationSettings(event) {
event.preventDefault();
const form = event.currentTarget;
const button = $('button[type="submit"]', form);
button.disabled = true;
const settings = {
ticks_per_day: Number($('#ticks-per-day').value),
days_per_week: Number($('#days-per-week').value),
days_per_month: Number($('#days-per-month').value),
tick_interval_ms: Number($('#tick-interval-ms').value),
agent_activation: $('#agent-activation').value,
};
try {
await api('/api/settings', { method: 'PUT', body: JSON.stringify(settings) });
setMessage($('#simulation-settings-message'), 'Settings saved.', 'success');
} catch (error) { setMessage($('#simulation-settings-message'), error.message, 'error'); }
finally { button.disabled = false; }
}
async function createUser(event) {
event.preventDefault();
const username = $('#new-username').value.trim();
const button = $('button[type="submit"]', event.currentTarget);
const downloadLink = $('#identity-download');
if (downloadLink.dataset.objectUrl) URL.revokeObjectURL(downloadLink.dataset.objectUrl);
delete downloadLink.dataset.objectUrl;
downloadLink.removeAttribute('href');
downloadLink.classList.add('hidden');
button.disabled = true;
setMessage($('#create-user-message'), 'Generating identity keypair...');
try {
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
const publicKey = await crypto.subtle.exportKey('raw', pair.publicKey);
const privateKey = await crypto.subtle.exportKey('pkcs8', pair.privateKey);
const identity = await api('/api/create_user', {
method: 'POST',
body: JSON.stringify({ username, public_key: toBase64(publicKey) }),
});
const bundle = JSON.stringify({ username, client_id: identity.client_id, private_key_pem: toPem('PRIVATE KEY', privateKey) }, null, 2);
downloadLink.dataset.objectUrl = URL.createObjectURL(new Blob([bundle], { type: 'application/json' }));
downloadLink.href = downloadLink.dataset.objectUrl;
downloadLink.download = `${username}-identity.json`;
downloadLink.classList.remove('hidden');
$('#new-username').value = '';
setMessage($('#create-user-message'), `Created ${username}. Download the private-key bundle before closing this page.`, 'success');
await refreshUsers();
} catch (error) { setMessage($('#create-user-message'), error.message, 'error'); }
finally { button.disabled = false; }
}
async function refreshUsers() {
try {
const users = await api('/api/users');
const list = $('#user-list');
list.replaceChildren();
users.forEach(user => {
const row = document.createElement('div');
row.className = 'user-row';
const info = document.createElement('div');
const name = document.createElement('div');
name.className = 'user-name';
name.textContent = `${user.username}${user.is_admin ? ' | admin' : user.is_mod ? ' | mod' : ''}`;
const id = document.createElement('div');
id.className = 'user-id';
id.textContent = user.client_id;
const meta = document.createElement('div');
meta.className = 'user-meta';
const agentStatus = [
user.has_agent ? `Active | ${user.agent_sha256 || 'uploaded'}` : 'No active agent',
user.queued_agent_sha256 ? `Queued | ${user.queued_agent_sha256}` : '',
].filter(Boolean);
meta.textContent = agentStatus.join(' | ');
info.append(name, id, meta);
row.append(info);
if (window.currentIdentity?.is_admin && user.username !== 'admin') {
const role = document.createElement('select');
role.setAttribute('aria-label', `${user.username} role`);
[['user', 'Player'], ['mod', 'Moderator'], ['admin', 'Administrator']].forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
role.append(option);
});
role.value = user.is_admin ? 'admin' : user.is_mod ? 'mod' : 'user';
role.addEventListener('change', async () => {
try {
await api('/api/set_role', { method: 'POST', body: JSON.stringify({ client_id: user.client_id, role: role.value }) });
await refreshUsers();
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
});
row.append(role);
}
const mayRemove = !user.is_admin && (window.currentIdentity?.is_admin || !user.is_mod);
if (mayRemove) {
const remove = document.createElement('button');
remove.className = 'button danger';
remove.type = 'button';
remove.textContent = 'Remove';
remove.addEventListener('click', async () => {
if (!confirm(`Remove ${user.username} and their uploaded agent?`)) return;
try {
await api('/api/delete_user', { method: 'POST', body: JSON.stringify({ client_id: user.client_id }) });
await refreshUsers();
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
});
row.append(remove);
}
list.append(row);
});
$('#user-count').textContent = String(users.filter(user => !user.is_admin).length);
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
}
async function resetWorld() {
if (!confirm('Reset the world? Settings, users, agents, and core will be kept.')) return;
try {
await api('/api/reset_world', { method: 'POST', body: '' });
setMessage($('#state-message'), 'World reset requested.', 'success');
} catch (error) { setMessage($('#state-message'), error.message, 'error'); }
}
async function exportState() {
try {
const state = await api('/api/state');
const url = URL.createObjectURL(new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = 'wolloseum-state.json';
link.click();
URL.revokeObjectURL(url);
setMessage($('#state-message'), 'Simulation state downloaded.', 'success');
} catch (error) { setMessage($('#state-message'), error.message, 'error'); }
}
async function importState(event) {
const file = event.currentTarget.files[0];
if (!file) return;
if (!confirm('Restore this simulation state? Current settings, users, agents, core, and world will be replaced.')) return;
try {
await api('/api/state', { method: 'POST', body: await file.text() });
setMessage($('#state-message'), 'Simulation state restored.', 'success');
await refreshUsers();
await loadSimulationSettings();
} catch (error) { setMessage($('#state-message'), error.message, 'error'); }
finally { event.currentTarget.value = ''; }
}
async function uploadCore(event) {
event.preventDefault();
const file = $('#core-file').files[0];
if (!file) return setMessage($('#core-message'), 'Choose a WASM core module.', 'error');
const button = $('button[type="submit"]', event.currentTarget);
button.disabled = true;
setMessage($('#core-message'), `Validating ${file.name}...`);
try {
const result = await api('/api/upload_core', { method: 'POST', body: await file.arrayBuffer() });
setMessage($('#core-message'), `Core module accepted | ${result.sha256}`, 'success');
} catch (error) { setMessage($('#core-message'), error.message, 'error'); }
finally { button.disabled = false; }
}
bootTheme();
if (document.body.dataset.page === 'viewer') bootViewer();
else bootLogin();