Back to Powder Tool V600Billion
SOURCE / PINNED RELEASE

Made of little things.

Powder Tool V600Billion

Release
142767edcab8…
Author-recorded commit
6d92971effd0…
License
LICENSE
Author’s source reference
nostr://npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/wss%3A%2F%2Fgit.napplet.soy%2F/powder-toy

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

source/src/main.js
/**
 * Boot: wait for the host, set up what the sandbox took away, start the game.
 *
 * Order matters. Everything the game reads at startup (the session element,
 * the filesystem hook, the Module hooks for its requests, WebSockets and
 * links) is in place before `create_powder` runs. The game is upstream's,
 * built without threads (patches/), so it needs neither SharedArrayBuffer nor
 * workers. The page takes over no browser global: the glue calls these hooks
 * instead (GLUE_PATCHES in scripts/upstream.mjs). Once the game is
 * presentable, the page's own controls (src/ui/) start; they drive the game
 * through its Lua (src/game.js).
 */
import './style.css';
import * as host from './napplet.js';
import { createNappletFS } from './storage-fs.js';
import { toast } from './toast.js';
import { connectGame } from './game.js';
import { frameGame } from './ui/frame.js';
import { drawIcons, isolateKeys, startUi } from './ui/app.js';
import { base64ToBytes } from './base64.js';
import { currentSession, writeSessionInfo } from './session.js';
import { createStore } from './server/store.js';
import { createHandler } from './server/index.js';
import { thumbnailPng } from './save/thumbnail.js';

const MOTD = 'Published saves go to Nostr under your name; unpublished ones stay in this browser.';
const MOTD_OWN_FORMAT = 'This host cannot publish the game\'s saves to Nostr: your saves stay in this browser. Saves shared from Nappelin show up here.';

const status = document.getElementById('status');
const canvas = document.getElementById('canvas');

function say(text) {
  status.textContent = text;
  status.hidden = false;
}

/** The module sits in the page gzip-compressed and base64-encoded; unpacking it fetches nothing. */
async function decodeWasm() {
  const element = document.getElementById('powder-wasm');
  if (!element) throw new Error('The game data is missing from this page.');
  const packed = base64ToBytes(element.textContent.trim());
  if (element.dataset.encoding !== 'gzip+base64') return packed;
  if (typeof DecompressionStream !== 'function') throw new Error('This browser is too old for the game (no DecompressionStream).');
  const stream = new Blob([packed]).stream().pipeThrough(new DecompressionStream('gzip'));
  return new Uint8Array(await new Response(stream).arrayBuffer());
}

