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/test/storage-fs.test.mjs
/**
 * /powder on napplet storage, against an in-memory stand-in for Emscripten's
 * FS (only the calls storage-fs.js makes). What must hold: a round trip keeps
 * bytes and mtimes; an unchanged tree writes nothing; a deleted file leaves
 * storage; a populate that failed never deletes anything; oversized and
 * unreadable entries are skipped, not fatal; large files go into pieces a
 * strict host (napplet.soy) takes, and a file such a host refuses does not
 * stop the others from being kept.
 */
import assert from 'node:assert/strict';
import test from 'node:test';

import { CHUNK, CHUNK_PREFIX, createNappletFS, keyFor } from '../src/storage-fs.js';

const DIR = 0o040000, FILE = 0o100000;

function fakeFS() {
  let clock = 1_000_000;
  const nodes = new Map([['/', { dir: true, mtime: clock }]]);
  const parentOf = (path) => path.slice(0, path.lastIndexOf('/')) || '/';
  const touch = (path) => { const node = nodes.get(path); if (node) node.mtime = ++clock; };
  const need = (path) => { const node = nodes.get(path); if (!node) throw Object.assign(new Error(`ENOENT ${path}`), { code: 'ENOENT' }); return node; };
  return {
    nodes,
    tick: () => ++clock,
    readdir(path) {
      need(path);
      const prefix = path === '/' ? '/' : `${path}/`;
      return ['.', '..', ...[...nodes.keys()].filter((key) => key !== path && key.startsWith(prefix) && !key.slice(prefix.length).includes('/')).map((key) => key.slice(prefix.length))];
    },
    stat(path) {
      const node = need(path);
      return { mode: node.dir ? DIR | 0o777 : FILE | 0o666, mtime: new Date(node.mtime), size: node.dir ? 4096 : node.data.length };
    },
    isDir: (mode) => (mode & 0o170000) === DIR,
    isFile: (mode) => (mode & 0o170000) === FILE,
    mkdirTree(path) {
      let current = '';
      for (const part of path.split('/').filter(Boolean)) {
        current += `/${part}`;
        if (!nodes.has(current)) { nodes.set(current, { dir: true, mtime: ++clock }); touch(parentOf(current)); }
      }
    },
    writeFile(path, data) {
      need(parentOf(path));
      nodes.set(path, { dir: false, data: Uint8Array.from(typeof data === 'string' ? Buffer.from(data) : data), mtime: ++clock });
      touch(parentOf(path));
    },
    readFile(path) { return need(path).data; },
    chmod() {},
    utime(path, atime, mtime) { need(path).mtime = Math.max(atime, mtime); },
    unlink(path) { need(path); nodes.delete(path); touch(parentOf(path)); },
  };
}

function fakeStore({ failKeys = false } = {}) {
  const map = new Map();
  const calls = { set: 0, remove: 0 };
  return {
    map, calls,
    async get(key) { return map.has(key) ? map.get(key) : null; },
    async set(key, value) { calls.set++; map.set(key, value); },
    async remove(key) { calls.remove++; map.delete(key); },
    async keys() { if (failKeys) throw new Error('storage did not answer'); return [...map.keys()]; },
  };
}

/**
 * Storage with napplet.soy's limits (packages/runtime/src/storage.ts and host.ts
 * there): 256 keys, 1 MiB of keys and values in all, and a message over
 * 360 000 characters is dropped, which the napplet sees as a timeout.
 */
function strictStore({ quota = 1024 * 1024, maxKeys = 256 } = {}) {
  const store = fakeStore();
  const used = (skip) => [...store.map].filter(([key]) => key !== skip).reduce((n, [key, value]) => n + key.length + value.length, 0);
  const set = store.set;
  store.set = async (key, value) => {
    if (JSON.stringify({ type: 'storage.set', id: '00000000-0000-0000-0000-000000000000', key, value }).length > 360000) throw new Error('State request timed out');
    if ((!store.map.has(key) && store.map.size >= maxKeys) || used(key) + key.length + value.length > quota) throw new Error('Storage quota exceeded');
    return set(key, value);
  };
  return store;
}

const mountAt = '/powder';
const sync = (fsType, populate) => new Promise((resolve) => fsType.syncfs({ mountpoint: mountAt }, populate, resolve));

function setup(store, fs = fakeFS(), log = () => {}) {
  fs.mkdirTree(mountAt);
  return { fs, fsType: createNappletFS({ FS: fs, MEMFS: { mount: () => null }, store, log }) };
}

