Back to Napcraft
SOURCE / PINNED RELEASE

Made of little things.

Napcraft

Release
ee47ac6a25df…
Author-recorded commit
410dea87e109…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-52f9e22f5ce

Archive hash verified: 9e6e0cabb18d0d46…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
import './styles.css';
import { cvm, webrtc, storage, identity } from '@napplet/sdk';
import backend from '../.napplet-space/soy-backend.json';
import {
  backendClient,
  BackendCallError,
} from '../docs/examples/backend-client';
import type {
  BackendIntent,
  BackendTarget,
} from '../docs/examples/backend-client';
import { runtimeHasDomain } from './domain-availability.js';
import { Scene } from './scene';
import { MATERIALS, practiceWorld, cell, top } from './world';
import type { World } from './world';
import { SharedEditor, validJournal } from './shared-editor';
import type { EditJournal } from './shared-editor';
import { mountWorldList, mountListingControl } from './directory-ui';

const FOLLOW_HOST_THEME = false;
void FOLLOW_HOST_THEME;
const $ = <T extends HTMLElement = HTMLElement>(id: string) =>
  document.getElementById(id) as T;
const scene = new Scene($<HTMLCanvasElement>('scene'), practiceWorld());
const api = backendClient({ cvm }),
  moduleRef = { napplet: backend.napplet, name: 'worlds' };
async function readSavedWorld(t: BackendTarget) {
  for (let attempt=0;;attempt++) {
    try { return await api.invoke<World>(api.intent(t,'readWorld',{})); }
    catch (error) {
      // Queries can conflict with another player's transaction. A fresh query is
      // safe; this retry never changes or resubmits a world-creation intent.
      if (!(error instanceof BackendCallError) || error.code !== 'CONFLICT' || attempt>=2) throw error;
      await new Promise(resolve=>setTimeout(resolve,150*(attempt+1)));
    }
  }
}
type Recent = { code: string; name: string };
type KeptSave = { id: string; savedAt: number; intent?: BackendIntent; journal?: EditJournal; code?: string };
let keptSaves: KeptSave[] = [];
let target: BackendTarget | undefined,
  recents: Recent[] = [],
  pending: BackendIntent | undefined;
let mode: 'lobby' | 'practice' | 'shared' = 'lobby',
  material = 4,
  busy = false,
  session = '',
  seq = 0,
  sending = false,
  lastSnapshot = -1;
let liveState = 'Waiting for friends';
let playerActor = '';
let spawnFresh = false;
let pollTimer = 0,
  sendTimer = 0,
  toastTimer = 0,
  identitySub: { close: () => void } | undefined,
  identityKey = '',
  epoch = 0;
const protocol = 'napcraft-presence-v1';
const connectedPeers = new Set<string>();
let editor: SharedEditor | undefined;
let editJournal: EditJournal | undefined;
async function persistEdits(journal: EditJournal | null) {
  editJournal = journal ?? undefined;
  if (runtimeHasDomain('storage'))
    await storage.setItem('napcraft-edit-journal-v1', JSON.stringify(journal));
}
function unfinished() { return Boolean(pending || editor?.hasWork || editJournal); }
async function recoverEdits() {
  if (editor) { editor.retry(); closePanel(); return; }
  if (busy || !editJournal) return;
  setBusy(true);
  try {
    const journal = editJournal;
    const t = parseCode(codeFor(journal.target));
    if (!validJournal(journal,t)) throw new Error('Invalid recovery data. Keep your world code and contact the creator.');
    const r = await readSavedWorld(t);
    await enter(t,r.result,r.revision,journal);
  } catch (error) { showError(error); }
  finally { setBusy(false); }
}
async function hintSaved() {
  if (!session || !connectedPeers.size || !target) return;
  try { await webrtc.send(session,{v:1,kind:'saved',instance:target.instance,release:target.release}); } catch {}
}
scene.player.color = crypto.getRandomValues(new Uint8Array(1))[0] % 6;
function toast(message: string) {
  $('toast').textContent = message;
  $('toast').hidden = false;
  clearTimeout(toastTimer);
  toastTimer = window.setTimeout(() => ($('toast').hidden = true), 4200);
}
function status(message: string, error = false) {
  $('saveStatus').textContent = message;
  $('saveStatus').classList.toggle('warning', error);
}
function showPanel(html: string) {
  scene.keys.clear();
  $('panel').classList.remove('directory-panel');
  $('overlay').hidden = false;
  $('panel').innerHTML = html;
  const close = $('close');
  if (close) close.onclick = closePanel;
  setTimeout(
    () => $('panel').querySelector<HTMLElement>('input,button')?.focus(),
    20,
  );
}
function closePanel() {
  if (mode === 'lobby') return;
  $('overlay').hidden = true;
  scene.canvas.focus();
}
const closer = () =>
  mode === 'lobby'
    ? ''
    : '<button id="close" class="close" aria-label="Close dialog">×</button>';
