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/server/events.js
/**
 * The Nostr side of the save server: which events carry what.
 * (Documented for other clients in specs/saves.md.)
 *
 *   save      kind 30078 (NIP-78), d = "powder-toy/save/<hash of the name>",
 *             t = "powder-toy", title, summary, published_at, x, size, alt;
 *             content = the .cps file, base64. Same author + same name
 *             replaces, as saving over your own save does in the game.
 *   vote      kind 7 (NIP-25), "+" or "-", a/e/p/k pointing at the save
 *   comment   kind 1111 (NIP-22), A/K/P root and a/e/k/p parent = the save
 *   deletion  kind 5 (NIP-09) for a save (a/e, t = "powder-toy") or for a
 *             vote being taken back (e, k = "7")
 */
export const SAVE_KIND = 30078;
export const SAVE_TAG = 'powder-toy';
export const D_PREFIX = 'powder-toy/save/';
/** Raw .cps bytes; base64 plus tags stays under the 64 KiB many relays allow per event. */
export const MAX_SHARED_BYTES = 45000;
const MAX_CONTENT = Math.ceil(MAX_SHARED_BYTES / 3) * 4;

export const tagValue = (event, name) => event.tags.find((tag) => tag[0] === name && typeof tag[1] === 'string')?.[1];
export const addressOf = (pubkey, d) => `${SAVE_KIND}:${pubkey}:${d}`;

function validEvent(event) {
  return event && typeof event === 'object' && typeof event.id === 'string' && /^[0-9a-f]{64}$/.test(event.pubkey ?? '')
    && Number.isInteger(event.kind) && Number.isInteger(event.created_at) && Array.isArray(event.tags)
    && typeof event.content === 'string';
}

/** A save event as the server keeps it, or null when it is not one of ours or is malformed. */
export function parseSaveEvent(event) {
  if (!validEvent(event) || event.kind !== SAVE_KIND) return null;
  const d = tagValue(event, 'd');
  if (!d || !d.startsWith(D_PREFIX) || d.length > 80) return null;
  if (!event.tags.some((tag) => tag[0] === 't' && tag[1] === SAVE_TAG)) return null;
  const name = (tagValue(event, 'title') ?? '').trim().slice(0, 100);
  if (!name) return null;
  if (!event.content || event.content.length > MAX_CONTENT || !/^[A-Za-z0-9+/]+={0,2}$/.test(event.content)) return null;
  const published = Number(tagValue(event, 'published_at'));
  const tags = [...new Set(event.tags
    .filter((tag) => tag[0] === 't' && typeof tag[1] === 'string' && tag[1] !== SAVE_TAG)
    .map((tag) => tag[1].toLowerCase())
    .filter((tag) => /^[a-z0-9]{1,16}$/.test(tag)))].slice(0, 20);
  return {
    address: addressOf(event.pubkey, d),
    d,
    event,
    pubkey: event.pubkey,
    name,
    description: (tagValue(event, 'summary') ?? '').slice(0, 2000),
    created: Number.isInteger(published) && published > 0 && published <= event.created_at ? published : event.created_at,
    updated: event.created_at,
    tags,
    published: true,
  };
}

/** A vote: +1, -1, or null for anything else. */
export function parseVote(event) {
  if (!validEvent(event) || event.kind !== 7) return null;
  const address = tagValue(event, 'a');
  if (!address || !address.startsWith(`${SAVE_KIND}:`)) return null;
  const value = event.content === '+' ? 1 : event.content === '-' ? -1 : 0;
  return value ? { id: event.id, pubkey: event.pubkey, address, value, at: event.created_at } : null;
}

export function parseComment(event) {
  if (!validEvent(event) || event.kind !== 1111) return null;
  const address = tagValue(event, 'A');
  const text = event.content.trim();
  if (!address || !text) return null;
  return { id: event.id, pubkey: event.pubkey, address, text: text.slice(0, 2000), at: event.created_at };
}

/** What a deletion takes back: the event ids and addresses it names, by its own author. */
export function parseDeletion(event) {
  if (!validEvent(event) || event.kind !== 5) return null;
  return {
    pubkey: event.pubkey,
    at: event.created_at,
    ids: event.tags.filter((tag) => tag[0] === 'e' && typeof tag[1] === 'string').map((tag) => tag[1]),
    addresses: event.tags.filter((tag) => tag[0] === 'a' && typeof tag[1] === 'string').map((tag) => tag[1]),
  };
}

export function saveTemplate({ d, name, description, data, size, tags, publishedAt, sha256, now }) {
  return {
    kind: SAVE_KIND,
    created_at: now,
    content: data,
    tags: [
      ['d', d],
      ['t', SAVE_TAG],
      ['title', name],
      ['summary', description],
      ['published_at', String(publishedAt)],
      ['x', sha256],
      ['size', String(size)],
      ['alt', `Powder Toy save: ${name}`],
      ...tags.map((tag) => ['t', tag]),
    ],
  };
}

export function voteTemplate(save, value, now) {
  return {
    kind: 7,
    created_at: now,
    content: value > 0 ? '+' : '-',
    tags: [['a', save.address], ['e', save.event.id], ['p', save.pubkey], ['k', String(SAVE_KIND)]],
  };
}

export function commentTemplate(save, text, now) {
  return {
    kind: 1111,
    created_at: now,
    content: text,
    tags: [
      ['A', save.address], ['K', String(SAVE_KIND)], ['P', save.pubkey],
      ['a', save.address], ['e', save.event.id], ['k', String(SAVE_KIND)], ['p', save.pubkey],
    ],
  };
}

export function deletionTemplate({ ids = [], addresses = [], kind, saveTag = false }, now) {
  return {
    kind: 5,
    created_at: now,
    content: '',
    tags: [
      ...ids.map((id) => ['e', id]),
      ...addresses.map((address) => ['a', address]),
      ['k', String(kind)],
      ...(saveTag ? [['t', SAVE_TAG]] : []),
    ],
  };
}