test('a round trip keeps directories, bytes and mtimes, and an unchanged tree writes nothing', async () => {
  const store = fakeStore();
  const first = setup(store);
  first.fs.mkdirTree('/powder/Saves');
  first.fs.writeFile('/powder/Saves/tower.cps', new Uint8Array([0x4f, 0x50, 0x53, 0x31, 0, 255]));
  first.fs.writeFile('/powder/powder.pref', '{"Scale":1}');
  assert.equal(await sync(first.fsType, true), null);
  assert.equal(await sync(first.fsType, false), null);
  assert.deepEqual([...store.map.keys()].sort(), ['fs:/powder/Saves', 'fs:/powder/Saves/tower.cps', 'fs:/powder/powder.pref']);
  const written = store.calls.set;
  assert.equal(await sync(first.fsType, false), null);
  assert.equal(store.calls.set, written, 'a second persist without changes wrote again');

  // A new session: empty memory, same storage.
  const second = setup(store);
  await sync(second.fsType, true);
  assert.deepEqual([...second.fs.readFile('/powder/Saves/tower.cps')], [0x4f, 0x50, 0x53, 0x31, 0, 255]);
  assert.equal(Buffer.from(second.fs.readFile('/powder/powder.pref')).toString(), '{"Scale":1}');
  assert.equal(second.fs.stat('/powder/Saves/tower.cps').mtime.getTime(), first.fs.stat('/powder/Saves/tower.cps').mtime.getTime());
  const before = store.calls.set;
  await sync(second.fsType, false);
  assert.equal(store.calls.set, before, 'restoring must not look like a change');
});

test('a changed file is written again and a deleted one leaves storage', async () => {
  const store = fakeStore();
  const { fs, fsType } = setup(store);
  await sync(fsType, true);
  fs.writeFile('/powder/a.txt', 'one');
  fs.writeFile('/powder/b.txt', 'two');
  await sync(fsType, false);
  fs.writeFile('/powder/a.txt', 'three');
  fs.unlink('/powder/b.txt');
  await sync(fsType, false);
  assert.equal(Buffer.from(JSON.parse(store.map.get('fs:/powder/a.txt')).data, 'base64').toString(), 'three');
  assert.equal(store.map.has('fs:/powder/b.txt'), false);
});

test('when populate fails the game still starts, and nothing is ever deleted', async () => {
  const store = fakeStore();
  store.map.set('fs:/powder/Saves/precious.cps', JSON.stringify({ path: '/powder/Saves/precious.cps', mode: FILE | 0o666, mtime: 5, data: 'AAEC' }));
  const failing = { ...store, keys: async () => { throw new Error('storage did not answer'); } };
  const logs = [];
  const { fs, fsType } = setup(failing, fakeFS(), (line) => logs.push(line));
  assert.equal(await sync(fsType, true), null, 'populate must report success so main() runs');
  fs.writeFile('/powder/new.txt', 'x');
  await sync(fsType, false);
  assert.ok(store.map.has('fs:/powder/Saves/precious.cps'), 'a save vanished after a failed load');
  assert.ok(store.map.has('fs:/powder/new.txt'), 'new files are still kept');
  assert.equal(store.calls.remove, 0);
  assert.ok(logs.some((line) => line.includes('storage did not answer')));
});

test('unreadable entries are skipped and entries outside /powder are ignored', async () => {
  const store = fakeStore();
  store.map.set('fs:/powder/broken', '{not json');
  store.map.set('fs:/elsewhere/file', JSON.stringify({ path: '/elsewhere/file', mtime: 1, data: '' }));
  store.map.set('fs:/powder/ok.txt', JSON.stringify({ path: '/powder/ok.txt', mode: FILE | 0o666, mtime: 7, data: Buffer.from('fine').toString('base64') }));
  store.map.set('favourites', '[]');
  const { fs, fsType } = setup(store);
  await sync(fsType, true);
  assert.equal(Buffer.from(fs.readFile('/powder/ok.txt')).toString(), 'fine');
  assert.equal(fs.nodes.has('/elsewhere/file'), false);
});

test('long paths get a hashed key and still round-trip', async () => {
  const store = fakeStore();
  const { fs, fsType } = setup(store);
  await sync(fsType, true);
  const name = `/powder/Saves/${'very long save name '.repeat(12).trim()}.cps`;
  fs.mkdirTree('/powder/Saves');
  fs.writeFile(name, 'long');
  await sync(fsType, false);
  const key = keyFor(name);
  assert.ok(key.length <= 200 && key.startsWith('fs:#'), key);
  assert.equal(JSON.parse(store.map.get(key)).path, name);
  const again = setup(store);
  await sync(again.fsType, true);
  assert.equal(Buffer.from(again.fs.readFile(name)).toString(), 'long');
});

test('a file too large to keep at all stays in memory and is reported', async () => {
  const store = fakeStore();
  const logs = [];
  const { fs, fsType } = setup(store, fakeFS(), (line) => logs.push(line));
  await sync(fsType, true);
  fs.writeFile('/powder/huge.bin', new Uint8Array(6.5 * 1024 * 1024));
  await sync(fsType, false);
  assert.equal(store.map.has('fs:/powder/huge.bin'), false);
  assert.ok(logs.some((line) => line.includes('too large')));
});

const bytes = (length, seed = 1) => Uint8Array.from({ length }, (_, i) => (i * 31 + seed * 7) & 255);

