init
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
// Client for the belt's HTTP API.
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const KEY = 'wh.key';
|
||||
export const keyStore = {
|
||||
get() {
|
||||
try { return localStorage.getItem(KEY); } catch { return null; }
|
||||
},
|
||||
set(k) {
|
||||
try { localStorage.setItem(KEY, k); } catch { /* private mode: session only */ }
|
||||
memoryKey = k;
|
||||
},
|
||||
clear() {
|
||||
try { localStorage.removeItem(KEY); } catch { /* ignore */ }
|
||||
memoryKey = null;
|
||||
},
|
||||
};
|
||||
let memoryKey = null;
|
||||
const currentKey = () => memoryKey ?? keyStore.get();
|
||||
|
||||
let unauthorized = () => {};
|
||||
export function onUnauthorized(cb) { unauthorized = cb; }
|
||||
|
||||
async function call(method, path, { json, body, type, auth = true, raw = false, key } = {}) {
|
||||
const headers = {};
|
||||
// An explicit key (used by login) is tried as given and never triggers the
|
||||
// global "session rejected" handler.
|
||||
const k = key ?? (auth ? currentKey() : null);
|
||||
if (k) headers.Authorization = `Bearer ${k}`;
|
||||
let payload = body;
|
||||
if (json !== undefined) {
|
||||
payload = JSON.stringify(json);
|
||||
headers['Content-Type'] = 'application/json';
|
||||
} else if (type) {
|
||||
headers['Content-Type'] = type;
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(path, { method, headers, body: payload });
|
||||
} catch {
|
||||
throw new ApiError(0, 'LINK DOWN: cannot reach the server');
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status} ${res.statusText}`;
|
||||
try { msg = (await res.json()).error ?? msg; } catch { /* not JSON */ }
|
||||
if (res.status === 401 && auth && key === undefined) unauthorized();
|
||||
throw new ApiError(res.status, msg);
|
||||
}
|
||||
if (raw) return res;
|
||||
if ((res.headers.get('Content-Type') ?? '').includes('json')) return res.json();
|
||||
return null;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
info: () => call('GET', '/info', { auth: false }),
|
||||
market: () => call('GET', '/market', { auth: false }),
|
||||
register: (name) => call('POST', '/register', { json: { name }, auth: false }),
|
||||
me: (key) => call('GET', '/me', { key }),
|
||||
ships: () => call('GET', '/ships'),
|
||||
assemble: (source) => call('POST', '/assemble', { body: source, type: 'text/plain', auth: false }),
|
||||
setProgram: (id, bytes) => call('PUT', `/ships/${id}/program`, { body: bytes, type: 'application/octet-stream' }),
|
||||
launch: (id) => call('POST', `/ships/${id}/launch`),
|
||||
setUplink: (id, bytes) => call('PUT', `/ships/${id}/uplink`, { body: bytes, type: 'application/octet-stream' }),
|
||||
async downlink(id) {
|
||||
const res = await call('GET', `/ships/${id}/downlink`, { raw: true });
|
||||
return {
|
||||
bytes: new Uint8Array(await res.arrayBuffer()),
|
||||
day: Number(res.headers.get('X-Downlink-Day')),
|
||||
};
|
||||
},
|
||||
async example(name) {
|
||||
const res = await fetch(`/examples/${name}.s`);
|
||||
if (!res.ok) throw new ApiError(res.status, `example ${name} not found`);
|
||||
return res.text();
|
||||
},
|
||||
async examples() {
|
||||
const res = await fetch('/examples/index.json');
|
||||
return res.ok ? res.json() : [];
|
||||
},
|
||||
};
|
||||
|
||||
export function b64ToBytes(b64) {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Halcyon Flight Control: application shell and routing.
|
||||
|
||||
import { api, keyStore, onUnauthorized } from './api.js';
|
||||
import { h, clear, toast } from './dom.js';
|
||||
import { startStarfield } from './starfield.js';
|
||||
import { statusOf } from './status.js';
|
||||
import { authView } from './view-auth.js';
|
||||
import { fleetView } from './view-fleet.js';
|
||||
import { codeView } from './view-code.js';
|
||||
import { uplinkView } from './view-uplink.js';
|
||||
import { downlinkView } from './view-downlink.js';
|
||||
|
||||
const root = document.getElementById('app');
|
||||
const TABS = [
|
||||
['fleet', 'FLEET'],
|
||||
['code', 'CODE'],
|
||||
['uplink', 'UPLINK'],
|
||||
['downlink', 'DOWNLINK'],
|
||||
];
|
||||
const VIEWS = { fleet: fleetView, code: codeView, uplink: uplinkView, downlink: downlinkView };
|
||||
const ORE_TAGS = { iron: 'FE', nickel: 'NI', ice: 'H2O', platinum: 'PT' };
|
||||
|
||||
const state = { info: null, me: null, market: null, ships: [], selected: null, tab: 'fleet' };
|
||||
let shell = null; // { top, nav, select, main }
|
||||
|
||||
const tabFromHash = () => {
|
||||
const t = location.hash.replace(/^#\/?/, '');
|
||||
return VIEWS[t] ? t : 'fleet';
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
state,
|
||||
get ship() { return state.ships.find((s) => s.id === state.selected) ?? null; },
|
||||
go(tab) {
|
||||
if (location.hash === `#/${tab}`) { state.tab = tab; renderMain(); } else location.hash = `#/${tab}`;
|
||||
},
|
||||
selectShip(id, tab) {
|
||||
state.selected = id;
|
||||
try { localStorage.setItem('wh.ship', String(id)); } catch { /* ignore */ }
|
||||
renderNav();
|
||||
if (tab) ctx.go(tab); else renderMain();
|
||||
},
|
||||
async refresh() {
|
||||
await loadAccount();
|
||||
renderTop();
|
||||
renderNav();
|
||||
renderMain();
|
||||
},
|
||||
};
|
||||
|
||||
async function loadAccount() {
|
||||
const [me, ships, market, info] = await Promise.all([api.me(), api.ships(), api.market().catch(() => null), api.info().catch(() => state.info)]);
|
||||
Object.assign(state, { me, ships, market, info });
|
||||
const remembered = Number(safeGet('wh.ship'));
|
||||
if (!ships.some((s) => s.id === state.selected)) {
|
||||
state.selected = ships.some((s) => s.id === remembered) ? remembered : (ships[0]?.id ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
function safeGet(k) { try { return localStorage.getItem(k); } catch { return null; } }
|
||||
|
||||
function logout(message) {
|
||||
keyStore.clear();
|
||||
Object.assign(state, { me: null, ships: [], selected: null });
|
||||
shell = null;
|
||||
if (message) toast(message, 'err');
|
||||
showAuth();
|
||||
}
|
||||
|
||||
function showAuth() {
|
||||
clear(root).append(authView({ onLogin: enter }));
|
||||
}
|
||||
|
||||
async function enter() {
|
||||
try {
|
||||
await loadAccount();
|
||||
} catch (e) {
|
||||
if (e.status !== 401) toast(`ERROR: ${e.message}`, 'err');
|
||||
return logout();
|
||||
}
|
||||
buildShell();
|
||||
state.tab = tabFromHash();
|
||||
renderTop();
|
||||
renderNav();
|
||||
renderMain();
|
||||
}
|
||||
|
||||
// ---- shell ------------------------------------------------------------------
|
||||
|
||||
function buildShell() {
|
||||
shell = {
|
||||
top: h('header', { class: 'topbar' }),
|
||||
nav: h('nav', { class: 'navbar', 'aria-label': 'Sections' }),
|
||||
main: h('main', { id: 'main', class: 'main', tabindex: '-1' }),
|
||||
};
|
||||
clear(root).append(
|
||||
h('a', { class: 'skip', href: '#main' }, 'SKIP TO CONTENT'),
|
||||
shell.top,
|
||||
shell.nav,
|
||||
shell.main,
|
||||
h('footer', { class: 'foot' }, 'HALCYON INSTRUMENT & CONTROL // WORMHOLE LINK 1 KB/DAY // THE BELT IS SIMULATED ONCE A DAY'),
|
||||
);
|
||||
}
|
||||
|
||||
function renderTop() {
|
||||
if (!shell) return;
|
||||
const { me, info, market } = state;
|
||||
const prices = market && info?.ores
|
||||
? info.ores.map((o, i) => h('span', { class: 'chip ore', title: `${o} price per kg` }, h('b', null, ORE_TAGS[o] ?? o.toUpperCase()), ` ${market[i]}`))
|
||||
: [h('span', { class: 'chip dim', title: 'Prices appear after the first daily run' }, 'MARKET: NO DATA YET')];
|
||||
clear(shell.top).append(
|
||||
h('div', { class: 'top-brand' }, h('span', { class: 'logo sm' }, 'HALCYON'), h('span', { class: 'dim' }, 'FLIGHT CONTROL')),
|
||||
h('div', { class: 'top-stats' },
|
||||
h('span', { class: 'chip' }, 'DAY ', h('b', null, String(info?.day ?? me?.day ?? 0))),
|
||||
h('span', { class: 'chip' }, 'CREDITS ', h('b', null, (me?.credits ?? 0).toLocaleString('en-US'))),
|
||||
...prices,
|
||||
),
|
||||
h('div', { class: 'top-user' },
|
||||
h('span', { class: 'callsign' }, me?.name?.toUpperCase() ?? ''),
|
||||
h('button', { class: 'btn small', type: 'button', onclick: async () => { try { await ctx.refresh(); toast('DATA REFRESHED'); } catch (e) { toast(e.message, 'err'); } } }, 'REFRESH'),
|
||||
h('button', { class: 'btn small', type: 'button', onclick: () => logout() }, 'LOGOUT'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
if (!shell) return;
|
||||
const select = h('select', {
|
||||
id: 'ship-select', 'aria-label': 'Active ship', disabled: state.ships.length === 0,
|
||||
onchange: (e) => ctx.selectShip(Number(e.target.value)),
|
||||
}, state.ships.length
|
||||
? state.ships.map((s) => h('option', { value: s.id, selected: s.id === state.selected }, `#${s.id} ${statusOf(s.status).label}`))
|
||||
: [h('option', null, 'NO SHIPS')]);
|
||||
clear(shell.nav).append(
|
||||
h('div', { class: 'tabs' },
|
||||
TABS.map(([id, label]) => h('a', { class: `tab ${state.tab === id ? 'on' : ''}`, href: `#/${id}`, 'aria-current': state.tab === id ? 'page' : null }, label)),
|
||||
h('a', { class: 'tab', href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'MANUAL'),
|
||||
),
|
||||
h('label', { class: 'shipsel' }, 'ACTIVE SHIP', select),
|
||||
);
|
||||
}
|
||||
|
||||
function renderMain() {
|
||||
if (!shell) return;
|
||||
clear(shell.main).append(VIEWS[state.tab](ctx));
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (!shell) return;
|
||||
state.tab = tabFromHash();
|
||||
renderNav();
|
||||
renderMain();
|
||||
shell.main.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
// A refresh when the tab regains focus, since the daily run happens while the page sits open.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && shell && state.tab === 'fleet') ctx.refresh().catch(() => {});
|
||||
});
|
||||
|
||||
// ---- boot -------------------------------------------------------------------
|
||||
|
||||
startStarfield(document.getElementById('stars'));
|
||||
onUnauthorized(() => logout('SESSION REJECTED: ACCESS KEY NOT RECOGNISED'));
|
||||
api.info().then((i) => { state.info = i; }).catch(() => {});
|
||||
if (keyStore.get()) enter(); else showAuth();
|
||||
@@ -0,0 +1,70 @@
|
||||
// Tiny DOM helpers: element builder, toasts and a confirm dialog.
|
||||
|
||||
/** Build an element. Props: class, on<Event> handlers, booleans, attributes. */
|
||||
export function h(tag, props, ...kids) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props ?? {})) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k === 'class') el.className = v;
|
||||
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
|
||||
else if (v === true) el.setAttribute(k, '');
|
||||
else el.setAttribute(k, v);
|
||||
}
|
||||
el.append(...kids.flat(Infinity).filter((c) => c != null && c !== false));
|
||||
return el;
|
||||
}
|
||||
|
||||
export function clear(el) {
|
||||
el.replaceChildren();
|
||||
return el;
|
||||
}
|
||||
|
||||
export function toast(message, kind = 'info') {
|
||||
const host = document.getElementById('toasts');
|
||||
const t = h('div', { class: `toast ${kind}`, role: kind === 'err' ? 'alert' : null }, message);
|
||||
host.append(t);
|
||||
setTimeout(() => {
|
||||
t.classList.add('out');
|
||||
setTimeout(() => t.remove(), 400);
|
||||
}, kind === 'err' ? 8000 : 4500);
|
||||
}
|
||||
|
||||
/** Resolve true if the user confirms. */
|
||||
export function confirmDialog({ title, body, confirmText = 'CONFIRM', cancelText = 'ABORT', danger = false }) {
|
||||
const dlg = document.getElementById('dialog');
|
||||
return new Promise((resolve) => {
|
||||
const finish = (v) => {
|
||||
dlg.close();
|
||||
resolve(v);
|
||||
};
|
||||
clear(dlg).append(
|
||||
h('div', { class: `panel dialog ${danger ? 'danger' : ''}` },
|
||||
h('div', { class: 'panel-title' }, title),
|
||||
h('div', { class: 'panel-body' }, ...[].concat(body).map((p) => h('p', null, p))),
|
||||
h('div', { class: 'actions' },
|
||||
h('button', { class: 'btn', type: 'button', onclick: () => finish(false) }, cancelText),
|
||||
h('button', { class: `btn ${danger ? 'danger' : 'primary'}`, type: 'button', onclick: () => finish(true) }, confirmText),
|
||||
),
|
||||
),
|
||||
);
|
||||
dlg.oncancel = () => resolve(false);
|
||||
dlg.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
export function download(filename, bytes, type = 'application/octet-stream') {
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type }));
|
||||
const a = h('a', { href: url, download: filename });
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
export function pad(n, width, ch = '0') {
|
||||
return String(n).padStart(width, ch);
|
||||
}
|
||||
|
||||
export function plural(n, one, many = `${one}S`) {
|
||||
return `${n} ${n === 1 ? one : many}`;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// A small assembly editor: line numbers, syntax highlighting (a coloured <pre>
|
||||
// underneath a transparent <textarea>), Tab-indents and error-line marking.
|
||||
|
||||
import { h } from './dom.js';
|
||||
|
||||
const MNEMONICS = new Set(
|
||||
('nop yield halt ldi lui mov add sub mul div mod and or xor shl shr sar addi jmp beq bne blt bge ' +
|
||||
'call ret push pop ldb ldh ldw stb sth stw in out li').split(' '),
|
||||
);
|
||||
|
||||
const TOKEN = new RegExp(
|
||||
[
|
||||
'(\\.[A-Za-z_]\\w*)', // 1 directive
|
||||
'([A-Za-z_]\\w*)(?=:)', // 2 label definition
|
||||
'\\b(r(?:1[0-5]|\\d)|sp)\\b', // 3 register
|
||||
'(-?\\b(?:0x[0-9a-fA-F]+|0b[01]+|\\d+)\\b)', // 4 number
|
||||
'([A-Za-z_]\\w*)', // 5 identifier
|
||||
'([\\[\\]+,:-])', // 6 punctuation
|
||||
].join('|'),
|
||||
'gi',
|
||||
);
|
||||
|
||||
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const span = (cls, text) => `<span class="${cls}">${esc(text)}</span>`;
|
||||
|
||||
/** Highlight one line of assembly, returning HTML. Exported for testing. */
|
||||
export function highlightLine(line) {
|
||||
const c = line.search(/[;#]/);
|
||||
const code = c < 0 ? line : line.slice(0, c);
|
||||
const comment = c < 0 ? '' : line.slice(c);
|
||||
let out = '';
|
||||
let pos = 0;
|
||||
TOKEN.lastIndex = 0;
|
||||
for (let m; (m = TOKEN.exec(code)); ) {
|
||||
out += esc(code.slice(pos, m.index));
|
||||
pos = m.index + m[0].length;
|
||||
if (m[1]) out += span('hl-dir', m[0]);
|
||||
else if (m[2]) out += span('hl-lbl', m[0]);
|
||||
else if (m[3]) out += span('hl-reg', m[0]);
|
||||
else if (m[4]) out += span('hl-num', m[0]);
|
||||
else if (m[5]) out += span(MNEMONICS.has(m[5].toLowerCase()) ? 'hl-mn' : 'hl-sym', m[0]);
|
||||
else out += span('hl-pun', m[0]);
|
||||
}
|
||||
out += esc(code.slice(pos));
|
||||
if (comment) out += span('hl-cmt', comment);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createEditor({ value = '', onInput = () => {}, onCursor = () => {} } = {}) {
|
||||
const gutter = h('div', { class: 'ed-gutter', 'aria-hidden': 'true' });
|
||||
const hl = h('div', { class: 'ed-hl', 'aria-hidden': 'true' });
|
||||
const input = h('textarea', {
|
||||
class: 'ed-input',
|
||||
spellcheck: 'false',
|
||||
autocapitalize: 'off',
|
||||
autocomplete: 'off',
|
||||
autocorrect: 'off',
|
||||
wrap: 'off',
|
||||
'aria-label': 'Assembly source code',
|
||||
});
|
||||
const el = h('div', { class: 'editor' }, gutter, h('div', { class: 'ed-body' }, hl, input));
|
||||
|
||||
let errorLine = null;
|
||||
let escaped = false; // Escape pressed: let the next Tab leave the editor
|
||||
|
||||
function render() {
|
||||
const lines = input.value.split('\n');
|
||||
hl.innerHTML = lines
|
||||
.map((l, i) => `<div class="ed-line${i + 1 === errorLine ? ' err' : ''}">${highlightLine(l) || ' '}</div>`)
|
||||
.join('');
|
||||
gutter.innerHTML = lines
|
||||
.map((_, i) => `<div class="ed-num${i + 1 === errorLine ? ' err' : ''}">${i + 1}</div>`)
|
||||
.join('');
|
||||
syncScroll();
|
||||
}
|
||||
|
||||
function syncScroll() {
|
||||
hl.scrollTop = gutter.scrollTop = input.scrollTop;
|
||||
hl.scrollLeft = input.scrollLeft;
|
||||
}
|
||||
|
||||
function cursor() {
|
||||
const upTo = input.value.slice(0, input.selectionStart);
|
||||
const line = upTo.split('\n').length;
|
||||
const col = upTo.length - upTo.lastIndexOf('\n');
|
||||
onCursor({ line, col });
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
errorLine = null;
|
||||
render();
|
||||
onInput(input.value);
|
||||
});
|
||||
input.addEventListener('scroll', syncScroll);
|
||||
for (const ev of ['keyup', 'click', 'focus']) input.addEventListener(ev, cursor);
|
||||
input.addEventListener('blur', () => { escaped = false; });
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { escaped = true; return; }
|
||||
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !escaped) {
|
||||
e.preventDefault();
|
||||
input.setRangeText(' ', input.selectionStart, input.selectionEnd, 'end');
|
||||
input.dispatchEvent(new Event('input'));
|
||||
}
|
||||
});
|
||||
|
||||
input.value = value;
|
||||
render();
|
||||
|
||||
return {
|
||||
el,
|
||||
get value() { return input.value; },
|
||||
set value(v) { input.value = v; errorLine = null; render(); onInput(v); },
|
||||
insertAtTop(text) {
|
||||
input.value = text + input.value;
|
||||
errorLine = null;
|
||||
render();
|
||||
onInput(input.value);
|
||||
},
|
||||
setError(line) {
|
||||
errorLine = line;
|
||||
render();
|
||||
if (line) {
|
||||
const lh = parseFloat(getComputedStyle(input).lineHeight) || 22;
|
||||
input.scrollTop = Math.max(0, (line - 3) * lh);
|
||||
syncScroll();
|
||||
}
|
||||
},
|
||||
focus() { input.focus(); },
|
||||
render, // re-measure after the element is attached
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffb62e"/><stop offset="1" stop-color="#ff3ad8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="10" fill="#12072b"/>
|
||||
<circle cx="32" cy="30" r="17" fill="url(#g)"/>
|
||||
<rect x="12" y="35" width="40" height="2.5" fill="#12072b"/>
|
||||
<rect x="12" y="41" width="40" height="3.5" fill="#12072b"/>
|
||||
<path d="M6 50h52" stroke="#28f5ff" stroke-width="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 539 B |
@@ -0,0 +1,103 @@
|
||||
// Hex dump with an inspector: hover or click a byte to decode it.
|
||||
|
||||
import { h, pad } from './dom.js';
|
||||
|
||||
const hex2 = (b) => pad(b.toString(16).toUpperCase(), 2);
|
||||
const printable = (b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : '.');
|
||||
|
||||
/** Decode the bytes at offset i as little-endian integers. */
|
||||
export function inspect(bytes, i) {
|
||||
const b = (k) => (i + k < bytes.length ? bytes[i + k] : null);
|
||||
const have = (n) => i + n <= bytes.length;
|
||||
const out = { offset: i, u8: bytes[i] };
|
||||
if (have(2)) {
|
||||
const v = b(0) | (b(1) << 8);
|
||||
out.u16 = v;
|
||||
out.i16 = (v << 16) >> 16;
|
||||
}
|
||||
if (have(4)) {
|
||||
const v = (b(0) | (b(1) << 8) | (b(2) << 16) | (b(3) << 24));
|
||||
out.i32 = v;
|
||||
out.u32 = v >>> 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function describe(bytes, i) {
|
||||
const d = inspect(bytes, i);
|
||||
const parts = [`OFFSET 0x${pad(d.offset.toString(16).toUpperCase(), 4)} (${d.offset})`, `U8 ${d.u8}`];
|
||||
if (d.i16 !== undefined) parts.push(`I16 ${d.i16}`);
|
||||
if (d.i32 !== undefined) parts.push(`I32 ${d.i32}`, `U32 ${d.u32}`);
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a hex dump. Returns { el, inspector } where inspector is the element
|
||||
* that shows the decoded value of the hovered/selected byte.
|
||||
*/
|
||||
export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
|
||||
const inspector = h('div', { class: 'hx-inspector', 'aria-live': 'polite' }, 'HOVER OR CLICK A BYTE TO DECODE IT');
|
||||
const body = h('div', { class: 'hx-body', tabindex: '0', role: 'group', 'aria-label': 'Hex dump' });
|
||||
let pinned = null;
|
||||
const cells = new Map();
|
||||
|
||||
const rowCount = Math.min(Math.ceil(bytes.length / 16), maxRows);
|
||||
for (let r = 0; r < rowCount; r++) {
|
||||
const start = r * 16;
|
||||
const hexCells = [];
|
||||
const ascCells = [];
|
||||
for (let k = 0; k < 16; k++) {
|
||||
const i = start + k;
|
||||
if (i >= bytes.length) {
|
||||
hexCells.push(h('span', { class: 'hx-b pad' }, ' '));
|
||||
continue;
|
||||
}
|
||||
const v = bytes[i];
|
||||
const cls = v === 0 ? 'hx-b z' : 'hx-b';
|
||||
const a = h('span', { class: `${cls} a`, 'data-i': i }, printable(v));
|
||||
const x = h('span', { class: cls, 'data-i': i }, hex2(v));
|
||||
cells.set(i, [x, a]);
|
||||
hexCells.push(x);
|
||||
ascCells.push(a);
|
||||
if (k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' '));
|
||||
}
|
||||
body.append(
|
||||
h('div', { class: 'hx-row' },
|
||||
h('span', { class: 'hx-off' }, pad(start.toString(16).toUpperCase(), 4)),
|
||||
h('span', { class: 'hx-hex' }, hexCells),
|
||||
h('span', { class: 'hx-asc' }, ascCells),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const select = (i, pin) => {
|
||||
if (pin) {
|
||||
if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.remove('sel'));
|
||||
pinned = pinned === i ? null : i;
|
||||
if (pinned !== null) cells.get(pinned).forEach((c) => c.classList.add('sel'));
|
||||
}
|
||||
const shown = pinned ?? i;
|
||||
inspector.textContent = shown === null ? 'HOVER OR CLICK A BYTE TO DECODE IT' : describe(bytes, shown);
|
||||
};
|
||||
const target = (e) => {
|
||||
const t = e.target.closest('[data-i]');
|
||||
return t ? Number(t.dataset.i) : null;
|
||||
};
|
||||
body.addEventListener('mouseover', (e) => { const i = target(e); if (i !== null && pinned === null) select(i, false); });
|
||||
body.addEventListener('mouseleave', () => { if (pinned === null) select(null, false); });
|
||||
body.addEventListener('click', (e) => { const i = target(e); if (i !== null) select(i, true); });
|
||||
|
||||
return { el: h('div', { class: 'hex' }, body, inspector), inspector };
|
||||
}
|
||||
|
||||
/** Parse hex text ("de ad 0xBE, ef") into bytes; throws on bad input. */
|
||||
export function parseHex(text) {
|
||||
const cleaned = text.replace(/0x/gi, ' ').replace(/[,\s]+/g, ' ').trim();
|
||||
if (!cleaned) return new Uint8Array(0);
|
||||
const out = [];
|
||||
for (const tok of cleaned.split(' ')) {
|
||||
if (!/^[0-9a-fA-F]+$/.test(tok) || tok.length % 2) throw new Error(`"${tok}" is not whole bytes of hex`);
|
||||
for (let i = 0; i < tok.length; i += 2) out.push(parseInt(tok.slice(i, i + 2), 16));
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Halcyon Flight Control</title>
|
||||
<meta name="description" content="Program, launch and command your ship in the belt.">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script type="module" src="app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="stars" aria-hidden="true"></canvas>
|
||||
<div class="horizon" aria-hidden="true">
|
||||
<div class="sun"></div>
|
||||
<div class="floor"></div>
|
||||
</div>
|
||||
<div class="crt" aria-hidden="true"></div>
|
||||
|
||||
<div id="app">
|
||||
<p class="boot">INITIALISING TERMINAL...</p>
|
||||
</div>
|
||||
|
||||
<dialog id="dialog"></dialog>
|
||||
<div id="toasts" role="status" aria-live="polite"></div>
|
||||
|
||||
<noscript>
|
||||
<p class="boot">THIS TERMINAL REQUIRES JAVASCRIPT.</p>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
// Slowly drifting, twinkling starfield with the odd shooting star.
|
||||
|
||||
export function startStarfield(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const tints = ['#ffffff', '#ffffff', '#ffffff', '#9ff8ff', '#ffb3f0'];
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
let stars = [];
|
||||
let shooters = [];
|
||||
let nextShot = 4000;
|
||||
let last = performance.now();
|
||||
let raf = 0;
|
||||
|
||||
function resize() {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
w = window.innerWidth;
|
||||
h = window.innerHeight;
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const n = Math.min(320, Math.round((w * h) / 5500));
|
||||
stars = Array.from({ length: n }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
z: 0.25 + Math.random() * 0.75,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.6 + Math.random() * 1.8,
|
||||
tint: tints[Math.floor(Math.random() * tints.length)],
|
||||
}));
|
||||
draw(performance.now());
|
||||
}
|
||||
|
||||
function draw(now) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
for (const s of stars) {
|
||||
const tw = 0.5 + 0.5 * Math.sin(now / 1000 * s.speed + s.phase);
|
||||
ctx.globalAlpha = reduce.matches ? 0.5 * s.z : (0.25 + 0.75 * tw) * s.z;
|
||||
ctx.fillStyle = s.tint;
|
||||
const r = 0.4 + s.z * 1.3;
|
||||
ctx.fillRect(s.x, s.y, r, r);
|
||||
}
|
||||
for (const s of shooters) {
|
||||
const g = ctx.createLinearGradient(s.x, s.y, s.x - s.vx * 9, s.y - s.vy * 9);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.95)');
|
||||
g.addColorStop(1, 'rgba(255,58,216,0)');
|
||||
ctx.globalAlpha = Math.min(1, s.life);
|
||||
ctx.strokeStyle = g;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.x, s.y);
|
||||
ctx.lineTo(s.x - s.vx * 9, s.y - s.vy * 9);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
const dt = Math.min(64, now - last);
|
||||
last = now;
|
||||
for (const s of stars) {
|
||||
s.x -= s.z * 0.012 * dt;
|
||||
if (s.x < -2) { s.x = w + 2; s.y = Math.random() * h; }
|
||||
}
|
||||
nextShot -= dt;
|
||||
if (nextShot <= 0) {
|
||||
nextShot = 7000 + Math.random() * 9000;
|
||||
shooters.push({ x: Math.random() * w * 0.8 + w * 0.2, y: Math.random() * h * 0.4, vx: -(6 + Math.random() * 4), vy: 2 + Math.random() * 2, life: 1.4 });
|
||||
}
|
||||
for (const s of shooters) {
|
||||
s.x += s.vx * dt / 16;
|
||||
s.y += s.vy * dt / 16;
|
||||
s.life -= dt / 900;
|
||||
}
|
||||
shooters = shooters.filter((s) => s.life > 0);
|
||||
draw(now);
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function sync() {
|
||||
cancelAnimationFrame(raf);
|
||||
if (!reduce.matches) raf = requestAnimationFrame((t) => { last = t; frame(t); });
|
||||
else draw(performance.now());
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
reduce.addEventListener('change', sync);
|
||||
resize();
|
||||
sync();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Ship status presentation shared by several views.
|
||||
|
||||
export const STATUS = {
|
||||
inventory: { label: 'DOCKED', cls: 'amber', hint: 'On the dock. Can be programmed and launched.' },
|
||||
launching: { label: 'LAUNCH PENDING', cls: 'mag', hint: 'Enters the belt on the next daily run.' },
|
||||
active: { label: 'IN BELT', cls: 'cyan', hint: 'Flying its program.' },
|
||||
destroyed: { label: 'LOST', cls: 'red', hint: 'Destroyed.' },
|
||||
};
|
||||
|
||||
export const statusOf = (s) => STATUS[s] ?? { label: String(s).toUpperCase(), cls: '', hint: '' };
|
||||
@@ -0,0 +1,391 @@
|
||||
/* Halcyon Flight Control -- 1980s space terminal. */
|
||||
|
||||
:root {
|
||||
--bg0: #07030f;
|
||||
--bg1: #12072b;
|
||||
--ink: #ece7ff;
|
||||
--dim: #9a91c8;
|
||||
--faint: #4a3f7a;
|
||||
--cyan: #28f5ff;
|
||||
--mag: #ff3ad8;
|
||||
--amber: #ffb62e;
|
||||
--lime: #7dff8a;
|
||||
--red: #ff5577;
|
||||
--violet: #b18cff;
|
||||
--panel: rgba(13, 6, 34, 0.84);
|
||||
--line: rgba(40, 245, 255, 0.5);
|
||||
--mono: "VT323", "IBM Plex Mono", "SF Mono", "Cascadia Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
||||
--display: Eurostile, "Microgramma D Extended", "Bank Gothic", Orbitron, "Avenir Next Condensed", "Arial Narrow", "Trebuchet MS", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { color-scheme: dark; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
background: linear-gradient(180deg, #07030f 0%, #0f0626 40%, #2a0a4d 72%, #6a1268 100%) fixed;
|
||||
font-family: var(--mono);
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ---- backdrop ------------------------------------------------------------ */
|
||||
|
||||
#stars { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 0; }
|
||||
|
||||
.horizon {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; height: 46vh;
|
||||
z-index: 1; pointer-events: none; overflow: hidden;
|
||||
--sun: clamp(180px, 34vmin, 400px);
|
||||
--floor: 17vh;
|
||||
}
|
||||
|
||||
.sun {
|
||||
position: absolute; left: 50%; margin-left: calc(var(--sun) / -2);
|
||||
bottom: calc(var(--floor) - var(--sun) * 0.34);
|
||||
width: var(--sun); height: var(--sun); border-radius: 50%;
|
||||
background: linear-gradient(180deg, #fff27a 0%, #ffb62e 32%, #ff3ad8 72%, #8a2cff 100%);
|
||||
box-shadow: 0 0 70px 12px rgba(255, 58, 216, 0.5);
|
||||
-webkit-mask-image: linear-gradient(#000 0 46%, transparent 46% 49%, #000 49% 56%, transparent 56% 60%, #000 60% 67%, transparent 67% 72%, #000 72% 79%, transparent 79% 85%, #000 85% 91%, transparent 91% 98%, #000 98%);
|
||||
mask-image: linear-gradient(#000 0 46%, transparent 46% 49%, #000 49% 56%, transparent 56% 60%, #000 60% 67%, transparent 67% 72%, #000 72% 79%, transparent 79% 85%, #000 85% 91%, transparent 91% 98%, #000 98%);
|
||||
}
|
||||
|
||||
.floor {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; height: var(--floor);
|
||||
overflow: hidden; background: linear-gradient(#22093f, #0c0420);
|
||||
border-top: 2px solid var(--cyan);
|
||||
box-shadow: 0 -2px 26px rgba(40, 245, 255, 0.7);
|
||||
}
|
||||
.floor::before {
|
||||
content: ""; position: absolute; left: -60%; right: -60%; top: 0; height: 400%;
|
||||
transform-origin: 50% 0; transform: perspective(240px) rotateX(64deg);
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 58, 216, 0.8) 2px, transparent 2px),
|
||||
linear-gradient(90deg, rgba(255, 58, 216, 0.8) 2px, transparent 2px);
|
||||
background-size: 64px 64px;
|
||||
animation: grid-move 2.6s linear infinite;
|
||||
}
|
||||
@keyframes grid-move { to { background-position: 0 64px; } }
|
||||
|
||||
.crt {
|
||||
position: fixed; inset: 0; z-index: 100; pointer-events: none;
|
||||
background:
|
||||
repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0) 0 2px, rgba(0, 0, 0, 0.17) 3px, rgba(0, 0, 0, 0) 4px),
|
||||
radial-gradient(ellipse at center, transparent 58%, rgba(0, 0, 0, 0.5) 100%);
|
||||
animation: flicker 7s infinite;
|
||||
}
|
||||
@keyframes flicker { 0%, 100% { opacity: 1; } 47% { opacity: 1; } 48% { opacity: 0.86; } 50% { opacity: 1; } 83% { opacity: 0.93; } 84% { opacity: 1; } }
|
||||
|
||||
#app, dialog, #toasts { position: relative; z-index: 10; }
|
||||
|
||||
/* ---- type ---------------------------------------------------------------- */
|
||||
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
|
||||
.logo {
|
||||
margin: 0; font-family: var(--display); font-style: italic; font-weight: 900;
|
||||
letter-spacing: 0.16em; font-size: clamp(2.2rem, 9vw, 4.8rem); line-height: 1.05;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #d6fbff 36%, #ffb62e 50%, #ff3ad8 76%, #8a2cff 100%);
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
filter: drop-shadow(0 0 14px rgba(255, 58, 216, 0.55));
|
||||
}
|
||||
.logo.sm { font-size: 1.15rem; letter-spacing: 0.3em; filter: drop-shadow(0 0 8px rgba(255, 58, 216, 0.6)); }
|
||||
|
||||
.kicker { font-family: var(--display); letter-spacing: 0.5em; font-size: 0.8rem; color: var(--cyan); text-shadow: 0 0 8px var(--cyan); }
|
||||
.tagline { letter-spacing: 0.3em; color: var(--dim); font-size: 0.95rem; margin-top: 0.4rem; }
|
||||
|
||||
.dim { color: var(--dim); }
|
||||
.hint { color: var(--dim); font-size: 0.9rem; line-height: 1.4; }
|
||||
.warn { color: var(--amber); }
|
||||
.ok { color: var(--lime); }
|
||||
.bad { color: var(--red); }
|
||||
a { color: var(--cyan); }
|
||||
a:hover { color: var(--mag); }
|
||||
|
||||
.boot { padding: 3rem 1rem; text-align: center; color: var(--lime); letter-spacing: 0.2em; }
|
||||
.boot::after { content: "_"; animation: blink 1s steps(1) infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.skip { position: absolute; left: -999px; top: 0; background: var(--amber); color: #000; padding: 0.5rem 1rem; z-index: 200; }
|
||||
.skip:focus { left: 0; }
|
||||
|
||||
/* ---- layout -------------------------------------------------------------- */
|
||||
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1.4rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: rgba(7, 3, 15, 0.88); backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line); box-shadow: 0 2px 24px rgba(40, 245, 255, 0.12);
|
||||
}
|
||||
.top-brand { display: flex; align-items: baseline; gap: 0.9rem; letter-spacing: 0.25em; font-size: 0.85rem; }
|
||||
.top-stats { display: flex; flex-wrap: wrap; gap: 0.5rem; flex: 1; }
|
||||
.top-user { display: flex; align-items: center; gap: 0.6rem; margin-left: auto; }
|
||||
.callsign { color: var(--lime); letter-spacing: 0.15em; text-shadow: 0 0 8px rgba(125, 255, 138, 0.6); }
|
||||
|
||||
.chip { border: 1px solid var(--faint); padding: 0.05rem 0.65rem; font-size: 0.9rem; letter-spacing: 0.08em; background: rgba(0, 0, 0, 0.3); }
|
||||
.chip b { color: var(--amber); font-weight: 700; }
|
||||
.chip.ore b { color: var(--cyan); }
|
||||
|
||||
.navbar { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.6rem; max-width: 1100px; margin: 1rem auto 0; padding: 0 1rem; }
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.tab {
|
||||
font-family: var(--display); font-size: 0.85rem; letter-spacing: 0.22em; text-transform: uppercase; text-decoration: none;
|
||||
padding: 0.6rem 1.2rem; color: var(--dim); background: rgba(13, 6, 34, 0.7);
|
||||
border: 1px solid var(--faint); cursor: pointer;
|
||||
}
|
||||
button.tab { font-size: 0.85rem; }
|
||||
.tab:hover { color: var(--ink); border-color: var(--mag); }
|
||||
.tab.on { color: var(--bg0); background: var(--cyan); border-color: var(--cyan); box-shadow: 0 0 16px rgba(40, 245, 255, 0.6); font-weight: 700; }
|
||||
.shipsel { display: flex; align-items: center; gap: 0.6rem; font-size: 0.8rem; letter-spacing: 0.2em; color: var(--dim); }
|
||||
|
||||
.main { max-width: 1100px; margin: 0 auto; padding: 1rem 1rem 8rem; outline: none; }
|
||||
.foot { position: relative; z-index: 10; width: max-content; max-width: 94vw; margin: 0 auto 1.5rem; padding: 0.35rem 1rem; text-align: center; color: var(--dim); font-size: 0.75rem; letter-spacing: 0.18em; background: rgba(7, 3, 15, 0.82); border: 1px solid var(--faint); }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 1.2rem; align-items: start; }
|
||||
.stack { display: grid; gap: 0.8rem; }
|
||||
.stack-lg { display: grid; gap: 1.2rem; }
|
||||
.row { display: flex; gap: 0.6rem; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
.pad { padding: 0.6rem 1rem; }
|
||||
|
||||
/* ---- panels -------------------------------------------------------------- */
|
||||
|
||||
.panel {
|
||||
position: relative; background: var(--panel); border: 1px solid var(--line);
|
||||
box-shadow: 0 0 26px rgba(40, 245, 255, 0.13), inset 0 0 36px rgba(255, 58, 216, 0.05);
|
||||
}
|
||||
.panel::before {
|
||||
content: ""; position: absolute; inset: -3px; pointer-events: none;
|
||||
--c: var(--mag);
|
||||
background:
|
||||
linear-gradient(var(--c), var(--c)) top left / 16px 2px,
|
||||
linear-gradient(var(--c), var(--c)) top left / 2px 16px,
|
||||
linear-gradient(var(--c), var(--c)) top right / 16px 2px,
|
||||
linear-gradient(var(--c), var(--c)) top right / 2px 16px,
|
||||
linear-gradient(var(--c), var(--c)) bottom left / 16px 2px,
|
||||
linear-gradient(var(--c), var(--c)) bottom left / 2px 16px,
|
||||
linear-gradient(var(--c), var(--c)) bottom right / 16px 2px,
|
||||
linear-gradient(var(--c), var(--c)) bottom right / 2px 16px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.panel-title {
|
||||
display: flex; align-items: center; gap: 0.8rem; padding: 0.55rem 1rem;
|
||||
font-family: var(--display); font-size: 0.8rem; letter-spacing: 0.26em; text-transform: uppercase; color: var(--cyan);
|
||||
text-shadow: 0 0 8px rgba(40, 245, 255, 0.7);
|
||||
background: linear-gradient(90deg, rgba(255, 58, 216, 0.34), rgba(40, 245, 255, 0.08) 65%, transparent);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
summary.panel-title { cursor: pointer; list-style: none; }
|
||||
summary.panel-title::-webkit-details-marker { display: none; }
|
||||
summary.panel-title::before { content: "\25B6"; font-size: 0.7em; }
|
||||
details[open] > summary.panel-title::before { content: "\25BC"; }
|
||||
.panel-body { padding: 1rem; }
|
||||
.panel-body.flush { padding: 0; }
|
||||
.panel .dim.cursor, .panel-title .dim { text-shadow: none; letter-spacing: 0.1em; }
|
||||
|
||||
.empty { padding: 1.4rem 1rem; color: var(--dim); letter-spacing: 0.15em; }
|
||||
|
||||
/* ---- controls ------------------------------------------------------------ */
|
||||
|
||||
.btn {
|
||||
font: inherit; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; text-decoration: none;
|
||||
display: inline-block; text-align: center;
|
||||
color: var(--cyan); background: rgba(40, 245, 255, 0.06);
|
||||
border: 1px solid var(--cyan); padding: 0.5rem 1.1rem; cursor: pointer;
|
||||
box-shadow: 0 0 10px rgba(40, 245, 255, 0.25);
|
||||
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: var(--cyan); color: var(--bg0); box-shadow: 0 0 20px var(--cyan); }
|
||||
.btn.primary { color: #fff; border-color: var(--mag); background: rgba(255, 58, 216, 0.2); box-shadow: 0 0 12px rgba(255, 58, 216, 0.45); }
|
||||
.btn.primary:hover:not(:disabled) { background: var(--mag); color: var(--bg0); box-shadow: 0 0 22px var(--mag); }
|
||||
.btn.danger { color: #fff; border-color: var(--red); background: rgba(255, 85, 119, 0.2); box-shadow: 0 0 12px rgba(255, 85, 119, 0.45); }
|
||||
.btn.danger:hover:not(:disabled) { background: var(--red); color: var(--bg0); box-shadow: 0 0 22px var(--red); }
|
||||
.btn.small { padding: 0.2rem 0.7rem; font-size: 0.8rem; }
|
||||
.btn.wide { width: 100%; }
|
||||
.btn:disabled { opacity: 0.38; cursor: not-allowed; box-shadow: none; }
|
||||
.btn:focus-visible, .tab:focus-visible, .linklike:focus-visible, a:focus-visible,
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible, .keybox:focus-visible, .hx-body:focus-visible {
|
||||
outline: 2px solid var(--amber); outline-offset: 2px;
|
||||
}
|
||||
|
||||
.linklike { font: inherit; background: none; border: 0; color: var(--cyan); text-decoration: underline; cursor: pointer; padding: 0; }
|
||||
|
||||
label { display: block; font-size: 0.85rem; letter-spacing: 0.22em; color: var(--cyan); text-transform: uppercase; }
|
||||
label.check { display: flex; align-items: center; gap: 0.6rem; letter-spacing: 0.12em; color: var(--ink); cursor: pointer; }
|
||||
input[type="checkbox"] { accent-color: var(--mag); width: 1.1rem; height: 1.1rem; }
|
||||
|
||||
input[type="text"], input[type="password"], select, textarea {
|
||||
width: 100%; font: inherit; color: var(--ink);
|
||||
background: rgba(0, 0, 0, 0.5); border: 1px solid var(--faint); padding: 0.5rem 0.7rem;
|
||||
caret-color: var(--amber); border-radius: 0;
|
||||
}
|
||||
select { width: auto; max-width: 100%; color: var(--cyan); border-color: var(--line); }
|
||||
select option { background: var(--bg1); color: var(--ink); }
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--cyan); box-shadow: 0 0 14px rgba(40, 245, 255, 0.35); }
|
||||
textarea { resize: vertical; }
|
||||
.form-error { color: var(--red); min-height: 1.4em; letter-spacing: 0.06em; }
|
||||
|
||||
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0.6rem 0; }
|
||||
.toolbar.pad { margin: 0; }
|
||||
.sealrow { justify-content: space-between; margin-top: 0.8rem; }
|
||||
.sealrow .grow { flex: 1 1 260px; }
|
||||
.panel-title .dim { white-space: nowrap; }
|
||||
|
||||
.badge { display: inline-block; padding: 0.05rem 0.7rem; font-size: 0.8rem; letter-spacing: 0.15em; border: 1px solid currentColor; text-shadow: 0 0 8px currentColor; white-space: nowrap; }
|
||||
.badge.amber { color: var(--amber); }
|
||||
.badge.mag { color: var(--mag); animation: pulse 1.6s ease-in-out infinite; }
|
||||
.badge.cyan { color: var(--cyan); }
|
||||
.badge.red { color: var(--red); }
|
||||
@keyframes pulse { 50% { box-shadow: 0 0 14px var(--mag); } }
|
||||
|
||||
/* ---- fleet --------------------------------------------------------------- */
|
||||
|
||||
.manifest { width: 100%; border-collapse: collapse; }
|
||||
.manifest th { text-align: left; font-weight: 400; font-size: 0.8rem; letter-spacing: 0.2em; color: var(--dim); padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--line); }
|
||||
.manifest th, .manifest td { white-space: nowrap; }
|
||||
.manifest td { padding: 0.7rem 0.9rem; border-bottom: 1px dashed var(--faint); vertical-align: middle; }
|
||||
.manifest tr.sel td { background: rgba(255, 58, 216, 0.09); }
|
||||
.manifest tr.sel td:first-child { box-shadow: inset 3px 0 var(--mag); }
|
||||
.manifest td.actions { text-align: right; white-space: normal; }
|
||||
.manifest td.actions .btn + .btn { margin-left: 0.4rem; }
|
||||
|
||||
.steps { list-style: none; margin: 0; padding: 0.8rem 1rem 0; display: grid; gap: 0.7rem; }
|
||||
.steps li { display: flex; gap: 0.8rem; align-items: baseline; }
|
||||
.steps .n { flex: none; width: 1.7rem; height: 1.7rem; display: grid; place-items: center; border: 1px solid var(--mag); color: var(--mag); font-weight: 700; }
|
||||
.steps strong { color: var(--amber); letter-spacing: 0.12em; }
|
||||
|
||||
/* ---- editor -------------------------------------------------------------- */
|
||||
|
||||
.editor {
|
||||
--lh: 22px;
|
||||
display: flex; height: min(60vh, 540px); min-height: 300px; margin: 0.6rem 0;
|
||||
background: #05020c; border: 1px solid var(--line);
|
||||
font-family: var(--mono); font-size: 16px; line-height: var(--lh);
|
||||
}
|
||||
.ed-gutter { flex: none; width: 3.6em; overflow: hidden; padding: 8px 0.7em 8px 0; text-align: right; color: #7266b0; background: rgba(255, 58, 216, 0.06); border-right: 1px solid var(--faint); user-select: none; }
|
||||
.ed-num { height: var(--lh); }
|
||||
.ed-num.err { color: var(--red); font-weight: 700; }
|
||||
.ed-body { position: relative; flex: 1; min-width: 0; }
|
||||
.ed-hl, .ed-input {
|
||||
position: absolute; inset: 0; margin: 0; padding: 8px 12px; border: 0;
|
||||
font: inherit; line-height: var(--lh); letter-spacing: 0; white-space: pre; tab-size: 8;
|
||||
}
|
||||
.ed-hl { overflow: hidden; pointer-events: none; color: var(--ink); }
|
||||
.ed-line { height: var(--lh); white-space: pre; }
|
||||
.ed-line.err { background: rgba(255, 85, 119, 0.22); box-shadow: inset 3px 0 var(--red); }
|
||||
.ed-input { overflow: auto; resize: none; background: transparent; color: transparent; caret-color: var(--amber); outline: none; box-shadow: none; width: auto; }
|
||||
.ed-input:focus { box-shadow: none; }
|
||||
.ed-input::selection { background: rgba(40, 245, 255, 0.35); }
|
||||
.ed-body:focus-within { box-shadow: inset 0 0 0 1px var(--cyan), 0 0 14px rgba(40, 245, 255, 0.3); }
|
||||
|
||||
.hl-mn { color: var(--mag); font-weight: 700; }
|
||||
.hl-reg { color: var(--cyan); }
|
||||
.hl-num { color: var(--amber); }
|
||||
.hl-lbl { color: var(--lime); }
|
||||
.hl-dir { color: var(--violet); }
|
||||
.hl-sym { color: #ffd6f7; }
|
||||
.hl-cmt { color: #8278bd; font-style: italic; }
|
||||
.hl-pun { color: #a89fd6; }
|
||||
|
||||
.console { background: #05020c; border: 1px solid var(--faint); padding: 0.7rem 1rem; min-height: 5.4rem; margin-top: 0.6rem; }
|
||||
.console p + p { margin-top: 0.2rem; }
|
||||
|
||||
.meter { height: 12px; border: 1px solid var(--cyan); background: #05020c; margin-top: 0.5rem; }
|
||||
.meter .fill { height: 100%; width: 0; background: repeating-linear-gradient(90deg, var(--cyan) 0 6px, transparent 6px 8px); box-shadow: 0 0 10px var(--cyan); }
|
||||
.meter.over { border-color: var(--red); }
|
||||
.meter.over .fill { background: repeating-linear-gradient(90deg, var(--red) 0 6px, transparent 6px 8px); box-shadow: 0 0 10px var(--red); }
|
||||
|
||||
.refcard { margin: 0; padding: 1rem; overflow-x: auto; font-size: 0.85rem; line-height: 1.3; color: var(--lime); background: #05020c; }
|
||||
|
||||
/* ---- uplink -------------------------------------------------------------- */
|
||||
|
||||
.drop {
|
||||
display: grid; place-items: center; gap: 0.3rem; padding: 2rem 1rem; text-align: center; cursor: pointer;
|
||||
border: 2px dashed var(--cyan); background: rgba(40, 245, 255, 0.04);
|
||||
letter-spacing: 0.15em; color: var(--ink); font-size: 1rem;
|
||||
}
|
||||
.drop-big { font-family: var(--display); letter-spacing: 0.25em; color: var(--cyan); }
|
||||
.drop:hover, .drop.over { background: rgba(40, 245, 255, 0.12); box-shadow: 0 0 22px rgba(40, 245, 255, 0.4); border-color: var(--mag); }
|
||||
.drop.disabled { opacity: 0.4; cursor: not-allowed; border-color: var(--faint); }
|
||||
.uplink-grid { grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); }
|
||||
.fleet-grid { grid-template-columns: minmax(0, 1fr) 330px; }
|
||||
.uplink-preview .hx-body { font-size: 13px; }
|
||||
.uplink-preview .hx-row { gap: 0.9rem; }
|
||||
.panel-body > .hint, .panel-body > .hx-inspector + .hint { margin-top: 0.7rem; }
|
||||
.uplink-preview:empty::before { content: "NOTHING TO SEND YET."; color: var(--dim); letter-spacing: 0.15em; }
|
||||
|
||||
/* ---- hex viewer ---------------------------------------------------------- */
|
||||
|
||||
.hx-body { background: #05020c; border: 1px solid var(--faint); padding: 0.7rem 0.9rem; overflow-x: auto; font-size: 15px; line-height: 1.55; }
|
||||
.hx-row { display: flex; gap: 1.4rem; white-space: pre; width: max-content; }
|
||||
.hx-off { color: var(--amber); }
|
||||
.hx-b { display: inline-block; width: 2ch; text-align: center; cursor: default; }
|
||||
.hx-hex .hx-b { margin-right: 1ch; }
|
||||
.hx-asc { color: var(--lime); }
|
||||
.hx-asc .hx-b { width: 1ch; }
|
||||
.hx-b.z { color: #5a4f96; }
|
||||
.hx-b:hover { background: rgba(40, 245, 255, 0.4); color: #fff; }
|
||||
.hx-b.sel { background: var(--mag); color: #fff; }
|
||||
.hx-gap { display: inline-block; width: 1ch; }
|
||||
.hx-inspector { margin-top: 0.7rem; padding: 0.5rem 0.9rem; min-height: 2.6rem; border: 1px solid var(--line); color: var(--cyan); font-size: 0.95rem; letter-spacing: 0.06em; overflow-wrap: anywhere; }
|
||||
|
||||
.nosignal { text-align: center; padding: 2.5rem 1rem; }
|
||||
.nosignal .big { font-family: var(--display); font-size: 2.4rem; letter-spacing: 0.4em; color: var(--red); text-shadow: 0 0 16px var(--red); animation: blink 1.4s steps(1) infinite; }
|
||||
|
||||
/* ---- auth ---------------------------------------------------------------- */
|
||||
|
||||
.auth { min-height: 100vh; display: grid; place-content: center; justify-items: center; gap: 1.8rem; padding: 2rem 1rem 12rem; text-align: center; }
|
||||
.auth-panel { width: min(460px, 92vw); text-align: left; }
|
||||
.auth-panel .stack, .auth-panel .keyreveal { padding: 1.1rem; }
|
||||
.auth-tabs { gap: 0; }
|
||||
.auth-tabs .tab { flex: 1; text-align: center; border-top: 0; border-left: 0; border-right: 0; }
|
||||
.keybox { display: block; padding: 0.9rem; font-size: 1.2rem; word-break: break-all; color: var(--amber); border: 1px solid var(--amber); background: rgba(255, 182, 46, 0.07); text-shadow: 0 0 8px rgba(255, 182, 46, 0.6); user-select: all; }
|
||||
|
||||
/* ---- toasts & dialog ----------------------------------------------------- */
|
||||
|
||||
#toasts { position: fixed; right: 1rem; bottom: 1rem; display: grid; gap: 0.6rem; z-index: 300; }
|
||||
.toast { max-width: min(430px, 92vw); padding: 0.7rem 1rem; background: rgba(7, 3, 15, 0.96); border: 1px solid var(--cyan); box-shadow: 0 0 18px rgba(40, 245, 255, 0.4); letter-spacing: 0.05em; animation: slide-in 0.25s ease-out; }
|
||||
.toast.err { border-color: var(--red); box-shadow: 0 0 18px rgba(255, 85, 119, 0.5); color: #ffd0d9; }
|
||||
.toast.out { opacity: 0; transition: opacity 0.4s; }
|
||||
@keyframes slide-in { from { transform: translateX(30px); opacity: 0; } }
|
||||
|
||||
dialog { background: transparent; border: 0; padding: 0; color: inherit; max-width: 100vw; }
|
||||
dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px); }
|
||||
.dialog { width: min(480px, 92vw); }
|
||||
.dialog.danger { border-color: var(--red); }
|
||||
.dialog .panel-body p + p { margin-top: 0.7rem; }
|
||||
.dialog .actions { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0 1rem 1rem; }
|
||||
|
||||
/* ---- small screens ------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid-2, .uplink-grid, .fleet-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
body { font-size: 16px; }
|
||||
.topbar { position: static; }
|
||||
.toolbar .btn { padding: 0.4rem 0.8rem; font-size: 0.9rem; letter-spacing: 0.08em; }
|
||||
.sealrow .btn { width: 100%; }
|
||||
.panel-title { letter-spacing: 0.16em; }
|
||||
.auth { padding-bottom: 10rem; }
|
||||
.top-user { margin-left: 0; }
|
||||
.manifest thead { display: none; }
|
||||
.manifest tr { display: block; padding: 0.6rem 0; border-bottom: 1px dashed var(--faint); }
|
||||
.manifest td { display: flex; justify-content: space-between; align-items: center; gap: 1rem; border: 0; padding: 0.25rem 1rem; }
|
||||
.manifest td::before { content: attr(data-label); color: var(--dim); font-size: 0.75rem; letter-spacing: 0.2em; }
|
||||
.manifest td.actions { justify-content: flex-end; }
|
||||
.manifest td.actions::before { content: none; }
|
||||
.tab { padding: 0.5rem 0.8rem; letter-spacing: 0.12em; }
|
||||
.editor { height: 52vh; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Login and registration.
|
||||
|
||||
import { api, keyStore } from './api.js';
|
||||
import { h, clear, download, toast } from './dom.js';
|
||||
|
||||
export function authView({ onLogin }) {
|
||||
let mode = 'login';
|
||||
const host = h('section', { class: 'auth' });
|
||||
|
||||
const brand = h('header', { class: 'brand' },
|
||||
h('p', { class: 'kicker' }, 'HALCYON INSTRUMENT & CONTROL'),
|
||||
h('h1', { class: 'logo' }, 'FLIGHT CONTROL'),
|
||||
h('p', { class: 'tagline' }, 'BELT UPLINK TERMINAL // MODEL HC-33'),
|
||||
);
|
||||
|
||||
const panel = h('div', { class: 'panel auth-panel' });
|
||||
host.append(brand, panel);
|
||||
|
||||
function tabs() {
|
||||
const tab = (id, label) => h('button', {
|
||||
class: `tab ${mode === id ? 'on' : ''}`, type: 'button', role: 'tab',
|
||||
'aria-selected': mode === id ? 'true' : 'false',
|
||||
onclick: () => { mode = id; render(); },
|
||||
}, label);
|
||||
return h('div', { class: 'tabs auth-tabs', role: 'tablist' }, tab('login', 'LOGIN'), tab('register', 'NEW ACCOUNT'));
|
||||
}
|
||||
|
||||
function render() {
|
||||
clear(panel).append(tabs(), mode === 'login' ? loginForm() : registerForm());
|
||||
panel.querySelector('input')?.focus();
|
||||
}
|
||||
|
||||
function loginForm() {
|
||||
const err = h('p', { class: 'form-error', role: 'alert' });
|
||||
const key = h('input', { id: 'key', type: 'password', autocomplete: 'current-password', required: true, spellcheck: 'false' });
|
||||
const show = h('input', { type: 'checkbox', id: 'show', onchange: (e) => { key.type = e.target.checked ? 'text' : 'password'; } });
|
||||
const btn = h('button', { class: 'btn primary wide', type: 'submit' }, 'ENGAGE LINK');
|
||||
return h('form', {
|
||||
class: 'stack',
|
||||
onsubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
const k = key.value.trim();
|
||||
if (!k) return;
|
||||
btn.disabled = true;
|
||||
err.textContent = '';
|
||||
try {
|
||||
await api.me(k);
|
||||
keyStore.set(k);
|
||||
onLogin();
|
||||
} catch (ex) {
|
||||
err.textContent = ex.status === 401 ? 'ACCESS DENIED: KEY NOT RECOGNISED' : `ERROR: ${ex.message}`;
|
||||
btn.disabled = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
h('label', { for: 'key' }, 'ACCESS KEY'),
|
||||
key,
|
||||
h('label', { class: 'check', for: 'show' }, show, 'SHOW KEY'),
|
||||
err,
|
||||
btn,
|
||||
h('p', { class: 'hint' }, 'Your access key was shown once, when you registered. There is no password: the key is the login.'),
|
||||
);
|
||||
}
|
||||
|
||||
function registerForm() {
|
||||
const err = h('p', { class: 'form-error', role: 'alert' });
|
||||
const name = h('input', { id: 'name', type: 'text', maxlength: '40', required: true, autocomplete: 'username', spellcheck: 'false' });
|
||||
const btn = h('button', { class: 'btn primary wide', type: 'submit' }, 'REGISTER');
|
||||
return h('form', {
|
||||
class: 'stack',
|
||||
onsubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
const callsign = name.value.trim();
|
||||
if (!callsign) { err.textContent = 'ENTER A CALLSIGN'; return; }
|
||||
btn.disabled = true;
|
||||
err.textContent = '';
|
||||
try {
|
||||
const res = await api.register(callsign);
|
||||
showKey(callsign, res);
|
||||
} catch (ex) {
|
||||
err.textContent = ex.status === 409 ? 'CALLSIGN ALREADY IN USE' : `ERROR: ${ex.message}`;
|
||||
btn.disabled = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
h('label', { for: 'name' }, 'CALLSIGN'),
|
||||
name,
|
||||
h('p', { class: 'hint' }, '1 to 40 characters. You will be issued one command ship and a secret access key.'),
|
||||
err,
|
||||
btn,
|
||||
);
|
||||
}
|
||||
|
||||
function showKey(callsign, res) {
|
||||
const enter = h('button', { class: 'btn primary wide', type: 'button', disabled: true }, 'ENTER FLIGHT CONTROL');
|
||||
const ack = h('input', { type: 'checkbox', id: 'ack', onchange: (e) => { enter.disabled = !e.target.checked; } });
|
||||
enter.addEventListener('click', () => { keyStore.set(res.api_key); onLogin(); });
|
||||
clear(panel).append(
|
||||
h('div', { class: 'panel-title' }, 'ACCOUNT CREATED'),
|
||||
h('div', { class: 'stack keyreveal' },
|
||||
h('p', null, `WELCOME, ${callsign.toUpperCase()}. COMMAND SHIP #${res.ship_id} IS WAITING ON THE DOCK.`),
|
||||
h('p', { class: 'warn' }, 'THIS IS YOUR ACCESS KEY. IT IS SHOWN ONCE AND CANNOT BE RECOVERED.'),
|
||||
h('code', { class: 'keybox', tabindex: '0' }, res.api_key),
|
||||
h('div', { class: 'row' },
|
||||
h('button', { class: 'btn', type: 'button', onclick: async () => {
|
||||
try { await navigator.clipboard.writeText(res.api_key); toast('KEY COPIED TO CLIPBOARD'); } catch { toast('COPY FAILED: SELECT THE KEY AND COPY IT BY HAND', 'err'); }
|
||||
} }, 'COPY'),
|
||||
h('button', { class: 'btn', type: 'button', onclick: () => download(`halcyon-key-${callsign}.txt`, `${res.api_key}\n`, 'text/plain') }, 'SAVE AS FILE'),
|
||||
),
|
||||
h('label', { class: 'check', for: 'ack' }, ack, 'I HAVE STORED MY KEY SAFELY'),
|
||||
enter,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
render();
|
||||
return host;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Code editor: write assembly, check it, download it, seal it into a ship.
|
||||
|
||||
import { api, b64ToBytes } from './api.js';
|
||||
import { h, clear, toast, confirmDialog, download } from './dom.js';
|
||||
import { createEditor } from './editor.js';
|
||||
|
||||
const draftKey = (ship) => `wh.draft.${ship?.id ?? 0}`;
|
||||
const load = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
|
||||
const save = (k, v) => { try { localStorage.setItem(k, v); } catch { /* ignore */ } };
|
||||
|
||||
export function codeView(ctx) {
|
||||
const ship = ctx.ship;
|
||||
const limit = ctx.state.info?.program_bytes ?? 4096;
|
||||
const key = draftKey(ship);
|
||||
const canSeal = ship?.status === 'inventory';
|
||||
|
||||
const cursor = h('span', { class: 'dim' }, 'LN 1 COL 1');
|
||||
let timer = 0;
|
||||
const editor = createEditor({
|
||||
value: load(key) ?? '',
|
||||
onInput: (v) => { clearTimeout(timer); timer = setTimeout(() => save(key, v), 300); },
|
||||
onCursor: ({ line, col }) => { cursor.textContent = `LN ${line} COL ${col}`; },
|
||||
});
|
||||
if (load(key) === null) api.example('telemetry').then((t) => { editor.value = t; }).catch(() => {});
|
||||
|
||||
// ---- console -----------------------------------------------------------
|
||||
const output = h('div', { class: 'console', role: 'log', 'aria-live': 'polite' },
|
||||
h('p', { class: 'dim' }, 'READY. ASSEMBLE TO CHECK YOUR PROGRAM.'));
|
||||
const say = (...nodes) => clear(output).append(...nodes);
|
||||
|
||||
let assembled = null;
|
||||
async function assemble() {
|
||||
try {
|
||||
const r = await api.assemble(editor.value);
|
||||
editor.setError(null);
|
||||
const bytes = b64ToBytes(r.program);
|
||||
assembled = { bytes, ...r };
|
||||
const pct = Math.min(100, Math.round((r.size / r.limit) * 100));
|
||||
say(
|
||||
h('p', { class: r.fits ? 'ok' : 'bad' }, r.fits ? 'ASSEMBLED OK' : (r.size === 0 ? 'NOTHING TO ASSEMBLE' : 'PROGRAM TOO LARGE FOR THIS BELT')),
|
||||
h('p', null, `${r.size} BYTES / ${r.instructions} INSTRUCTIONS / LIMIT ${r.limit} BYTES`),
|
||||
h('div', { class: `meter ${r.fits ? '' : 'over'}`, role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': String(r.limit), 'aria-valuenow': String(r.size), 'aria-label': 'Program size' },
|
||||
h('div', { class: 'fill', style: null, 'data-pct': String(pct) })),
|
||||
);
|
||||
output.querySelector('.fill')?.style.setProperty('width', `${pct}%`);
|
||||
return assembled;
|
||||
} catch (e) {
|
||||
assembled = null;
|
||||
const m = /^line (\d+):/.exec(e.message);
|
||||
if (m) editor.setError(Number(m[1]));
|
||||
say(h('p', { class: 'bad' }, `ERROR: ${e.message}`));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- toolbar -----------------------------------------------------------
|
||||
const btn = (label, onclick, opts = {}) =>
|
||||
h('button', { class: `btn ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
|
||||
|
||||
const exampleSel = h('select', { id: 'example', 'aria-label': 'Example program' }, h('option', { value: '' }, 'EXAMPLES...'));
|
||||
api.examples().then((names) => {
|
||||
for (const n of names.filter((n) => n !== 'ports')) exampleSel.append(h('option', { value: n }, n.toUpperCase()));
|
||||
});
|
||||
exampleSel.addEventListener('change', async () => {
|
||||
const name = exampleSel.value;
|
||||
exampleSel.value = '';
|
||||
if (!name) return;
|
||||
if (editor.value.trim() && !(await confirmDialog({ title: 'REPLACE EDITOR CONTENTS?', body: `LOAD THE ${name.toUpperCase()} EXAMPLE OVER YOUR CURRENT TEXT.`, confirmText: 'REPLACE' }))) return;
|
||||
try { editor.value = await api.example(name); say(h('p', { class: 'dim' }, `LOADED ${name.toUpperCase()}.`)); } catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
|
||||
const bytesOrNull = async () => {
|
||||
const r = await assemble();
|
||||
if (r && r.size === 0) { toast('NOTHING TO ASSEMBLE', 'err'); return null; }
|
||||
return r;
|
||||
};
|
||||
|
||||
const seal = btn(ship ? `SEAL INTO SHIP #${ship.id}` : 'NO SHIP', async () => {
|
||||
const r = await bytesOrNull();
|
||||
if (!r) return;
|
||||
if (!r.fits) { toast(`PROGRAM IS ${r.size} BYTES; THE LIMIT IS ${r.limit}`, 'err'); return; }
|
||||
try {
|
||||
await api.setProgram(ship.id, r.bytes);
|
||||
toast(`PROGRAM SEALED IN SHIP #${ship.id} (${r.size} BYTES). READY TO LAUNCH.`);
|
||||
await ctx.refresh();
|
||||
} catch (e) { toast(`UPLOAD FAILED: ${e.message}`, 'err'); }
|
||||
}, { cls: 'primary', disabled: !canSeal, title: canSeal ? 'Store this program in the ship' : 'Only docked ships can be reprogrammed' });
|
||||
|
||||
const toolbar = h('div', { class: 'toolbar' },
|
||||
exampleSel,
|
||||
btn('INSERT EQUATES', async () => {
|
||||
if (/\bP_UPNEW\b/.test(editor.value)) { toast('EQUATES ALREADY PRESENT'); return; }
|
||||
try { editor.insertAtTop(`${await api.example('ports')}\n`); } catch (e) { toast(e.message, 'err'); }
|
||||
}),
|
||||
h('span', { class: 'spacer' }),
|
||||
btn('ASSEMBLE', assemble),
|
||||
btn('DOWNLOAD .BIN', async () => { const r = await bytesOrNull(); if (r) download(`ship-${ship?.id ?? 'x'}-program.bin`, r.bytes); }),
|
||||
btn('SAVE .S', () => download(`ship-${ship?.id ?? 'x'}.s`, editor.value, 'text/plain')),
|
||||
);
|
||||
|
||||
// ---- reference ---------------------------------------------------------
|
||||
const card = h('pre', { class: 'refcard' }, 'LOADING...');
|
||||
const ref = h('details', { class: 'panel ref', onToggle: async (e) => {
|
||||
if (!e.target.open || card.dataset.loaded) return;
|
||||
card.dataset.loaded = '1';
|
||||
try {
|
||||
const txt = await (await fetch('/manual.txt')).text();
|
||||
const a = txt.indexOf('APPENDIX E QUICK REFERENCE CARD');
|
||||
const b = txt.indexOf('* * * END OF MANUAL');
|
||||
card.textContent = txt.slice(a, b).split('\n').slice(3).join('\n').trim();
|
||||
} catch { card.textContent = 'COULD NOT LOAD THE MANUAL.'; }
|
||||
} },
|
||||
h('summary', { class: 'panel-title' }, 'QUICK REFERENCE (HC-33 MANUAL, APPENDIX E)'),
|
||||
card,
|
||||
h('p', { class: 'hint pad' }, h('a', { href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'OPEN THE FULL MANUAL')),
|
||||
);
|
||||
|
||||
const note = !ship
|
||||
? 'YOU HAVE NO SHIP TO PROGRAM.'
|
||||
: canSeal
|
||||
? `PROGRAMMING SHIP #${ship.id}. SEALING REPLACES ANY EARLIER PROGRAM UNTIL LAUNCH.`
|
||||
: `SHIP #${ship.id} IS ${ship.status.toUpperCase()}: ITS PROGRAM IS SEALED. YOU CAN STILL EDIT AND DOWNLOAD HERE.`;
|
||||
|
||||
const view = h('div', { class: 'stack-lg' },
|
||||
h('section', { class: 'panel' },
|
||||
h('div', { class: 'panel-title' }, 'FLIGHT PROGRAM EDITOR', h('span', { class: 'spacer' }), cursor),
|
||||
h('div', { class: 'panel-body' },
|
||||
toolbar,
|
||||
editor.el,
|
||||
h('div', { class: 'toolbar sealrow' }, h('p', { class: 'hint grow' }, note), seal),
|
||||
h('p', { class: 'hint' }, 'DRAFTS ARE KEPT IN THIS BROWSER ONLY. TAB INDENTS; ESC THEN TAB LEAVES THE EDITOR.'),
|
||||
output,
|
||||
),
|
||||
),
|
||||
ref,
|
||||
);
|
||||
queueMicrotask(() => editor.render());
|
||||
return view;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Downlink viewer: hex dump of the ship's transmit buffer, with download.
|
||||
|
||||
import { api } from './api.js';
|
||||
import { h, clear, toast, download } from './dom.js';
|
||||
import { hexView } from './hexview.js';
|
||||
|
||||
export function downlinkView(ctx) {
|
||||
const ship = ctx.ship;
|
||||
const body = h('div', { class: 'panel-body' }, h('p', { class: 'dim' }, ship ? 'ACQUIRING SIGNAL...' : 'YOU HAVE NO SHIP.'));
|
||||
const meta = h('span', { class: 'dim' });
|
||||
const dl = h('button', { class: 'btn', type: 'button', disabled: true }, 'DOWNLOAD .BIN');
|
||||
const refresh = h('button', { class: 'btn', type: 'button', disabled: !ship }, 'REFRESH');
|
||||
|
||||
let current = null;
|
||||
async function fetchIt() {
|
||||
if (!ship) return;
|
||||
refresh.disabled = true;
|
||||
try {
|
||||
const { bytes, day } = await api.downlink(ship.id);
|
||||
current = { bytes, day };
|
||||
const allZero = bytes.every((b) => b === 0);
|
||||
meta.textContent = `SHIP #${ship.id} // FROM THE RUN OF DAY ${day} // ${bytes.length} BYTES`;
|
||||
dl.disabled = false;
|
||||
clear(body).append(
|
||||
...(allZero ? [h('p', { class: 'warn' }, 'BUFFER IS ALL ZEROS: THE SHIP HAS NOT WRITTEN ANYTHING TO ITS DOWNLINK BUFFER.')] : []),
|
||||
hexView(bytes).el,
|
||||
h('p', { class: 'hint' }, 'OFFSETS ARE INTO THE 1 KB DOWNLINK BUFFER (SHIP RAM 1024-2047). MULTI-BYTE VALUES ARE LITTLE-ENDIAN.'),
|
||||
);
|
||||
} catch (e) {
|
||||
current = null;
|
||||
dl.disabled = true;
|
||||
meta.textContent = '';
|
||||
clear(body).append(
|
||||
e.status === 404
|
||||
? h('div', { class: 'nosignal' },
|
||||
h('p', { class: 'big' }, 'NO SIGNAL'),
|
||||
h('p', { class: 'dim' }, 'THIS SHIP HAS NOT TRANSMITTED YET. A SHIP\'S FIRST DOWNLINK ARRIVES AFTER ITS FIRST DAY IN THE BELT.'))
|
||||
: h('p', { class: 'bad' }, `ERROR: ${e.message}`),
|
||||
);
|
||||
} finally {
|
||||
refresh.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reloading the account too keeps the ship's status and the day current.
|
||||
refresh.addEventListener('click', () => ctx.refresh().catch((e) => toast(e.message, 'err')));
|
||||
dl.addEventListener('click', () => {
|
||||
if (!current) return;
|
||||
download(`ship-${ship.id}-day-${current.day}-downlink.bin`, current.bytes);
|
||||
toast(`SAVED ${current.bytes.length} BYTES`);
|
||||
});
|
||||
fetchIt();
|
||||
|
||||
return h('section', { class: 'panel' },
|
||||
h('div', { class: 'panel-title' }, 'DOWNLINK RECEIVER', h('span', { class: 'spacer' }), meta),
|
||||
h('div', { class: 'toolbar pad' }, refresh, h('span', { class: 'spacer' }), dl),
|
||||
body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Fleet manifest with launch controls.
|
||||
|
||||
import { api } from './api.js';
|
||||
import { h, toast, confirmDialog } from './dom.js';
|
||||
import { statusOf } from './status.js';
|
||||
|
||||
export function fleetView(ctx) {
|
||||
const { ships, selected } = ctx.state;
|
||||
|
||||
async function launch(ship) {
|
||||
const ok = await confirmDialog({
|
||||
title: `LAUNCH SHIP #${ship.id}?`,
|
||||
body: [
|
||||
'THE SHIP ENTERS THE BELT ON THE NEXT DAILY RUN.',
|
||||
'ITS PROGRAM IS SEALED AT LAUNCH. IT CANNOT BE CHANGED, RECALLED OR RESTARTED.',
|
||||
],
|
||||
confirmText: 'LAUNCH',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.launch(ship.id);
|
||||
toast(`SHIP #${ship.id} CLEARED FOR LAUNCH. IT FLIES ON THE NEXT DAILY RUN.`);
|
||||
await ctx.refresh();
|
||||
} catch (e) {
|
||||
toast(`LAUNCH FAILED: ${e.message}`, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
const btn = (label, onclick, opts = {}) =>
|
||||
h('button', { class: `btn small ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
|
||||
|
||||
function actions(s) {
|
||||
const out = [];
|
||||
if (s.status === 'inventory') {
|
||||
out.push(btn('CODE', () => ctx.selectShip(s.id, 'code')));
|
||||
out.push(btn('LAUNCH', () => launch(s), {
|
||||
cls: 'primary',
|
||||
disabled: s.program_bytes === 0,
|
||||
title: s.program_bytes === 0 ? 'Upload a program first' : 'Launch on the next daily run',
|
||||
}));
|
||||
}
|
||||
if (s.status === 'launching' || s.status === 'active') out.push(btn('UPLINK', () => ctx.selectShip(s.id, 'uplink')));
|
||||
if (s.downlink_day >= 0) out.push(btn('DOWNLINK', () => ctx.selectShip(s.id, 'downlink')));
|
||||
return out;
|
||||
}
|
||||
|
||||
const rows = ships.map((s) => {
|
||||
const st = statusOf(s.status);
|
||||
return h('tr', { class: s.id === selected ? 'sel' : '' },
|
||||
h('td', { 'data-label': 'SHIP' }, h('button', { class: 'linklike', type: 'button', onclick: () => ctx.selectShip(s.id) }, `#${s.id}`)),
|
||||
h('td', { 'data-label': 'STATUS' }, h('span', { class: `badge ${st.cls}`, title: st.hint }, st.label)),
|
||||
h('td', { 'data-label': 'PROGRAM' }, s.program_bytes ? `${s.program_bytes} BYTES` : h('span', { class: 'dim' }, 'NONE')),
|
||||
h('td', { 'data-label': 'LAST DOWNLINK' }, s.downlink_day >= 0 ? `DAY ${s.downlink_day}` : h('span', { class: 'dim' }, '--')),
|
||||
h('td', { class: 'actions', 'data-label': '' }, actions(s)),
|
||||
);
|
||||
});
|
||||
|
||||
const step = (n, title, text) => h('li', null, h('span', { class: 'n' }, n), h('div', null, h('strong', null, title), ' ', text));
|
||||
|
||||
return h('div', { class: 'grid-2 fleet-grid' },
|
||||
h('section', { class: 'panel' },
|
||||
h('div', { class: 'panel-title' }, 'FLEET MANIFEST'),
|
||||
h('div', { class: 'panel-body flush' },
|
||||
ships.length
|
||||
? h('table', { class: 'manifest' },
|
||||
h('thead', null, h('tr', null, ['SHIP', 'STATUS', 'PROGRAM', 'LAST DOWNLINK', ''].map((t) => h('th', { scope: 'col' }, t)))),
|
||||
h('tbody', null, rows))
|
||||
: h('p', { class: 'empty' }, 'NO SHIPS ON RECORD.'),
|
||||
),
|
||||
),
|
||||
h('aside', { class: 'panel brief' },
|
||||
h('div', { class: 'panel-title' }, 'MISSION BRIEF'),
|
||||
h('ol', { class: 'steps' },
|
||||
step('1', 'WRITE', 'a flight program in the CODE editor. Your ship cannot be flown by hand.'),
|
||||
step('2', 'SEAL', 'it into a docked ship. You can replace it until launch.'),
|
||||
step('3', 'LAUNCH', 'the ship. It enters the belt on the next daily run and the program is fixed for good.'),
|
||||
step('4', 'TALK', 'to it once a day: 1 KB up, 1 KB down, over the wormhole link.'),
|
||||
),
|
||||
h('p', { class: 'hint pad' }, 'The belt is simulated once a day. Ore delivered to the station earns credits.'),
|
||||
h('p', { class: 'pad' }, h('a', { class: 'btn small', href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'OPEN THE HC-33 MANUAL')),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Uplink form: choose a binary file (or type hex) and transmit it.
|
||||
|
||||
import { api } from './api.js';
|
||||
import { h, clear, toast } from './dom.js';
|
||||
import { hexView, parseHex } from './hexview.js';
|
||||
|
||||
export function uplinkView(ctx) {
|
||||
const ship = ctx.ship;
|
||||
const limit = ctx.state.info?.comm_bytes ?? 1024;
|
||||
const open = ship && (ship.status === 'launching' || ship.status === 'active');
|
||||
|
||||
let bytes = null;
|
||||
let label = '';
|
||||
|
||||
const preview = h('div', { class: 'uplink-preview' });
|
||||
const meter = h('div', { class: 'meter', role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': String(limit), 'aria-label': 'Message size' }, h('div', { class: 'fill' }));
|
||||
const sizeText = h('p', { class: 'dim' }, `NO MESSAGE LOADED. LIMIT ${limit} BYTES.`);
|
||||
const err = h('p', { class: 'form-error', role: 'alert' });
|
||||
const send = h('button', { class: 'btn primary', type: 'button', disabled: true }, ship ? `TRANSMIT TO SHIP #${ship.id}` : 'NO SHIP');
|
||||
|
||||
function set(b, from) {
|
||||
bytes = b;
|
||||
label = from;
|
||||
err.textContent = '';
|
||||
const over = b.length > limit;
|
||||
meter.classList.toggle('over', over);
|
||||
meter.setAttribute('aria-valuenow', String(b.length));
|
||||
meter.querySelector('.fill').style.setProperty('width', `${Math.min(100, (b.length / limit) * 100)}%`);
|
||||
sizeText.textContent = `${from}: ${b.length} BYTES OF ${limit}${over ? ' -- TOO LARGE' : ''}`;
|
||||
if (over) err.textContent = `MESSAGE EXCEEDS THE ${limit}-BYTE LINK. REMOVE ${b.length - limit} BYTES.`;
|
||||
else if (b.length === 0) err.textContent = 'MESSAGE IS EMPTY.';
|
||||
send.disabled = !open || over || b.length === 0;
|
||||
clear(preview);
|
||||
if (b.length) {
|
||||
preview.append(hexView(b, { rows: 8 }).el);
|
||||
if (b.length > 128) preview.append(h('p', { class: 'hint' }, `SHOWING THE FIRST 128 OF ${b.length} BYTES.`));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- file chooser / drop zone ------------------------------------------
|
||||
const file = h('input', { type: 'file', id: 'uplink-file', class: 'sr-only' });
|
||||
const drop = h('label', { class: `drop ${open ? '' : 'disabled'}`, for: 'uplink-file' },
|
||||
h('span', { class: 'drop-big' }, 'DROP A BINARY FILE HERE'),
|
||||
h('span', { class: 'dim' }, 'or click to choose one'),
|
||||
);
|
||||
file.disabled = !open;
|
||||
const takeFile = async (f) => {
|
||||
if (!f) return;
|
||||
if (f.size > 1 << 20) { err.textContent = 'THAT FILE IS FAR TOO LARGE FOR THE LINK.'; return; }
|
||||
set(new Uint8Array(await f.arrayBuffer()), f.name.toUpperCase());
|
||||
};
|
||||
file.addEventListener('change', () => takeFile(file.files[0]));
|
||||
for (const ev of ['dragenter', 'dragover']) drop.addEventListener(ev, (e) => { e.preventDefault(); if (open) drop.classList.add('over'); });
|
||||
for (const ev of ['dragleave', 'drop']) drop.addEventListener(ev, () => drop.classList.remove('over'));
|
||||
drop.addEventListener('drop', (e) => { e.preventDefault(); if (open) takeFile(e.dataTransfer.files[0]); });
|
||||
|
||||
// ---- hex entry ---------------------------------------------------------
|
||||
const hexIn = h('textarea', { id: 'hex-in', rows: '3', spellcheck: 'false', placeholder: 'DE AD BE EF 00 01 ...', disabled: !open, 'aria-label': 'Message as hexadecimal' });
|
||||
const useHex = h('button', { class: 'btn small', type: 'button', disabled: !open, onclick: () => {
|
||||
try { set(parseHex(hexIn.value), 'HEX ENTRY'); } catch (e) { err.textContent = `HEX ERROR: ${e.message}`; }
|
||||
} }, 'USE HEX');
|
||||
|
||||
send.addEventListener('click', async () => {
|
||||
send.disabled = true;
|
||||
try {
|
||||
await api.setUplink(ship.id, bytes);
|
||||
toast(`UPLINK QUEUED FOR SHIP #${ship.id}: ${bytes.length} BYTES. DELIVERED AT THE START OF THE NEXT RUN.`);
|
||||
} catch (e) {
|
||||
err.textContent = `TRANSMISSION FAILED: ${e.message}`;
|
||||
send.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const status = !ship
|
||||
? 'YOU HAVE NO SHIP.'
|
||||
: open
|
||||
? `TARGET: SHIP #${ship.id}. A NEW UPLINK REPLACES ONE ALREADY QUEUED.`
|
||||
: `SHIP #${ship.id} IS ${ship.status.toUpperCase()}. UPLINKS CAN ONLY BE SENT TO SHIPS THAT ARE LAUNCHING OR IN THE BELT.`;
|
||||
|
||||
return h('div', { class: 'grid-2 uplink-grid' },
|
||||
h('section', { class: 'panel' },
|
||||
h('div', { class: 'panel-title' }, 'UPLINK TRANSMITTER'),
|
||||
h('div', { class: 'panel-body stack' },
|
||||
h('p', { class: open ? 'hint' : 'warn' }, status),
|
||||
drop, file,
|
||||
h('label', { for: 'hex-in' }, 'OR ENTER HEX'),
|
||||
hexIn,
|
||||
h('div', null, useHex),
|
||||
meter, sizeText, err,
|
||||
h('div', null, send),
|
||||
),
|
||||
),
|
||||
h('section', { class: 'panel' },
|
||||
h('div', { class: 'panel-title' }, 'OUTGOING MESSAGE'),
|
||||
h('div', { class: 'panel-body' },
|
||||
preview,
|
||||
h('p', { class: 'hint' }, 'THE MESSAGE IS OPAQUE BYTES. YOUR SHIP\'S PROGRAM DECIDES WHAT IT MEANS. IT ARRIVES IN THE UPLINK BUFFER (RAM 0-1023) BEFORE THE FIRST TICK OF THE NEXT DAILY RUN.'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user