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.
/**
* 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;
}
