fix
This commit is contained in:
+173
-63
@@ -1,27 +1,37 @@
|
||||
// Code editor: write assembly, check it, download it, seal it into a ship.
|
||||
// Code editor with sixteen saved slots. Slots 1-3 are read-only examples; 4-16
|
||||
// are the player's own. Launching a ship (Fleet tab) picks which slot it flies.
|
||||
|
||||
import { api, b64ToBytes } from './api.js';
|
||||
import { h, clear, toast, confirmDialog, download } from './dom.js';
|
||||
import { h, clear, toast, confirmDialog, customDialog, download, pad } 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 */ } };
|
||||
const DEFAULT_SLOT = 2; // the telemetry example, a working program to start from
|
||||
|
||||
const readJSON = (k) => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } };
|
||||
const writeJSON = (k, v) => { try { localStorage.setItem(k, JSON.stringify(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';
|
||||
// The unsaved working copy survives reloads, per account and per browser.
|
||||
const draftKey = `halcyon.editor.${ctx.state.me?.name ?? ''}`;
|
||||
|
||||
const cursor = h('span', { class: 'dim' }, 'LN 1 COL 1');
|
||||
let slots = []; // [{ slot, name, builtin, empty }] from the server
|
||||
let current = { slot: DEFAULT_SLOT, name: '', builtin: true, saved: '' }; // what the editor is bound to
|
||||
let ready = false;
|
||||
let timer = 0;
|
||||
|
||||
const info = (n) => slots.find((s) => s.slot === n) ?? { slot: n, name: '', builtin: false, empty: true };
|
||||
const slotLabel = (s) => `${pad(s.slot, 2)} ${s.builtin ? `${s.name} (EXAMPLE)` : s.empty ? '(EMPTY)' : s.name}`;
|
||||
|
||||
const cursor = h('span', { class: 'dim nowrap' }, 'LN 1 COL 1');
|
||||
const editor = createEditor({
|
||||
value: load(key) ?? '',
|
||||
onInput: (v) => { clearTimeout(timer); timer = setTimeout(() => save(key, v), 300); },
|
||||
value: '',
|
||||
onInput: (v) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => writeJSON(draftKey, { slot: current.slot, text: v }), 150);
|
||||
renderStatus();
|
||||
},
|
||||
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' },
|
||||
@@ -33,14 +43,13 @@ export function codeView(ctx) {
|
||||
try {
|
||||
const r = await api.assemble(editor.value);
|
||||
editor.setError(null);
|
||||
const bytes = b64ToBytes(r.program);
|
||||
assembled = { bytes, ...r };
|
||||
assembled = { bytes: b64ToBytes(r.program), ...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) })),
|
||||
h('div', { class: 'fill' })),
|
||||
);
|
||||
output.querySelector('.fill')?.style.setProperty('width', `${pct}%`);
|
||||
return assembled;
|
||||
@@ -52,50 +61,138 @@ export function codeView(ctx) {
|
||||
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' });
|
||||
// ---- slots -------------------------------------------------------------
|
||||
const btn = (label, onclick, opts = {}) =>
|
||||
h('button', { class: `btn ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
|
||||
|
||||
const toolbar = h('div', { class: 'toolbar' },
|
||||
exampleSel,
|
||||
const slotSel = h('select', { id: 'slot', class: 'span2', 'aria-label': 'Code slot', disabled: true });
|
||||
const status = h('p', { class: 'hint slot-status', 'aria-live': 'polite' }, 'LOADING YOUR SLOTS...');
|
||||
const saveBtn = btn('SAVE', () => save(), { disabled: true });
|
||||
const saveAsBtn = btn('SAVE AS...', () => saveAs(), { disabled: true });
|
||||
const clearBtn = btn('CLEAR SLOT', () => clearSlot(), { cls: 'span2', disabled: true });
|
||||
|
||||
const dirty = () => editor.value !== current.saved;
|
||||
|
||||
function renderSelect() {
|
||||
slotSel.replaceChildren(...slots.map((s) => h('option', { value: s.slot, selected: s.slot === current.slot }, slotLabel(s))));
|
||||
slotSel.value = String(current.slot);
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
if (!ready) return;
|
||||
const s = info(current.slot);
|
||||
status.textContent = `SLOT ${current.slot}: ${s.builtin ? `${s.name} (EXAMPLE, READ-ONLY)` : s.empty ? 'EMPTY' : s.name}${dirty() ? ' // UNSAVED CHANGES' : ''}`;
|
||||
status.classList.toggle('warn', dirty());
|
||||
saveBtn.disabled = current.builtin;
|
||||
saveBtn.title = current.builtin ? 'The examples are read-only: use SAVE AS to keep a copy in your own slot' : `Save in slot ${current.slot}`;
|
||||
saveAsBtn.disabled = false;
|
||||
clearBtn.disabled = current.builtin || s.empty;
|
||||
}
|
||||
|
||||
async function openSlot(n, { keepText = null } = {}) {
|
||||
const s = await api.slot(n);
|
||||
current = { slot: n, name: s.name, builtin: s.builtin, saved: s.source };
|
||||
editor.value = keepText ?? s.source;
|
||||
writeJSON(draftKey, { slot: n, text: editor.value });
|
||||
renderSelect();
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
slotSel.addEventListener('change', async () => {
|
||||
const n = Number(slotSel.value);
|
||||
if (dirty() && !(await confirmDialog({
|
||||
title: 'DISCARD UNSAVED CHANGES?',
|
||||
body: `YOU HAVE CHANGES IN SLOT ${current.slot} THAT ARE NOT SAVED. OPENING SLOT ${n} REPLACES THEM.`,
|
||||
confirmText: 'DISCARD', danger: true,
|
||||
}))) { slotSel.value = String(current.slot); return; }
|
||||
try { await openSlot(n); say(h('p', { class: 'dim' }, `OPENED SLOT ${n}.`)); } catch (e) { toast(e.message, 'err'); slotSel.value = String(current.slot); }
|
||||
});
|
||||
|
||||
async function put(n, name) {
|
||||
try {
|
||||
await api.saveSlot(n, name, editor.value);
|
||||
slots = slots.map((s) => (s.slot === n ? { slot: n, name, builtin: false, empty: false } : s));
|
||||
current = { slot: n, name, builtin: false, saved: editor.value };
|
||||
writeJSON(draftKey, { slot: n, text: editor.value });
|
||||
renderSelect();
|
||||
renderStatus();
|
||||
toast(`SAVED IN SLOT ${n}: ${name}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast(`SAVE FAILED: ${e.message}`, 'err');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (current.builtin) return saveAs();
|
||||
if (!current.name) return saveAs();
|
||||
return put(current.slot, current.name);
|
||||
}
|
||||
|
||||
async function saveAs() {
|
||||
const writable = slots.filter((s) => !s.builtin);
|
||||
const firstFree = writable.find((s) => s.empty);
|
||||
const target = current.builtin ? (firstFree ?? writable[0]).slot : current.slot;
|
||||
const sel = h('select', { id: 'sa-slot' }, writable.map((s) => h('option', { value: s.slot, selected: s.slot === target }, slotLabel(s))));
|
||||
const name = h('input', { id: 'sa-name', type: 'text', maxlength: '40', value: current.name || 'MY PROGRAM', spellcheck: 'false' });
|
||||
const warn = h('p', { class: 'warn', 'aria-live': 'polite' });
|
||||
const dlg = customDialog({
|
||||
title: 'SAVE IN A SLOT',
|
||||
content: [h('label', { for: 'sa-slot' }, 'SLOT'), sel, h('label', { for: 'sa-name' }, 'NAME'), name, warn],
|
||||
confirmText: 'SAVE',
|
||||
});
|
||||
const check = () => {
|
||||
const s = info(Number(sel.value));
|
||||
warn.textContent = s.empty || s.slot === current.slot ? '' : `THIS REPLACES "${s.name}".`;
|
||||
dlg.confirm.disabled = !name.value.trim();
|
||||
};
|
||||
sel.addEventListener('change', check);
|
||||
name.addEventListener('input', check);
|
||||
name.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault(); // otherwise the same keystroke lands on the SAVE AS button that regains focus, and reopens this dialog
|
||||
if (!dlg.confirm.disabled) dlg.close(true);
|
||||
});
|
||||
check();
|
||||
name.select();
|
||||
if (await dlg.result) await put(Number(sel.value), name.value.trim());
|
||||
}
|
||||
|
||||
async function clearSlot() {
|
||||
const s = info(current.slot);
|
||||
if (!(await confirmDialog({ title: `CLEAR SLOT ${s.slot}?`, body: [`"${s.name}" WILL BE DELETED FROM THE SLOT.`, 'THE TEXT NOW IN THE EDITOR STAYS UNTIL YOU CHANGE SLOT.'], confirmText: 'CLEAR SLOT', danger: true }))) return;
|
||||
try {
|
||||
await api.deleteSlot(s.slot);
|
||||
slots = slots.map((x) => (x.slot === s.slot ? { slot: s.slot, name: '', builtin: false, empty: true } : x));
|
||||
current = { slot: s.slot, name: '', builtin: false, saved: '' };
|
||||
renderSelect();
|
||||
renderStatus();
|
||||
toast(`SLOT ${s.slot} CLEARED`);
|
||||
} catch (e) { toast(`CLEAR FAILED: ${e.message}`, 'err'); }
|
||||
}
|
||||
|
||||
// ---- bars --------------------------------------------------------------
|
||||
const bar = (label, ...buttons) =>
|
||||
h('div', { class: 'bar', role: 'group', 'aria-label': label }, h('span', { class: 'bar-label' }, label), h('div', { class: 'bar-buttons' }, ...buttons));
|
||||
|
||||
const slotBar = bar('SLOT', slotSel, saveBtn, saveAsBtn, clearBtn);
|
||||
const buildBar = bar('BUILD',
|
||||
btn('ASSEMBLE', assemble),
|
||||
btn('DOWNLOAD .BIN', async () => { const r = await bytesOrNull(); if (r) download(`slot-${current.slot}-program.bin`, r.bytes); }),
|
||||
);
|
||||
const fileBar = bar('FILE',
|
||||
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')),
|
||||
btn('DOWNLOAD .S', () => download(`slot-${current.slot}.s`, editor.value, 'text/plain')),
|
||||
);
|
||||
|
||||
// ---- reference ---------------------------------------------------------
|
||||
@@ -104,36 +201,49 @@ export function codeView(ctx) {
|
||||
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();
|
||||
// Appendix E of the manual source is one fenced block: the quick reference card.
|
||||
const txt = await (await fetch('/manual.md')).text();
|
||||
const open = txt.indexOf('```', txt.indexOf('# Appendix E'));
|
||||
const close = txt.indexOf('```', open + 3);
|
||||
card.textContent = txt.slice(txt.indexOf('\n', open) + 1, close).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')),
|
||||
h('p', { class: 'hint pad' }, h('a', { href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE FULL MANUAL (PDF)')),
|
||||
);
|
||||
|
||||
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,
|
||||
h('p', { class: 'hint' }, 'YOU HAVE 16 CODE SLOTS: 1-3 HOLD EXAMPLES, 4-16 ARE YOURS. TO FLY A PROGRAM, LAUNCH A SHIP FROM THE FLEET TAB AND CHOOSE ITS SLOT.'),
|
||||
slotBar,
|
||||
status,
|
||||
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.'),
|
||||
h('p', { class: 'hint' }, 'YOUR UNSAVED TEXT IS KEPT IN THIS BROWSER. TAB INDENTS; ESC THEN TAB LEAVES THE EDITOR.'),
|
||||
buildBar,
|
||||
fileBar,
|
||||
output,
|
||||
),
|
||||
),
|
||||
ref,
|
||||
);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
slots = await api.slots();
|
||||
const draft = readJSON(draftKey);
|
||||
const start = draft && slots.some((s) => s.slot === draft.slot) ? draft.slot : DEFAULT_SLOT;
|
||||
ready = true;
|
||||
slotSel.disabled = false;
|
||||
await openSlot(start, { keepText: draft && draft.slot === start && typeof draft.text === 'string' ? draft.text : null });
|
||||
} catch (e) {
|
||||
status.textContent = `COULD NOT LOAD YOUR SLOTS: ${e.message}`;
|
||||
status.classList.add('bad');
|
||||
}
|
||||
})();
|
||||
|
||||
queueMicrotask(() => editor.render());
|
||||
return view;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user