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/napplet.js
/**
 * The game's one door to the host: `window.napplet`, as the Kehto prelude
 * installs it in the Hangar and @napplet/shim installs it on napplet.soy.
 *
 * The contract (nappelin.com apps/hangar/src/grants.ts): the prelude exposes exactly the
 * domains the host serves this napplet, so the presence of a domain object is
 * the answer to "can I use it". Every call here degrades instead of throwing
 * when a domain is missing, so the same file also runs as a plain page: the
 * game plays, nothing is kept and nothing goes online.
 *
 * Domains this napplet uses:
 *   storage   saves, stamps and settings (src/storage-fs.js), favourites, private saves
 *   identity  who is playing; read-only, the host never hands out a key
 *   outbox    shared saves, votes and comments over Nostr (src/server/, formats in
 *             specs/saves.md): the napplet hands the host filters and unsigned
 *             events; the host signs, picks the relays and fans out (NAP-OUTBOX)
 *   link      the game's links (wiki, forum) open through the host, after it asks
 *
 * One host-specific read: napplet.soy puts a host-policy hint (`appData`) into
 * its shell handshake and asks napplets to check it before offering to publish
 * (its docs/SHARED-DATA.md). `awaitShell` reads it where a host offers
 * `window.napplet.shell`; everything else works the same without it.
 */

function namespace() {
  const value = typeof window === 'undefined' ? null : window.napplet;
  return value && typeof value === 'object' ? value : null;
}

export function has(domain) {
  return Boolean(namespace()?.[domain]);
}

/**
 * Wait for the host handshake, but never hold up the game for it: a host
 * that stays silent just means we run as a plain page. Returns what the host
 * said at the handshake (its shell.init), `{}` if it said nothing we can
 * read, or `null` without an answer.
 */
export async function awaitShell(timeoutMs = 1500) {
  const shell = namespace()?.shell;
  if (typeof shell?.ready !== 'function') return null;
  let timer;
  const answered = await Promise.race([
    Promise.resolve(shell.ready()).then((environment) => (environment && typeof environment === 'object' ? environment : {}), () => null),
    new Promise((resolve) => { timer = setTimeout(() => resolve(null), timeoutMs); }),
  ]);
  clearTimeout(timer);
  return answered;
}

/**
 * Whether the host signs only records in its own format. napplet.soy says so
 * at the handshake with an `appData` host-policy hint and asks napplets to
 * check it before offering publication (its docs/SHARED-DATA.md).
 */
export function publishesOwnFormatOnly(environment) {
  return Boolean(environment?.capabilities?.appData);
}

/** Napplet-scoped storage; `null` when the host does not keep anything for us. */
export function storage() {
  const store = namespace()?.storage;
  if (typeof store?.getItem !== 'function' || typeof store?.setItem !== 'function') return null;
  return {
    get: (key) => store.getItem(key),
    set: (key, value) => store.setItem(key, value),
    remove: (key) => store.removeItem(key),
    keys: () => store.keys(),
  };
}

/** A host call that must not hold up the game: the prelude's own timeout is 30 seconds. */
function within(promise, ms, fallback) {
  let timer;
  return Promise.race([
    Promise.resolve(promise).finally(() => clearTimeout(timer)),
    new Promise((resolve) => { timer = setTimeout(() => resolve(fallback), ms); }),
  ]);
}

/** The player's public key (hex), or '' when nobody is signed in. */
export async function publicKey() {
  const identity = namespace()?.identity;
  if (typeof identity?.getPublicKey !== 'function') return '';
  try {
    const pubkey = await within(identity.getPublicKey(), 3000, '');
    return typeof pubkey === 'string' && /^[0-9a-f]{64}$/.test(pubkey) ? pubkey : '';
  } catch {
    return '';
  }
}

/** The player's kind-0 profile as the host knows it, or null. */
export async function profile() {
  const identity = namespace()?.identity;
  if (typeof identity?.getProfile !== 'function') return null;
  try {
    const value = await within(identity.getProfile(), 2500, null);
    return value && typeof value === 'object' ? value : null;
  } catch {
    return null;
  }
}