test('a large file goes into pieces a strict host takes, and comes back whole', async () => {
  const store = strictStore({ quota: 4 * 1024 * 1024 });
  const { fs, fsType } = setup(store);
  await sync(fsType, true);
  fs.mkdirTree('/powder/Saves');
  const data = bytes(500_000);
  fs.writeFile('/powder/Saves/big.cps', data);
  assert.equal(await sync(fsType, false), null);
  const head = JSON.parse(store.map.get('fs:/powder/Saves/big.cps'));
  assert.equal(head.chunks.length, 2, 'base64 of 500 kB is three pieces: the entry and two more');
  assert.equal(head.data.length, CHUNK);
  for (const value of store.map.values()) assert.ok(value.length <= CHUNK + 200, 'no value is larger than a piece');

  const again = setup(store);
  await sync(again.fsType, true);
  assert.deepEqual(again.fs.readFile('/powder/Saves/big.cps'), data);
});

test('rewriting a large file replaces its pieces, and a write that fails keeps the old version', async () => {
  const store = fakeStore();
  const { fs, fsType } = setup(store);
  await sync(fsType, true);
  fs.writeFile('/powder/big.bin', bytes(400_000, 1));
  await sync(fsType, false);
  const first = JSON.parse(store.map.get('fs:/powder/big.bin')).chunks;
  fs.writeFile('/powder/big.bin', bytes(400_000, 2));
  await sync(fsType, false);
  const second = JSON.parse(store.map.get('fs:/powder/big.bin')).chunks;
  assert.notDeepEqual(second, first);
  assert.deepEqual([...store.map.keys()].filter((key) => key.startsWith(CHUNK_PREFIX)).sort(), [...second].sort(), 'the old pieces are gone');

  // The host stops taking writes half way through the third version.
  let writes = 0;
  const set = store.set;
  store.set = async (key, value) => { if (++writes > 1) throw new Error('Storage quota exceeded'); return set(key, value); };
  fs.writeFile('/powder/big.bin', bytes(400_000, 3));
  await sync(fsType, false);
  store.set = set;
  const again = setup(store);
  await sync(again.fsType, true);
  assert.deepEqual(again.fs.readFile('/powder/big.bin'), bytes(400_000, 2), 'the second version is still whole');
  assert.deepEqual([...store.map.keys()].filter((key) => key.startsWith(CHUNK_PREFIX)).sort(), [...second].sort(), 'no pieces left over');
});

test('a file a full host refuses does not stop the others, is reported once, and waits until it changes', async () => {
  const store = strictStore({ quota: 300_000 });
  const trouble = [];
  const { fs, fsType } = setup(store);
  const fsType2 = createNappletFS({ FS: fs, MEMFS: { mount: () => null }, store, log: () => {}, onTrouble: (path, reason) => trouble.push([path, reason]) });
  await sync(fsType2, true);
  fs.mkdirTree('/powder/Saves');
  fs.writeFile('/powder/Saves/huge.cps', bytes(250_000));
  fs.writeFile('/powder/powder.pref', '{"Scale":2}');
  assert.equal(await sync(fsType2, false), null);
  assert.ok(store.map.has('fs:/powder/powder.pref'), 'the settings are kept');
  assert.equal(store.map.has('fs:/powder/Saves/huge.cps'), false);
  assert.deepEqual(trouble.map(([path]) => path), ['/powder/Saves/huge.cps']);
  assert.match(trouble[0][1], /quota/);

  const attempts = store.calls.set;
  fs.writeFile('/powder/powder.pref', '{"Scale":1}');
  await sync(fsType2, false);
  assert.equal(store.calls.set, attempts + 1, 'only the changed settings were written, the refused save was not tried again');
  assert.equal(trouble.length, 1, 'reported once');

  fs.writeFile('/powder/Saves/huge.cps', bytes(1000));
  await sync(fsType2, false);
  assert.ok(store.map.has('fs:/powder/Saves/huge.cps'), 'kept once it fits');
  assert.equal(fsType2.state.refused.size, 0);
});

test('pieces no entry names are removed at the next start, but only after a complete load', async () => {
  const store = fakeStore();
  store.map.set(`${CHUNK_PREFIX}old:1:/powder/gone.bin`, 'AAAA');
  store.map.set('fs:/powder/ok.txt', JSON.stringify({ path: '/powder/ok.txt', mode: FILE | 0o666, mtime: 7, data: 'b2s=' }));
  const failing = setup({ ...store, keys: async () => { throw new Error('storage did not answer'); } });
  await sync(failing.fsType, true);
  assert.ok(store.map.has(`${CHUNK_PREFIX}old:1:/powder/gone.bin`), 'kept while the load failed');
  const { fsType } = setup(store);
  await sync(fsType, true);
  assert.equal(store.map.has(`${CHUNK_PREFIX}old:1:/powder/gone.bin`), false);
  assert.ok(store.map.has('fs:/powder/ok.txt'));
});