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/store.js
/**
 * The save server's memory: public saves, votes, comments and profiles as
 * they arrive from relays through the host, plus the player's private saves
 * and favourites in napplet storage.
 *
 * Everything that goes out is an unsigned template handed to the host, which
 * signs it with the player's key (`nostr.publish`). Everything that comes in
 * is checked by src/server/events.js before it counts. Reads use
 * `nostr.collect`, a one-shot query the host answers from its relays, and
 * `nostr.watch` for saves that arrive while the game runs (src/napplet.js).
 */
import { base64ToBytes, bytesToBase64 } from '../base64.js';
import {
  addressOf, commentTemplate, D_PREFIX, deletionTemplate, MAX_SHARED_BYTES, parseComment, parseDeletion,
  parseSaveEvent, parseVote, SAVE_KIND, SAVE_TAG, saveTemplate, voteTemplate,
} from './events.js';
import { stableId, usernameFor } from './names.js';

export class ServerError extends Error {
  constructor(message) {
    super(message);
    this.name = 'ServerError';
  }
}

const PRIVATE_PREFIX = 'priv:';
const FAVOURITES_KEY = 'favourites';
const LIST_TTL = 15;
const VOTES_TTL = 20;
const COMMENTS_TTL = 15;

const chunks = (items, size) => {
  const out = [];
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
  return out;
};

const encoder = new TextEncoder();

export async function sha256Hex(bytes) {
  const digest = await crypto.subtle.digest('SHA-256', bytes);
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}

export function parseQuery(text) {
  const query = { words: [], user: null, sortDate: false, after: null, ids: [] };
  for (const token of String(text ?? '').split(/\s+/).filter(Boolean)) {
    const lower = token.toLowerCase();
    // A save ID (shown after uploading, "click to copy") finds that save.
    const id = /^(?:id:)?(\d{1,10})$/.exec(lower);
    if (id) query.ids.push(Number(id[1]));
    else if (lower === 'sort:date') query.sortDate = true;
    else if (lower.startsWith('user:')) query.user = token.slice(5);
    else if (lower.startsWith('after:')) {
      const at = Date.parse(`${token.slice(6)}T00:00:00Z`);
      if (!Number.isNaN(at)) query.after = Math.floor(at / 1000);
    } else query.words.push(lower);
  }
  return query;
}

/**
 * @param {{
 *   nostr: { collect(filters, options?): Promise<object[]>, publish(template): Promise<object>, watch?(filters, onEvent): () => void },
 *   storage: { get, set, remove, keys } | null,
 *   session: { pubkey: string, username: string } | null,
 *   now?: () => number,
 *   thumbnail?: (bytes: Uint8Array) => Promise<Uint8Array>,
 * }} options
 */