/** Called with the new public key ('' for signed out) whenever the host switches accounts. */
export function onIdentityChanged(callback) {
  const identity = namespace()?.identity;
  if (typeof identity?.onChanged === 'function') {
    const handle = identity.onChanged((value) => callback(typeof value === 'string' ? value : (value?.pubkey ?? '')));
    return () => handle?.close?.();
  }
  const listener = (event) => {
    const message = event.data;
    if (event.source !== window.parent || !message || message.type !== 'identity.changed') return;
    callback(typeof message.pubkey === 'string' ? message.pubkey : '');
  };
  window.addEventListener('message', listener);
  return () => window.removeEventListener('message', listener);
}

export function canPublish() {
  return typeof namespace()?.outbox?.publish === 'function';
}

/**
 * Hosts that sign only records in their own format refuse the game's events.
 * napplet.soy's host answers outbox.publish only for its `soy.app-data/1`
 * records (its docs/SHARED-DATA.md) and says so with an `app-data-…` error.
 */
const OWN_FORMAT_ONLY = /^(?:app-data-[a-z-]+|not-signed-in):/;
export const OWN_FORMAT_ONLY_MESSAGE = 'This host publishes only its own kind of records, so the game cannot share saves, '
  + 'votes or comments over Nostr here (in Nappelin it can). Uncheck Publish to keep a save on this device.';

const refusal = (reason) => new Error(OWN_FORMAT_ONLY.test(reason) ? OWN_FORMAT_ONLY_MESSAGE : reason);

/** Hand an unsigned event to the host, which signs it with the player's key and sends it out. */
export async function publish(template) {
  const outbox = namespace()?.outbox;
  if (typeof outbox?.publish !== 'function') throw new Error('Sharing is not available here.');
  let result;
  try {
    result = await outbox.publish(template);
  } catch (error) {
    throw refusal(String(error?.message ?? error));
  }
  if (!result?.ok) throw refusal(String(result?.error ?? 'The host did not publish the event.'));
  const event = result.event;
  if (!event || typeof event !== 'object' || typeof event.id !== 'string') {
    throw new Error('The host did not return the signed event.');
  }
  return event;
}

const unwrap = (item) => {
  const event = item && typeof item === 'object' && item.event && typeof item.event === 'object' ? item.event : item;
  return event && typeof event === 'object' && typeof event.id === 'string' ? event : null;
};

/**
 * Read events once: the host asks its relays and answers when they are done,
 * or after `timeoutMs` with what arrived (NAP-OUTBOX query). Never throws;
 * nothing to read is an empty list.
 */
export function collect(filters, { timeoutMs = 4000 } = {}) {
  const outbox = namespace()?.outbox;
  if (typeof outbox?.query !== 'function') return Promise.resolve([]);
  const list = Array.isArray(filters) ? filters : [filters];
  return within(Promise.resolve(outbox.query(list, { timeoutMs })), timeoutMs + 2000, null)
    .then((reply) => {
      const items = Array.isArray(reply) ? reply : reply?.events;
      return Array.isArray(items) ? items.map(unwrap).filter(Boolean) : [];
    }, () => []);
}

/** Keep a subscription open for new events; returns a close function. */
export function watch(filters, onEvent) {
  const outbox = namespace()?.outbox;
  if (typeof outbox?.subscribe !== 'function') return () => {};
  const seen = new Set();
  let handle;
  try {
    handle = outbox.subscribe(Array.isArray(filters) ? filters : [filters]);
    handle?.on?.('event', (item) => {
      const event = unwrap(item);
      if (!event || seen.has(event.id)) return;
      seen.add(event.id);
      onEvent(event);
    });
  } catch {
    return () => {};
  }
  return () => { try { handle?.close?.(); } catch { /* already closed */ } };
}

/** Open a link through the host, which asks the player first. https only. */
export function openLink(url) {
  const link = namespace()?.link;
  if (typeof link?.open !== 'function') return false;
  let parsed;
  try { parsed = new URL(String(url)); } catch { return false; }
  if (parsed.protocol !== 'https:') return false;
  void Promise.resolve(link.open(parsed.href)).catch(() => {});
  return true;
}