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 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]] : []),
],
};
}