async function boot() {
  if (typeof window.create_powder !== 'function') {
    say('The game code is missing from this page.');
    return;
  }

  const environment = await host.awaitShell();
  // A host that signs only its own record format would refuse every save, vote and comment; say so up front.
  const ownFormatOnly = host.publishesOwnFormatOnly(environment);
  // Whether the game can share here, on the page for anyone looking (tests, host previews).
  document.documentElement.dataset.nappletPublish = ownFormatOnly ? 'host-own-format-only' : host.canPublish() ? 'on' : 'off';

  // The game's links (wiki, forum, the credits) go through the host, which asks first.
  // Save and profile pages on powdertoy.co.uk would show nothing: those saves live on Nostr.
  const nappletOpen = (url) => {
    const text = String(url);
    const save = /^https:\/\/powdertoy\.co\.uk\/Browse\/View\.html\?ID=(\d+)/.exec(text);
    if (save) toast(`This save lives on Nostr, not on powdertoy.co.uk. Others find it by typing ${save[1]} into the game's search.`, { timeoutMs: 10000 });
    else if (/^https:\/\/powdertoy\.co\.uk\/User\.html/.test(text)) toast('Profiles live in Nappelin, not on powdertoy.co.uk.');
    else if (!host.openLink(text)) toast(`This link cannot be opened here: ${text}`);
  };

  const storage = host.storage();
  // Hosts keep different amounts (napplet.soy: 1 MiB in all); say once when one runs out.
  let refusedBefore = false;
  const onTrouble = (path) => {
    if (refusedBefore) return;
    refusedBefore = true;
    toast(`This host did not keep ${path.replace(/^\/powder\//, '')}, its storage may be full. It stays until you close the game.`, { timeoutMs: 12000 });
  };
  const nappletFS = storage ? (FS, MEMFS) => createNappletFS({ FS, MEMFS, store: storage, onTrouble }) : undefined;
  if (!storage) toast('Saves last until you close the game: this host keeps no storage for it.', { timeoutMs: 8000 });

  // Signed in to Nappelin = signed in to the game; the online features answer from Nostr.
  const session = await currentSession();
  if (session) writeSessionInfo(session);
  const server = createStore({
    nostr: {
      collect: host.collect,
      publish: ownFormatOnly ? async () => { throw new Error(host.OWN_FORMAT_ONLY_MESSAGE); } : host.publish,
      watch: host.watch,
    },
    storage,
    session,
    thumbnail: async (bytes) => (await thumbnailPng(bytes)).png,
  });
  let closeHint = () => {};
  const nappletFetch = createHandler(server, { motd: ownFormatOnly ? MOTD_OWN_FORMAT : MOTD, login: currentSession, onLogin: () => closeHint() });
  host.onIdentityChanged((pubkey) => {
    const now = server.session?.pubkey ?? '';
    if (pubkey === now) return;
    if (!now) {
      closeHint = toast('Signed in to Nappelin. Press [sign in] at the bottom right of the game to share saves.', { timeoutMs: 20000 });
      return;
    }
    server.block(pubkey
      ? 'You switched accounts in Nappelin. Reload the game to continue as the new account.'
      : 'You signed out of Nappelin. Sign in again and reload the game to share saves.');
    toast(pubkey ? 'You switched accounts. Reload the game to continue as the new account.' : 'You signed out of Nappelin.', {
      action: { label: 'Reload', run: () => location.reload() },
      timeoutMs: 0,
    });
  });

  const frame = frameGame({ canvas, viewport: document.getElementById('viewport'), stage: document.getElementById('room') });
  let started;
  const presentable = new Promise((resolve) => { started = resolve; });
  // Upstream calls this once the first frame is ready (Platform::MarkPresentable).
  // The ready marker is what napplet.soy's browser check waits for (preview.readySelector).
  window.mark_presentable = () => {
    canvas.classList.add('ready');
    status.hidden = true;
    frame.apply();
    document.documentElement.dataset.nappletReady = 'true';
    started();
  };
  window.addEventListener('error', (event) => say(`Exception thrown, see the JavaScript console.\n${event.message}`));
  canvas.addEventListener('webglcontextlost', (event) => {
    say('WebGL context lost. Close and reopen the game.');
    event.preventDefault();
  });
  canvas.addEventListener('mousedown', () => canvas.focus());
  // The right mouse button draws with the right tool, as in the game.
  canvas.addEventListener('contextmenu', (event) => event.preventDefault());

  const module = await window.create_powder({
    canvas,
    wasmBinary: await decodeWasm(),
    nappletFS,
    nappletFetch,
    nappletOpen,
    nappletWebSocket: unreachableSocket,
    print: (text) => console.log(text),
    printErr: (text) => console.log(text),
  });
  // The page's own controls, once the game's Lua state and view exist.
  await presentable;
  window.powderUi = startUi({ game: connectGame(module), canvas, frame, openLink: (url) => host.openLink(url) });
}

/**
 * The game's WebSockets. A napplet has no network, so each one fails the way
 * an unreachable server does: an error, then a close, never an open.
 */
function unreachableSocket() {
  const socket = new EventTarget();
  Object.assign(socket, { readyState: 3, bufferedAmount: 0, binaryType: 'arraybuffer', send() {}, close() {} });
  setTimeout(() => {
    socket.dispatchEvent(new Event('error'));
    socket.dispatchEvent(new CloseEvent('close', { code: 1006, reason: 'no network in a napplet', wasClean: false }));
  }, 0);
  return socket;
}

drawIcons();
isolateKeys();
boot().catch((error) => say(`The game did not start: ${error?.message ?? error}`));