function escape(value: string) {
  const span = document.createElement('span');
  span.textContent = value;
  return span.innerHTML;
}
function codeFor(t: BackendTarget) {
  return (
    'NC1-' +
    btoa(JSON.stringify([t.release, t.instance]))
      .replaceAll('+', '-')
      .replaceAll('/', '_')
      .replace(/=+$/, '')
  );
}
function parseCode(value: string): BackendTarget {
  try {
    if (value.length > 300 || !value.startsWith('NC1-')) throw 0;
    const [release, instance] = JSON.parse(
      atob(value.trim().slice(4).replaceAll('-', '+').replaceAll('_', '/')),
    );
    if (
      !/^[a-f0-9]{64}$/.test(release) ||
      typeof instance !== 'string' ||
      !/^[a-zA-Z0-9_-]{1,64}$/.test(instance)
    )
      throw 0;
    return { module: moduleRef, release, instance };
  } catch {
    throw new Error(
      'That world code looks incomplete. Paste the entire NC1- code.',
    );
  }
}
async function saveLocal(required = false) {
  if (!runtimeHasDomain('storage')) {
    if (mode === 'shared')
      toast(
        'Keep your world code to return. This host has no recent-world storage.',
      );
    return;
  }
  try {
    await storage.setItem('napcraft-worlds-v1', JSON.stringify(recents));
    await storage.setItem(
      'napcraft-pending-v1',
      JSON.stringify(pending ?? null),
    );
  } catch (error) {
    if (required) throw new Error('Could not keep your recovery request. Retry when host storage is available.');
    toast('World is online; recent-world saving failed. Keep the world code.');
  }
}
async function loadLocal() {
  recents = [];
  pending = undefined;
  editJournal = undefined;
  keptSaves = [];
  if (runtimeHasDomain('storage'))
    try {
      const raw = await storage.getItem('napcraft-worlds-v1');
      const entries = raw ? JSON.parse(String(raw)) : [];
      recents = Array.isArray(entries)
        ? entries
            .filter(
              (x) => typeof x?.name === 'string' && typeof x?.code === 'string',
            )
            .slice(0, 8)
        : [];
      const journal = await storage.getItem('napcraft-edit-journal-v1');
      if (journal) {
        const value = JSON.parse(String(journal));
        if (value?.target?.module?.napplet === backend.napplet && value.target.module.name === 'worlds' && validJournal(value,value.target)) editJournal = value;
      }
      const p = await storage.getItem('napcraft-pending-v1');
      if (p) {
        const saved = JSON.parse(String(p));
        if (
          saved?.target?.module?.napplet === backend.napplet &&
          typeof saved.requestId === 'string'
        )
          pending = saved;
      }
      const kept = JSON.parse(String(await storage.getItem('napcraft-kept-saves-v1') || '[]'));
      if (Array.isArray(kept)) keptSaves = kept.filter(k => k && typeof k.id === 'string' &&
        (k.intent?.target?.module?.napplet === backend.napplet || k.journal?.target?.module?.napplet === backend.napplet)).slice(0,8);
    } catch {
      /* Optional local history is not authoritative world state. */
    }
}
function lobby() {
  showPanel(
    `${closer()}<span class="stamp">WELCOME TO NAPCRAFT</span><div class="lobby-heading"><h1>Find your little<br>corner of the world.</h1><button id="create" class="primary">✦ Create a world</button></div>${unfinished() || editor?.uncertain ? '<button id="recover" class="wide">Review unfinished save</button>' : ''}${keptSaves.length ? '<button id="kept" class="wide">Kept recovery records</button>' : ''}<section id="publicWorlds" aria-label="Public worlds"></section><p id="error" class="error" role="status"></p><div class="secondary-row"><button id="join">↗ Join with a code</button><button id="practice">Try a practice island</button></div>${recents.length?'<h3 class="recents-heading">Your recent worlds</h3>':''}<div id="recents"></div><p class="footnote">Sign in to create or list a world. Friends can join as guests.<br>Listings are shared invitations, not live player counts.</p>`,
  );
  $('panel').classList.add('directory-panel');
  mountWorldList($('publicWorlds'),code=>joinWorld(code),identityKey);
  $('create').onclick = () => createDialog();
  $('join').onclick = joinDialog;
  $('practice').onclick = () => void startPractice();
  if ($('recover')) $('recover').onclick = () => recoveryDialog();
  if ($('kept')) $('kept').onclick = keptDialog;
  for (const recent of recents) {
    const b = document.createElement('button');
    b.className = 'recent';
    b.textContent = `▧ ${recent.name}  →`;
    $('recents').append(b);
    b.onclick = () => void joinWorld(recent.code);
  }
}
function createDialog(separate = false) {
  if (unfinished()) { recoveryDialog(); return; }
  const unresolved = keptSaves.find(k => k.intent?.operation === 'createWorld' && !k.code);
  if (unresolved && !separate) { recoveryDialog(unresolved); return; }
  showPanel(
    `${closer()}<span class="stamp">A FRESH START</span><h2>Your world awaits.</h2><label for="name">Give your island a name</label><input id="name" maxlength="48" value="Our little island"/><button id="confirmCreate" class="primary wide">Create shared world</button><p class="footnote">Your host will ask to use your signed-in account. Anyone with the code can build here. Worlds are public; the code is not a password.</p><p id="error" class="error"></p><button id="back" class="linkish">← Back</button>`,
  );
  if (unresolved) {
    const notice=document.createElement('label'); notice.className='recovery-consent';
    notice.innerHTML='<input id="separateWorld" type="checkbox"/> I want a separate world. My older request may already have created one; keep its recovery record.';
    $('confirmCreate').before(notice);
    $<HTMLInputElement>('name').value='A different little island';
  }
  $('confirmCreate').onclick = () => void createWorld();
  $('back').onclick = lobby;
}
function joinDialog() {
  showPanel(
    `${closer()}<span class="stamp">BETTER TOGETHER</span><h2>Meet on the island.</h2><label for="code">Paste a world code</label><input id="code" class="code" placeholder="NC1-…" autocomplete="off" spellcheck="false"/><button id="confirmJoin" class="primary wide">Join world</button><p id="error" class="error"></p><p class="footnote">Keep this code: it works again after everyone leaves. Anyone with it can build.</p><button id="back" class="linkish">← Back</button>`,
  );
  $('confirmJoin').onclick = () =>
    void joinWorld($<HTMLInputElement>('code').value.trim());
  $('back').onclick = lobby;
}
function showError(error: unknown) {
  const message = error instanceof Error ? error.message : String(error);
  if ($('error')) $('error').textContent = message;
  else toast(message);
  status('Connection needs attention', true);
}
async function keepRecovery(intent?: BackendIntent, journal?: EditJournal) {
  if (!runtimeHasDomain('storage')) throw new Error('This host cannot keep recovery records. Copy the details before leaving; your pending request stays here.');
  const id = intent?.requestId || journal?.active?.intent.requestId || `edits-${journal?.target.instance}`;
  const old = keptSaves.find(k=>k.id===id);
  if (old) return old;
  if (keptSaves.length >= 8) throw new Error('Eight recovery records are already kept. Copy these details and resolve an earlier record first.');
  const record: KeptSave = {id,savedAt:Date.now(),...(intent?{intent:structuredClone(intent)}:{}),...(journal?{journal:structuredClone(journal)}:{})};
  const next=[record,...keptSaves];
  await storage.setItem('napcraft-kept-saves-v1',JSON.stringify(next));
  keptSaves=next;
  return record;
}
function keptDialog() {
  showPanel(`${closer()}<span class="stamp">NOTHING THROWN AWAY</span><h2>Kept recovery records.</h2><p>These copies are never sent automatically. Recover a missing world with its code, or deliberately choose a separate new world.</p><div id="records"></div><button id="back" class="linkish">← World menu</button>`);
  for (const record of keptSaves) {
    const b=document.createElement('button'); b.className='recent';
    b.textContent=record.code?'Recovered world · View record':record.intent?.operation==='createWorld'?'Unresolved world creation':'Block recovery copy';
    b.onclick=()=>recoveryDialog(record); $('records').append(b);
  }
  $('back').onclick=lobby;
}
function recoveryDialog(record?: KeptSave) {
  const intent=record ? record.intent : pending, journal=record ? record.journal : editJournal;
  const creation=intent?.operation==='createWorld';
  const expired=Boolean(intent && intent.expiresAt <= Date.now()/1000);
  const name=creation ? String((intent.input as {name?:string})?.name || 'Your world') : 'Your blocks';
  const text=creation ? expired ?
    'This older request can no longer be safely retried. Your world might already exist, but its code never arrived. The original request is kept; creating a replacement could make a duplicate.' :
    'The world code has not arrived yet. Check your host or signer for an approval prompt. Retrying uses the exact same saved request, so a lost reply will not create a second world.' :
    `${journal?.queue.length || 1} block or setting change(s) need review. We will check the saved world and retry only when it is safe. A recovery copy is kept before reviewing older work.`;
  showPanel(`${closer()}<span class="stamp">LET’S PICK UP WHERE YOU LEFT OFF</span><h2>${record?.code?'Recovered world.':creation?'Let’s find your world.':'Your unfinished save.'}</h2><p><b>${escape(name)}</b></p><p>${record?.code?'This request was linked to the world code you recognized. Its original details are retained.':text}</p>${!record && (!creation || !expired)?'<button id="retrySave" class="primary wide">'+(creation?'Retry saved request':'Review and retry safely')+'</button>':''}${creation && !record?.code?'<label for="recoveredCode">Already have this world’s code?</label><input id="recoveredCode" class="code" placeholder="NC1-…" autocomplete="off"/><button id="checkCode" class="wide">Check recovered world</button>':''}${creation&&expired&&!record?'<button id="keepSave" class="wide">Keep request and continue</button>':''}<p id="error" class="error" role="status"></p><details><summary>Recovery details to keep or share with support</summary><textarea id="recoveryDetails" class="code" readonly rows="6">${escape(JSON.stringify(record || {intent,journal},null,2))}</textarea></details><p class="footnote">${record?'You can join an existing world or use practice from World menu.':'Nothing is discarded when you close this panel.'}</p><button id="back" class="linkish">← World menu</button>`);
  $('back').onclick=lobby;
  if (record?.intent?.operation==='createWorld' && !record.code) {
    const different=document.createElement('button'); different.id='differentWorld'; different.className='wide';
    different.textContent='Create a different world…';
    different.onclick=()=>createDialog(true); $('back').before(different);
  }
  if ($('retrySave')) $('retrySave').onclick=()=>void (async()=>{
    if (busy) return;
    if (creation) { await retryPending(); return; }
    try {
      if (journal || intent) await keepRecovery(intent,journal);
      await (pending ? retryPending() : recoverEdits());
    } catch(error) { showError(error); }
  })();
  if ($('keepSave')) $('keepSave').onclick=()=>void (async()=>{
    if (busy || !intent || pending !== intent) return;
    try {
      await keepRecovery(intent);
      await storage.setItem('napcraft-pending-v1','null');
      pending=undefined; lobby(); toast('Request kept. You can join another island or practice while its creation is unresolved.');
    } catch(error) { showError(error); }
  })();
  if ($('checkCode')) $('checkCode').onclick=()=>void (async()=>{
    if (busy || !intent) return;
    setBusy(true);
    try {
      const code=$<HTMLInputElement>('recoveredCode').value.trim(), t=parseCode(code);
      if (t.release !== intent.target.release) throw new Error('That code is from a different world release. Keep this request and check the original world code.');
      const r=await readSavedWorld(t);
      showPanel(`${closer()}<h2>Is this your missing world?</h2><p><b>${escape(r.result.name)}</b><br>${r.result.edits} saved edits.</p><p>Only continue if you recognize this as the world you were creating. We will retain the original request and open this code.</p><button id="useRecovered" class="primary wide">Yes, open this world</button><button id="back" class="wide">Keep looking</button><p id="error" class="error"></p>`);
      $('back').onclick=()=>recoveryDialog(record);
      $('useRecovered').onclick=()=>void (async()=>{
        if (busy) return;
        setBusy(true);
        try {
          const saved=record || await keepRecovery(intent);
          const next=keptSaves.map(k=>k.id===saved.id?{...k,code}:k);
          await storage.setItem('napcraft-kept-saves-v1',JSON.stringify(next)); keptSaves=next;
          if (pending===intent) {await storage.setItem('napcraft-pending-v1','null');pending=undefined;}
          recents=[{code,name:r.result.name},...recents.filter(v=>v.code!==code)].slice(0,8);await saveLocal();
          await enter(t,r.result,r.revision);
        } catch(error) {showError(error);} finally {setBusy(false);}
      })();
    } catch(error) {showError(error);} finally {setBusy(false);}
  })();
}
function setBusy(value: boolean) {
  busy = value;
  for (const id of ['confirmCreate', 'confirmJoin', 'place', 'remove', 'retrySave', 'checkCode', 'keepSave', 'useRecovered'])
    if ($(id)) $<HTMLButtonElement>(id).disabled = value;
  for (const [id,label] of [['confirmCreate','Create shared world'],['retrySave','Retry saved request']] as const) {
    if ($(id) && (value || id==='confirmCreate')) $(id).textContent=value?'Waiting for approval or reply…':label;
  }
}
async function invoke(intent: BackendIntent) {
  return api.invoke<World>(intent);
}
async function createWorld() {
  if (busy) return;
  if (unfinished()) {
    toast('Resolve the unfinished save before creating another world.');
    return;
  }
  if (keptSaves.some(k=>k.intent?.operation==='createWorld'&&!k.code) && !$<HTMLInputElement>('separateWorld')?.checked) {
    showError(new Error('Confirm that you want a separate world. Your earlier request will remain in Kept recovery records.'));
    return;
  }
  setBusy(true);
  try {
    const desc = await api.describe(moduleRef);
    if (desc.disabled || !desc.active)
      throw new Error(
        'Shared world hosting is not active yet. Try practice, or retry shortly.',
      );
    pending = api.intent(
      { module: moduleRef, release: desc.active },
      'createWorld',
      { name: $<HTMLInputElement>('name').value.trim() || 'Our little island' },
    );
    await saveLocal(true);
    await finishPending();
  } catch (error) {
    if (pending) recoveryDialog();
    showError(error);
  } finally {
    setBusy(false);
  }
}
async function finishPending() {
  if (!pending) return;
  const intent = pending;
  const generation = epoch;
  if (intent.expiresAt <= Date.now() / 1000 || intent.operation === 'editBlock') {
    if (intent.target.instance) {
      const fresh = await readSavedWorld(intent.target);
      if (generation !== epoch) return;
      pending = undefined;
      await saveLocal();
      await enter(intent.target, fresh.result, fresh.revision);
      toast(
        intent.operation === 'editBlock' ? 'Older pending save refreshed. Review your last block before editing again.' :
        'Retry expired. World refreshed; check your last block before editing again.',
      );
      return;
    }
    throw new Error(
      'Creation response expired before its world code was recovered. Do not create a duplicate without checking with the provider.',
    );
  }
  const result = await invoke(intent);
  if (generation !== epoch) return;
  const t = {
    module: moduleRef,
    release: result.release,
    instance: result.instance,
  };
  const code = codeFor(t);
  recents = [
    { code, name: result.result.name },
    ...recents.filter((r) => r.code !== code),
  ].slice(0, 8);
  // Keep the original intent until the recovered code has been stored. If that
  // write fails, an exact retry can still retrieve the same creation receipt.
  await saveLocal(true);
  pending = undefined;
  await saveLocal();
  await enter(t, result.result, result.revision);
  void hintSaved();
  status('✓ All blocks saved');
}
async function retryPending() {
  if (busy || !pending) return;
  setBusy(true);
  try {
    await finishPending();
  } catch (error) {
    recoveryDialog();
    showError(error);
  } finally {
    setBusy(false);
  }
}
async function joinWorld(code: string) {
  if (busy) return;
  setBusy(true);
  try {
    if (unfinished())
      throw new Error('Retry your unfinished save before changing worlds.');
    const t = parseCode(code);
    const result = await readSavedWorld(t);
    recents = [
      { code, name: result.result.name },
      ...recents.filter((r) => r.code !== code),
    ].slice(0, 8);
    await saveLocal();
    await enter(t, result.result, result.revision);
  } catch (error) {
    showError(error);
  } finally {
    setBusy(false);
  }
}
function applyWorld(world: World, revision: number) {
  if (!world || !Array.isArray(world.blocks) || world.blocks.length !== 3888)
    throw new Error('Invalid world snapshot.');
  if (revision < lastSnapshot) return;
  scene.world = world;
  lastSnapshot = revision;
  $('worldName').textContent = world.name;
  $('app').dataset.revision = String(revision);
  status(world.locked ? 'Building paused by owner' : '✓ All blocks saved');
}
async function stopSession() {
  epoch++;
  editor?.dispose();
  editor = undefined;
  scene.pendingCells.clear();
  clearInterval(pollTimer);
  clearInterval(sendTimer);
  scene.peers.clear();
  connectedPeers.clear();
  spawnFresh = false;
  const old = session;
  session = '';
  if (old)
    try {
      await webrtc.close(old, 'Leaving world');
    } catch {}
  scene.keys.clear();
}
async function enter(t: BackendTarget, world: World, revision: number, journal?: EditJournal) {
  const sameWorld = mode === 'shared' && target?.instance === t.instance && target?.release === t.release;
  await stopSession();
  target = t;
  mode = 'shared';
  lastSnapshot = -1;
  applyWorld(world, revision);
  const generation = epoch;
  // Query the pinned release, not the current default: old worlds keep their rules.
  let compact = false;
  try {
    const description = await api.describe(moduleRef,t.release);
    const schemas = description.schemas as {operations?:Record<string,unknown>} | undefined;
    compact = Boolean(schemas?.operations?.editBlocks && schemas.operations.readUpdates);
  } catch { /* Legacy operations work on both releases; retain the existing world. */ }
  if (generation !== epoch) return;
  const transport = await cvm.registry.call('soy.rooms.v1','soy_session',{}, {timeoutMs:15000});
  const actor = (transport.structuredContent as {actor?:unknown} | undefined)?.actor;
  if (transport.isError || typeof actor !== 'string' || !/^[a-f0-9]{64}$/.test(actor))
    throw new Error('Could not verify your player session. Rejoin before saving blocks.');
  if (generation !== epoch) return;
  playerActor = actor;
  if (!sameWorld) {
    scene.player = {
      x: 6.5 + (parseInt(actor.slice(0,4),16)%5)*1.3,
      z: 8.5 + (parseInt(actor.slice(4,8),16)%4)*1.3,
      color: parseInt(actor.slice(8,12),16)%6,
      name: 'You',
    };
    spawnFresh = true;
  }
  editor = new SharedEditor(t,world,revision,compact,api,{
    persist:persistEdits,
    saved:() => { void hintSaved(); },
    notice:toast,
    change(view) {
      if (generation !== epoch) return;
      scene.world = view.world;
      scene.pendingCells = view.cells;
      scene.confirmedEdits = view.confirmedEdits;
      $('app').dataset.revision = String(view.revision);
      $('app').dataset.pending = String(view.pending);
      $('app').dataset.sync = view.state;
      const message = view.state === 'retry' || view.state === 'refreshing' ? view.message || 'Refreshing saved blocks…' :
        view.pending ? `${view.pending} block${view.pending === 1 ? '' : 's'} pending · Saving…` :
        view.world.locked ? 'Building paused by owner' : '✓ All blocks saved';
      status(message,view.state === 'retry');
    },
  },journal,actor);
  editor.start();
  activate();
  $('share').hidden = false;
  $('worldMode').textContent = 'Shared island · Creative';
  try {
    const result = await webrtc.open({
      scope: { type: 'room', room: `napcraft-${t.instance}` },
      channel: 'garden',
      protocol,
    });
    if (generation !== epoch) { await webrtc.close(result.session.id,'World changed'); return; }
    session = result.session.id;
    liveState = 'Waiting for friends';
  } catch (error) {
    liveState = 'Live players unavailable';
    $('worldMode').textContent = 'Shared · Live players unavailable';
    toast(
      `Blocks are saved. Live players unavailable: ${error instanceof Error ? error.message : 'Connection denied'}`,
    );
  }
  pollTimer = window.setInterval(() => { if (!document.hidden) void refresh(); }, 2600);
  sendTimer = window.setInterval(() => void sendPresence(), 110);
}
async function startPractice() {
  if (unfinished()) {
    toast('Retry your unfinished save before changing worlds.');
    return;
  }
  await stopSession();
  target = undefined;
  mode = 'practice';
  scene.world = practiceWorld();
  scene.player = { x: 8, z: 11, color: 0, name: 'You' };
  $('worldName').textContent = 'Practice island';
  $('worldMode').textContent = 'Creative · Temporary';
  $('share').hidden = true;
  status('Practice only · Changes are not saved');
  activate();
}
function activate() {
  scene.active = true;
  $('playHud').hidden = false;
  $('overlay').hidden = true;
  scene.canvas.focus();
}
async function refresh() {
  if (mode === 'shared' && !busy && !pending) editor?.sync();
}
function edit(remove: boolean) {
  if (busy || mode === 'lobby' || !$('overlay').hidden) return;
  spawnFresh = false;
  if (pending) { toast('Your last save needs a retry. Open World menu.'); return; }
  if (scene.world.locked) { toast('The owner has paused building.'); return; }
  const {x,z} = scene.selected, y = top(scene.world,x,z)+(remove?0:1);
  if (y < 2) { toast('Keep the island’s foundation.'); return; }
  if (y >= 12) { toast('You reached the sky limit — 12 blocks high.'); return; }
  const expected = scene.world.blocks[cell(x,y,z)], block = remove?0:material;
  if (mode === 'practice') {
    scene.world.blocks[cell(x,y,z)] = block; scene.world.edits++;
  } else if (!editor?.enqueue({x,y,z,expected,block})) return;
  scene.burst(x,y,z,remove?expected:material);
}
async function sendPresence() {
  if (!session || sending || !connectedPeers.size) return;
  sending = true;
  const generation = epoch;
  try {
    await webrtc.send(session, {
      v: 1,
      seq: ++seq,
      x: scene.player.x,
      z: scene.player.z,
      color: scene.player.color,
    });
  } catch {
    if (generation === epoch) liveState = 'Live connection interrupted';
  } finally {
    sending = false;
  }
}
let rtcSub: { close: () => void } | undefined;
try {
  rtcSub = webrtc.onEvent((event) => {
    if (event.sessionId !== session) return;
    if (event.type === 'message') {
      const p = event.payload as Record<string, unknown>;
      if (p?.v === 1 && p.kind === 'saved') {
        if (connectedPeers.has(event.from) && p.instance === target?.instance && p.release === target?.release)
          editor?.sync(true);
        return;
      }
      if (
        !p ||
        !connectedPeers.has(event.from) ||
        p.v !== 1 ||
        !Number.isSafeInteger(p.seq) ||
        typeof p.x !== 'number' ||
        typeof p.z !== 'number' ||
        !Number.isFinite(p.x) ||
        !Number.isFinite(p.z) ||
        p.x < 0 ||
        p.x >= 18 ||
        p.z < 0 ||
        p.z >= 18 ||
        !Number.isInteger(p.color) ||
        Number(p.color) < 0 ||
        Number(p.color) > 5
      )
        return;
      const old = scene.peers.get(event.from);
      if (old && Number(p.seq) <= old.seq) return;
      if (!old && scene.peers.size >= 8) return;
      scene.peers.set(event.from, {
        x: old?.x ?? p.x,
        z: old?.z ?? p.z,
        tx: p.x,
        tz: p.z,
        color: Number(p.color),
        name: `FRIEND ${event.from.slice(-4).toUpperCase()}`,
        seen: performance.now(),
        seq: Number(p.seq),
      });
      // Two transports can hash to the same starting spot. Only the higher key
      // yields, and only before movement/edit input, so friends never jump mid-play.
      if (spawnFresh && playerActor > event.from &&
          Math.hypot(scene.player.x-p.x,scene.player.z-p.z)<1.2) {
        for (let i=0;i<20;i++) {
          const x=6.5+(i%5)*1.3, z=8.5+Math.floor(i/5)*1.3;
          if ([...scene.peers.values()].every(peer=>Math.hypot(x-peer.tx,z-peer.tz)>=1.2)) {
            scene.player.x=x; scene.player.z=z; break;
          }
        }
      }
    } else if (event.type === 'peer') {
      if (event.state === 'joined') {
        connectedPeers.add(event.pubkey);
        scene.peers.delete(event.pubkey);
        liveState = 'Live';
        void sendPresence();
        editor?.sync(true);
      } else if (event.state === 'left') {
        connectedPeers.delete(event.pubkey);
        scene.peers.delete(event.pubkey);
        if (!connectedPeers.size) liveState = 'Waiting for friends';
      }
    } else if (event.type === 'closed') {
      session = '';
      liveState = 'Live disconnected';
      $('worldMode').textContent = 'Shared · Live players disconnected';
      scene.peers.clear();
      connectedPeers.clear();
      status('Live players disconnected · Rejoin from World menu', true);
    }
  });
} catch {
  /* The conformance reference shell may expose no WebRTC methods. User actions explain errors. */
}
function shareDialog() {
  if (!target) return;
  showPanel(
    `${closer()}<span class="stamp">SAVE THIS LITTLE INVITATION</span><h2>Room for your friends.</h2><section id="listingControl" class="listing-control"></section><label for="shareCode">Or share your persistent world code</label><input id="shareCode" class="code" readonly value="${escape(codeFor(target))}"/><button id="selectCode" class="primary wide">Select code to copy</button><p class="footnote">Copy with your device’s Copy command. Keep the code to return later. Anyone with it can edit. Worlds are public.</p><button id="lock" class="linkish">${scene.world.locked ? 'Resume' : 'Pause'} building (owner only)</button>`,
  );
  mountListingControl($('listingControl'),codeFor(target),scene.world.name);
  $('selectCode').onclick = () => {
    $<HTMLInputElement>('shareCode').focus();
    $<HTMLInputElement>('shareCode').select();
  };
  $('lock').onclick = async () => {
    if (busy || unfinished() || !target) { toast('Let pending blocks save before changing building permissions.'); return; }
    setBusy(true);
    pending = api.intent(target, 'setLocked', { locked: !scene.world.locked });
    await saveLocal();
    try {
      await finishPending();
      closePanel();
    } catch (e) {
      if (e instanceof BackendCallError && e.definitive) {
        pending = undefined;
        await saveLocal();
      }
      showError(e);
    } finally {
      setBusy(false);
    }
  };
}
$('help').onclick = () =>
  showPanel(
    `${closer()}<span class="stamp">MAKE YOURSELF AT HOME</span><h2>A few little tricks.</h2><div class="help-grid"><b>Wander</b><span>WASD / arrow keys, or the on-screen arrows. Friends have colored gardeners and name tags.</span><b>Desktop</b><span>Hover to aim. Click any visible block face to add on top of its column. Right-click erases the top block. Shift-click aims without building.</span><b>Touch</b><span>Tap a block to aim, then use Build or Erase. Drag to pan without building.</span><b>Keys</b><span>Space builds, X erases. Pick a material with 1–6 or the swatches.</span><b>Look around</b><span>Drag to pan. Use + / − to zoom.</span><b>Come back</b><span>Shared blocks save online. Keep your world code. Practice is temporary.</span></div><button id="helpBack" class="primary wide" style="margin-top:20px">Got it</button>`,
  );