export function createStore({ nostr, storage, session: initialSession = null, now = () => Math.floor(Date.now() / 1000), thumbnail }) {
  let session = initialSession;
  let sessionProblem = '';
  const saves = new Map(); // address -> newest public save
  const removedSaves = new Map(); // address -> time its author deleted it
  const privateSaves = new Map(); // d -> record (the player's own, never published)
  const ids = new Map(); // id -> { address } | { d } for private saves
  const votes = new Map(); // address -> Map<pubkey, vote>
  const voteEvents = new Map(); // vote id -> vote (kept to answer deletions)
  const revokedVotes = new Set();
  const comments = new Map(); // address -> { at, list }
  const profiles = new Map(); // pubkey -> profile | null
  const usernames = new Map(); // username -> pubkey
  const thumbnails = new Map(); // event id -> png
  let listedAt = 0;
  let votesAt = 0;
  let privateLoaded = false;
  let favourites = null;

  const adopt = (next) => {
    session = next;
    if (session) usernames.set(session.username, session.pubkey);
  };
  adopt(session);

  const nameOf = (pubkey) => {
    if (session && pubkey === session.pubkey) return session.username;
    const name = usernameFor(pubkey, profiles.get(pubkey));
    usernames.set(name, pubkey);
    return name;
  };

  function keepSave(save) {
    const known = saves.get(save.address);
    const removedAt = removedSaves.get(save.address);
    if (removedAt !== undefined && removedAt >= save.updated) return;
    if (known && (known.updated > save.updated || (known.updated === save.updated && known.event.id >= save.event.id))) return;
    saves.set(save.address, save);
    ids.set(stableId(save.address), { address: save.address });
  }

  function keepDeletion(deletion) {
    for (const address of deletion.addresses) {
      if (!address.startsWith(`${SAVE_KIND}:${deletion.pubkey}:`)) continue; // only authors delete their own
      const before = removedSaves.get(address) ?? 0;
      removedSaves.set(address, Math.max(before, deletion.at));
      const save = saves.get(address);
      if (save && save.updated <= deletion.at) saves.delete(address);
    }
    for (const id of deletion.ids) {
      const vote = voteEvents.get(id);
      if (vote && vote.pubkey === deletion.pubkey) revokeVote(vote);
      else if (!vote) revokedVotes.add(`${deletion.pubkey}:${id}`);
    }
  }

  function revokeVote(vote) {
    revokedVotes.add(`${vote.pubkey}:${vote.id}`);
    const byVoter = votes.get(vote.address);
    if (byVoter?.get(vote.pubkey)?.id === vote.id) byVoter.delete(vote.pubkey);
  }

  function keepVote(vote) {
    voteEvents.set(vote.id, vote);
    if (revokedVotes.has(`${vote.pubkey}:${vote.id}`)) return;
    const byVoter = votes.get(vote.address) ?? new Map();
    const current = byVoter.get(vote.pubkey);
    if (!current || current.at < vote.at || (current.at === vote.at && current.id < vote.id)) byVoter.set(vote.pubkey, vote);
    votes.set(vote.address, byVoter);
  }

  async function loadPrivate() {
    if (privateLoaded || !storage || !session) { privateLoaded = true; return; }
    privateLoaded = true;
    for (const key of await storage.keys()) {
      if (!key.startsWith(PRIVATE_PREFIX)) continue;
      try {
        const record = JSON.parse(await storage.get(key));
        if (record?.owner !== session.pubkey || typeof record.d !== 'string') continue;
        privateSaves.set(record.d, record);
        ids.set(stableId(`local:${record.owner}:${record.d}`), { d: record.d });
      } catch { /* an unreadable record is skipped */ }
    }
  }

  async function loadFavourites() {
    if (favourites) return favourites;
    favourites = new Set();
    if (!storage) return favourites;
    try {
      const list = JSON.parse(await storage.get(FAVOURITES_KEY) ?? '[]');
      if (Array.isArray(list)) for (const id of list) if (Number.isInteger(id)) favourites.add(id);
    } catch { /* start over */ }
    return favourites;
  }

  let watching = false;
  /** New saves and deletions arrive while the game runs, so a friend's save shows up at once. */
  function startWatching() {
    if (watching || typeof nostr.watch !== 'function') return;
    watching = true;
    nostr.watch([{ kinds: [SAVE_KIND], '#t': [SAVE_TAG] }, { kinds: [5], '#t': [SAVE_TAG] }], (event) => {
      if (event?.kind === 5) { const deletion = parseDeletion(event); if (deletion) keepDeletion(deletion); return; }
      const save = parseSaveEvent(event);
      if (save) { keepSave(save); void loadProfiles([save.pubkey]); }
    });
  }

  async function refresh(force = false) {
    const due = force || now() - listedAt >= LIST_TTL;
    await loadPrivate();
    startWatching();
    if (!due) return;
    const [found, deletions] = await Promise.all([
      nostr.collect([{ kinds: [SAVE_KIND], '#t': [SAVE_TAG], limit: 500 }]),
      nostr.collect([{ kinds: [5], '#t': [SAVE_TAG], limit: 500 }]),
    ]);
    for (const event of deletions) { const deletion = parseDeletion(event); if (deletion) keepDeletion(deletion); }
    for (const event of found) { const save = parseSaveEvent(event); if (save) keepSave(save); }
    listedAt = now();
    await Promise.all([loadProfiles([...new Set([...saves.values()].map((save) => save.pubkey))]), loadVotes(force)]);
  }

  async function loadProfiles(pubkeys) {
    const missing = pubkeys.filter((pubkey) => !profiles.has(pubkey) && !(session && pubkey === session.pubkey));
    if (!missing.length) return;
    for (const pubkey of missing) profiles.set(pubkey, null);
    const events = (await Promise.all(chunks(missing, 100).map((authors) => nostr.collect([{ kinds: [0], authors }], { minMs: 600 })))).flat();
    const newest = new Map();
    for (const event of events) {
      if (event?.kind !== 0 || typeof event.content !== 'string') continue;
      if ((newest.get(event.pubkey)?.created_at ?? -1) >= event.created_at) continue;
      newest.set(event.pubkey, event);
    }
    for (const [pubkey, event] of newest) {
      try { profiles.set(pubkey, JSON.parse(event.content)); } catch { /* keep null */ }
    }
    for (const pubkey of pubkeys) nameOf(pubkey);
  }

  async function loadVotes(force = false) {
    if (!force && now() - votesAt < VOTES_TTL) return;
    const addresses = [...saves.keys()];
    if (!addresses.length) { votesAt = now(); return; }
    const found = (await Promise.all(chunks(addresses, 100).map((list) => nostr.collect([{ kinds: [7], '#a': list }], { minMs: 600 })))).flat();
    const parsed = found.map(parseVote).filter(Boolean);
    const voteIds = parsed.map((vote) => vote.id);
    const deletions = voteIds.length
      ? (await Promise.all(chunks(voteIds, 100).map((list) => nostr.collect([{ kinds: [5], '#e': list }], { minMs: 600 })))).flat()
      : [];
    for (const vote of parsed) keepVote(vote);
    for (const event of deletions) { const deletion = parseDeletion(event); if (deletion) keepDeletion(deletion); }
    votesAt = now();
  }

  function scoreOf(address) {
    let up = 0, down = 0;
    for (const vote of votes.get(address)?.values() ?? []) {
      if (vote.value > 0) up++; else down++;
    }
    return { up, down };
  }

  function publicInfo(save) {
    const { up, down } = scoreOf(save.address);
    return {
      id: stableId(save.address), address: save.address, pubkey: save.pubkey, username: nameOf(save.pubkey),
      name: save.name, description: save.description, created: save.created, updated: save.updated,
      up, down, tags: save.tags, published: true, save,
    };
  }

  function privateInfo(record) {
    return {
      id: stableId(`local:${record.owner}:${record.d}`), address: null, pubkey: record.owner, username: nameOf(record.owner),
      name: record.name, description: record.description, created: record.created, updated: record.updated,
      up: 0, down: 0, tags: record.tags ?? [], published: false, record,
    };
  }

  function find(id) {
    const entry = ids.get(Number(id));
    if (entry?.address) {
      const save = saves.get(entry.address);
      return save ? publicInfo(save) : null;
    }
    if (entry?.d) {
      const record = privateSaves.get(entry.d);
      return record ? privateInfo(record) : null;
    }
    return null;
  }

  async function lookup(id) {
    let info = find(id);
    if (!info) {
      await refresh(true);
      info = find(id);
    }
    if (!info) throw new ServerError('This save is not on the relays (any more).');
    return info;
  }

  function requireSession() {
    if (sessionProblem) throw new ServerError(sessionProblem);
    if (!session) throw new ServerError('Sign in to Nappelin first.');
    return session;
  }

  function requireOwner(info) {
    const me = requireSession();
    if (info.pubkey !== me.pubkey) throw new ServerError('Only the author can do that.');
    return me;
  }

  async function dTagFor(name) {
    return D_PREFIX + (await sha256Hex(encoder.encode(name.trim().toLowerCase()))).slice(0, 16);
  }

  async function publishSave({ d, name, description, bytes, tags, publishedAt }) {
    if (bytes.length > MAX_SHARED_BYTES) {
      throw new ServerError(`This save is ${Math.ceil(bytes.length / 1024)} KB. Saves shared over Nostr can be at most `
        + `${Math.floor(MAX_SHARED_BYTES / 1024)} KB. Save it on your device instead, or uncheck Publish.`);
    }
    const template = saveTemplate({
      d, name, description, data: bytesToBase64(bytes), size: bytes.length, tags, publishedAt,
      sha256: await sha256Hex(bytes), now: now(),
    });
    const signed = await nostr.publish(template);
    const save = parseSaveEvent(signed);
    if (!save) throw new ServerError('The host returned a save the game cannot read back.');
    removedSaves.delete(save.address);
    keepSave(save);
    return save;
  }

  async function storePrivate(record) {
    if (!storage) throw new ServerError('This host keeps no storage, so unpublished saves cannot be kept.');
    await storage.set(PRIVATE_PREFIX + record.d, JSON.stringify(record));
    privateSaves.set(record.d, record);
    ids.set(stableId(`local:${record.owner}:${record.d}`), { d: record.d });
  }

  async function dropPrivate(d) {
    privateSaves.delete(d);
    if (storage) await storage.remove(PRIVATE_PREFIX + d);
  }

  return {
    get session() { return session; },

    /** Sign-in from the game's login window, after the player signed in to Nappelin. */
    setSession(next) {
      sessionProblem = '';
      privateLoaded = false;
      adopt(next);
    },

    /** The account changed under a running game: writes stop until the game is reloaded. */
    block(reason) {
      sessionProblem = reason;
    },

    async list({ query = '', category = '', start = 0, count = 20 } = {}) {
      await refresh();
      const parsed = parseQuery(query);
      let items = [...saves.values()].map(publicInfo);
      if (session && (parsed.user === session.username || category === 'MyOwn')) {
        items = items.concat([...privateSaves.values()].map(privateInfo));
      }
      if (category === 'Favourites') {
        const favs = await loadFavourites();
        items = items.filter((info) => favs.has(info.id));
      }
      if (parsed.user !== null) {
        const pubkey = usernames.get(parsed.user);
        items = pubkey ? items.filter((info) => info.pubkey === pubkey) : [];
      }
      if (parsed.ids.length) items = items.filter((info) => parsed.ids.includes(info.id));
      if (parsed.after !== null) items = items.filter((info) => info.created >= parsed.after);
      for (const word of parsed.words) {
        items = items.filter((info) => info.name.toLowerCase().includes(word) || info.description.toLowerCase().includes(word)
          || info.username.toLowerCase().includes(word) || info.tags.includes(word));
      }
      items.sort(parsed.sortDate
        ? (a, b) => b.updated - a.updated
        : (a, b) => (b.up - b.down) - (a.up - a.down) || b.updated - a.updated);
      return { count: items.length, items: items.slice(start, start + count) };
    },

    async info(id) {
      const info = await lookup(id);
      const favs = await loadFavourites();
      let mine = 0;
      if (session && info.address) mine = votes.get(info.address)?.get(session.pubkey)?.value ?? 0;
      let commentCount = 0;
      if (info.address) commentCount = (await this.comments(id, 0, 1000)).length;
      return { ...info, mine, favourite: favs.has(info.id), comments: commentCount };
    },

    async data(id) {
      const info = await lookup(id);
      return base64ToBytes(info.published ? info.save.event.content : info.record.data);
    },

    async thumbnail(id) {
      const info = await lookup(id);
      const key = info.published ? info.save.event.id : `local:${info.record.d}:${info.record.updated}`;
      if (!thumbnails.has(key)) {
        if (!thumbnail) throw new ServerError('No preview');
        if (thumbnails.size > 200) thumbnails.delete(thumbnails.keys().next().value);
        thumbnails.set(key, thumbnail(await this.data(id)));
      }
      return thumbnails.get(key);
    },

    async upload({ name, description = '', bytes, publish }) {
      const me = requireSession();
      const clean = String(name ?? '').trim().slice(0, 100);
      if (!clean) throw new ServerError('Give the save a name.');
      await refresh();
      const d = await dTagFor(clean);
      const address = addressOf(me.pubkey, d);
      if (publish) {
        const before = saves.get(address);
        const save = await publishSave({
          d, name: clean, description: String(description).slice(0, 2000), bytes,
          tags: before?.tags ?? privateSaves.get(d)?.tags ?? [], publishedAt: before?.created ?? now(),
        });
        return stableId(save.address);
      }
      const at = now();
      const earlier = privateSaves.get(d);
      await storePrivate({
        owner: me.pubkey, d, name: clean, description: String(description).slice(0, 2000),
        data: bytesToBase64(bytes), created: earlier?.created ?? at, updated: at, tags: earlier?.tags ?? [],
      });
      return stableId(`local:${me.pubkey}:${d}`);
    },

    async vote(id, direction) {
      const me = requireSession();
      const info = await lookup(id);
      if (!info.published) throw new ServerError('Unpublished saves cannot be voted on.');
      if (info.pubkey === me.pubkey) throw new ServerError('You cannot vote on your own save.');
      const mineNow = [...voteEvents.values()].filter((vote) => vote.pubkey === me.pubkey && vote.address === info.address
        && !revokedVotes.has(`${vote.pubkey}:${vote.id}`));
      if (mineNow.length) {
        await nostr.publish(deletionTemplate({ ids: mineNow.map((vote) => vote.id), kind: 7 }, now()));
        for (const vote of mineNow) revokeVote(vote);
      }
      if (direction) {
        const signed = await nostr.publish(voteTemplate(info.save, direction, now()));
        const vote = parseVote(signed);
        if (vote) keepVote(vote);
      }
    },

    async comments(id, start = 0, count = 20) {
      const info = await lookup(id);
      if (!info.address) return [];
      const cached = comments.get(info.address);
      if (!cached || now() - cached.at >= COMMENTS_TTL) {
        const found = await nostr.collect([{ kinds: [1111], '#A': [info.address] }], { minMs: 600 });
        const list = new Map((cached?.list ?? []).map((comment) => [comment.id, comment]));
        for (const event of found) {
          const comment = parseComment(event);
          if (comment && comment.address === info.address) list.set(comment.id, comment);
        }
        comments.set(info.address, { at: now(), list: [...list.values()] });
        await loadProfiles([...new Set([...list.values()].map((comment) => comment.pubkey))]);
      }
      return comments.get(info.address).list
        .slice().sort((a, b) => b.at - a.at || (a.id < b.id ? -1 : 1))
        .slice(start, start + count)
        .map((comment) => ({ ...comment, username: nameOf(comment.pubkey) }));
    },

    async addComment(id, text) {
      requireSession();
      const info = await lookup(id);
      if (!info.published) throw new ServerError('Unpublished saves have no comments.');
      const clean = String(text ?? '').trim().slice(0, 2000);
      if (!clean) throw new ServerError('The comment is empty.');
      const signed = await nostr.publish(commentTemplate(info.save, clean, now()));
      const comment = parseComment(signed);
      if (comment) {
        const cached = comments.get(info.address) ?? { at: 0, list: [] };
        cached.list = cached.list.filter((item) => item.id !== comment.id).concat(comment);
        comments.set(info.address, cached);
      }
    },

    async editTag(id, op, tag) {
      const info = await lookup(id);
      requireOwner(info);
      const clean = String(tag ?? '').trim().toLowerCase();
      if (!/^[a-z0-9]{1,16}$/.test(clean) || clean === SAVE_TAG) throw new ServerError('Tags are 1 to 16 lowercase letters or digits.');
      const next = op === 'delete' ? info.tags.filter((item) => item !== clean) : [...new Set([...info.tags, clean])].slice(0, 20);
      if (info.published) {
        const saved = await publishSave({
          d: info.save.d, name: info.name, description: info.description, bytes: base64ToBytes(info.save.event.content),
          tags: next, publishedAt: info.created,
        });
        return saved.tags;
      }
      await storePrivate({ ...info.record, tags: next });
      return next;
    },

    async remove(id, mode) {
      const info = await lookup(id);
      const me = requireOwner(info);
      if (!info.published) {
        if (mode === 'Unpublish') throw new ServerError('This save is not published.');
        await dropPrivate(info.record.d);
        return;
      }
      if (mode === 'Unpublish') {
        await storePrivate({
          owner: me.pubkey, d: info.save.d, name: info.name, description: info.description, data: info.save.event.content,
          created: info.created, updated: now(), tags: info.tags,
        });
      }
      await nostr.publish(deletionTemplate({ ids: [info.save.event.id], addresses: [info.address], kind: SAVE_KIND, saveTag: true }, now()));
      removedSaves.set(info.address, now());
      saves.delete(info.address);
    },

    async publishPrivate(id) {
      const info = await lookup(id);
      requireOwner(info);
      if (info.published) return stableId(info.address);
      const save = await publishSave({
        d: info.record.d, name: info.name, description: info.description, bytes: base64ToBytes(info.record.data),
        tags: info.tags, publishedAt: now(),
      });
      await dropPrivate(info.record.d);
      return stableId(save.address);
    },

    async favourite(id, on) {
      requireSession();
      const favs = await loadFavourites();
      if (on) favs.add(Number(id)); else favs.delete(Number(id));
      if (storage) await storage.set(FAVOURITES_KEY, JSON.stringify([...favs]));
    },

    async user(name) {
      await refresh();
      const pubkey = usernames.get(name);
      if (!pubkey) throw new ServerError('No player by that name has shared a save yet.');
      const profile = session && pubkey === session.pubkey ? session.profile : profiles.get(pubkey);
      const own = [...saves.values()].filter((save) => save.pubkey === pubkey).map(publicInfo);
      const scores = own.map((info) => info.up - info.down);
      return {
        id: stableId(pubkey), username: name, pubkey,
        biography: typeof profile?.about === 'string' ? profile.about.slice(0, 1000) : '',
        website: typeof profile?.website === 'string' ? profile.website.slice(0, 200) : '',
        location: typeof profile?.nip05 === 'string' ? profile.nip05.slice(0, 100) : '',
        saves: own.length,
        average: scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length : 0,
        highest: scores.length ? Math.max(...scores) : 0,
      };
    },

    async tags({ start = 0, count = 50, query = '' } = {}) {
      await refresh();
      const counts = new Map();
      for (const save of saves.values()) for (const tag of save.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1);
      const prefix = String(query).toLowerCase();
      return [...counts].filter(([tag]) => !prefix || tag.startsWith(prefix))
        .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
        .slice(start, start + count);
    },
  };
}