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