$('help').addEventListener('click', () => {
  $('helpBack').onclick = () => (mode === 'lobby' ? lobby() : closePanel());
});
$('panel').addEventListener('keydown', (e) => {
  if (e.key !== 'Tab') return;
  const focusable = [
    ...$('panel').querySelectorAll<HTMLElement>('button:not(:disabled),input'),
  ];
  const first = focusable[0],
    last = focusable.at(-1);
  if (e.shiftKey && document.activeElement === first) {
    e.preventDefault();
    last?.focus();
  } else if (!e.shiftKey && document.activeElement === last) {
    e.preventDefault();
    first?.focus();
  }
});
$('worldMenu').onclick = lobby;
$('share').onclick = shareDialog;
for (const [id, remove] of [['place',false],['remove',true]] as const) {
  const button = $(id);
  let touchAt = -Infinity;
  button.onpointerdown = (event) => {
    if (event.pointerType !== 'touch' && event.pointerType !== 'pen') return;
    event.preventDefault(); touchAt = performance.now(); edit(remove);
  };
  button.onclick = (event) => {
    if (event.detail && performance.now()-touchAt < 800) return;
    edit(remove);
  };
}
$('zoomIn').onclick = () => (scene.zoom = Math.min(2.2, scene.zoom + 0.15));
$('zoomOut').onclick = () => (scene.zoom = Math.max(0.65, scene.zoom - 0.15));
const tray = document.querySelector('.materials')!;
for (let i = 1; i <= 6; i++) {
  const b = document.createElement('button');
  b.className = 'material';
  b.setAttribute('aria-label', MATERIALS[i].name);
  b.innerHTML = `<kbd>${i}</kbd><span class="swatch" style="background:${MATERIALS[i].color}"></span><small>${MATERIALS[i].name}</small>`;
  b.onclick = () => select(i);
  tray.append(b);
}
function select(i: number) {
  material = i;
  tray
    .querySelectorAll('button')
    .forEach((b, k) => b.setAttribute('aria-pressed', String(k + 1 === i)));
}
select(4);
for (const b of document.querySelectorAll<HTMLButtonElement>('[data-key]')) {
  const key = b.dataset.key!;
  b.onpointerdown = (e) => {
    e.preventDefault();
    b.setPointerCapture(e.pointerId);
    scene.keys.add(key);
    spawnFresh = false;
  };
  for (const event of ['pointerup', 'pointercancel', 'lostpointercapture'])
    b.addEventListener(event, () => scene.keys.delete(key));
}
document.addEventListener('keydown', (e) => {
  if (!$('overlay').hidden) {
    if (e.key === 'Escape') closePanel();
    return;
  }
  if (/^[1-6]$/.test(e.key)) select(Number(e.key));
  if (
    [
      'KeyW',
      'KeyA',
      'KeyS',
      'KeyD',
      'ArrowUp',
      'ArrowDown',
      'ArrowLeft',
      'ArrowRight',
    ].includes(e.code)
  ) {
    scene.keys.add(e.code);
    spawnFresh = false;
    e.preventDefault();
  }
  if (e.code === 'Space') {
    e.preventDefault();
    if (!e.repeat) void edit(false);
  }
  if (e.code === 'KeyX' && !e.repeat) void edit(true);
  if (e.key === 'Escape') lobby();
});
document.addEventListener('keyup', (e) => scene.keys.delete(e.code));
window.addEventListener('blur', () => scene.keys.clear());
window.addEventListener('online', () => { if (editor?.uncertain) editor.retry(); else editor?.sync(true); });
document.addEventListener('visibilitychange', () => {
  scene.keys.clear();
  if (!document.hidden) { if (editor?.uncertain) editor.retry(); else editor?.sync(true); }
});
let drag:
  | { id: number; x: number; y: number; px: number; py: number; moved: boolean; button: number; pointerType: string }
  | undefined;
scene.canvas.onpointerdown = (e) => {
  if (drag || !scene.active || !$('overlay').hidden) return;
  if (![0,1,2].includes(e.button)) return;
  e.preventDefault();
  scene.canvas.focus();
  scene.canvas.setPointerCapture(e.pointerId);
  drag = {
    id: e.pointerId,
    x: e.offsetX,
    y: e.offsetY,
    px: scene.pan.x,
    py: scene.pan.y,
    moved: false,
    button: e.button,
    pointerType: e.pointerType,
  };
};
scene.canvas.onpointermove = (e) => {
  if (!drag) {
    if (e.pointerType === 'mouse' && scene.active && $('overlay').hidden)
      scene.canvas.style.cursor = scene.pick(e.offsetX,e.offsetY) ? 'crosshair' : 'grab';
    return;
  }
  if (drag.id !== e.pointerId) return;
  const dx = e.offsetX - drag.x,
    dy = e.offsetY - drag.y;
  if (Math.hypot(dx, dy) > 8) drag.moved = true;
  if (drag.moved) {
    scene.canvas.style.cursor = 'grabbing';
    scene.pan.x = Math.max(-600, Math.min(600, drag.px + dx));
    scene.pan.y = Math.max(-400, Math.min(400, drag.py + dy));
  }
};
scene.canvas.onpointerup = (e) => {
  if (!drag || drag.id !== e.pointerId) return;
  const gesture = drag;
  drag = undefined;
  if (!gesture.moved && Math.hypot(e.offsetX-gesture.x,e.offsetY-gesture.y)<=8 &&
      gesture.button !== 1 && scene.pick(e.offsetX, e.offsetY) &&
      gesture.pointerType === 'mouse' && !e.shiftKey) edit(gesture.button === 2);
  scene.canvas.style.cursor = 'grab';
};
scene.canvas.onpointercancel = () => (drag = undefined);
scene.canvas.onlostpointercapture = () => (drag = undefined);
scene.canvas.oncontextmenu = (e) => e.preventDefault();
window.setInterval(() => {
  $('presence').textContent =
    mode === 'shared'
      ? scene.peers.size
        ? `${scene.peers.size + 1} building together`
        : liveState
      : 'CREATIVE CLUB';
  if (matchMedia('(pointer: coarse)').matches)
    $('hint').textContent = 'Tap a block to aim · Pick a color · Build';
  else if (innerWidth < 600)
    $('hint').textContent = 'Click to build · Right-click to erase';
  else
    $('hint').textContent =
      'Click to build · Right-click to erase · WASD to wander · Drag to pan';
}, 500);
async function bootstrap() {
  try {
    if (runtimeHasDomain('identity')) {
      identityKey = await identity.getPublicKey();
      identitySub = identity.onChanged(() => {
        void (async () => {
          const key = await identity.getPublicKey();
          if (key === identityKey) return;
          identityKey = key;
          await stopSession();
          mode = 'lobby';
          scene.active = false;
          target = undefined;
          $('playHud').hidden = true;
          $('share').hidden = true;
          await loadLocal();
          lobby();
        })();
      });
    }
  } catch {}
  await loadLocal();
  lobby();
  $('app').dataset.ready = 'true';
}
void bootstrap();
window.addEventListener('pagehide', () => {
  void stopSession();
  rtcSub?.close();
  identitySub?.close();
  scene.dispose();
});