Back to Black Site
SOURCE / PINNED RELEASE

Made of little things.

Black Site

Release
666bc7c74ebd…
Author-recorded commit
05e95af79676…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-d2225ddc391

Archive hash verified: 1544e072e245c4a8…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

spec/lib/campaign-event.mjs
// Black Site campaigns on Nostr (../campaign-format.md): an addressable kind 35859 event whose
// content is the campaign JSON. Its stages point at built-in maps or at kind 35858 map events
// pinned by hash. Dependency-free, so a map editor can copy it.
//
//   campaignEventTemplate(id, campaign, extra) -> unsigned event template for publishing
//   parseCampaignEvent(event)                  -> { ok: true, campaign: {...} } | { ok: false, errors }
//   stageMapFilter(stage)                      -> NAP-OUTBOX filter for a stage's map event
//   stageMapRef(stage, parsedMapEvent?)        -> what mapResourceUrls / readMapBytes need
//
// A remix passes the signed parent campaign event as `extra.remixOf`; see ../remix-lineage.md.

import { parseLineage, remixTags } from './lineage.mjs';
import { MAP_EVENT_KIND } from './map-event.mjs';
import { CAMPAIGN_VERSION, campaignRules, validateCampaign } from './validate-campaign.mjs';

export const CAMPAIGN_EVENT_KIND = 35859;
/** Campaign JSON (the event content) larger than this is rejected. */
export const MAX_CAMPAIGN_BYTES = 64 * 1024;

const ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
const HEX64 = /^[0-9a-f]{64}$/;

/**
 * Builds the unsigned event for a valid campaign. `extra` may add { image, hashtags, client }, and
 * { remixOf, remixRelay } for a remix: the signed kind 35859 event it was made from and a relay
 * hint where that event was found.
 */
export function campaignEventTemplate(id, campaign, extra = {}) {
  if (typeof id !== 'string' || !ID.test(id)) throw new Error('campaign id must be 1-32 chars of a-z, 0-9 and -');
  if (extra.remixOf && extra.remixOf.kind !== CAMPAIGN_EVENT_KIND) throw new Error(`a campaign can only remix a kind ${CAMPAIGN_EVENT_KIND} campaign`);
  const errors = validateCampaign(campaign);
  if (errors.length) throw new Error(`campaign is invalid:\n${errors.join('\n')}`);
  const content = JSON.stringify(campaign);
  if (new TextEncoder().encode(content).byteLength > MAX_CAMPAIGN_BYTES) throw new Error(`campaign JSON is larger than ${MAX_CAMPAIGN_BYTES} bytes`);
  // one a tag per published map (first use wins), so relays can find campaigns that use a map
  const seen = new Set();
  const aTags = [];
  for (const s of campaign.stages) {
    if (!s.address || seen.has(s.address)) continue;
    seen.add(s.address);
    aTags.push(s.relays?.length ? ['a', s.address, s.relays[0]] : ['a', s.address]);
  }
  const tags = [
    ['d', id],
    ['title', campaign.name],
    ...(campaign.desc ? [['summary', campaign.desc]] : []),
    ['version', String(CAMPAIGN_VERSION)],
    ...aTags,
    ...(extra.remixOf ? remixTags(extra.remixOf, { relay: extra.remixRelay }) : []),
    ...(extra.image ? [['image', extra.image]] : []),
    ...(extra.hashtags || []).map((t) => ['t', String(t).toLowerCase()]),
    ...(extra.client ? [['client', extra.client]] : []),
    ['alt', `Black Site campaign: ${campaign.name} (${campaign.stages.length} maps)`],
  ];
  return { kind: CAMPAIGN_EVENT_KIND, content, tags, created_at: Math.floor(Date.now() / 1000) };
}

/** Reads a kind 35859 event. The content is authoritative; the tags only help discovery. */
export function parseCampaignEvent(event) {
  if (!event || event.kind !== CAMPAIGN_EVENT_KIND) return { ok: false, errors: [`kind must be ${CAMPAIGN_EVENT_KIND}`] };
  const errors = [];
  if (!HEX64.test(event.pubkey || '')) errors.push('pubkey must be 64 lowercase hex chars');
  const tags = Array.isArray(event.tags) ? event.tags.filter((t) => Array.isArray(t) && typeof t[0] === 'string') : [];
  const one = (name) => tags.find((t) => t[0] === name)?.[1];
  const id = one('d');
  if (typeof id !== 'string' || !ID.test(id)) errors.push('d tag must be a valid campaign id');
  const version = Number(one('version'));
  if (!Number.isInteger(version) || version < 1) errors.push('version tag must be a positive integer');
  else if (version > CAMPAIGN_VERSION) errors.push(`campaign version ${version} is newer than this game supports (${CAMPAIGN_VERSION})`);
  const lineage = parseLineage(event);
  if (!lineage.ok) errors.push(...lineage.errors);
  if (errors.length) return { ok: false, errors };

  const content = typeof event.content === 'string' ? event.content : '';
  if (new TextEncoder().encode(content).byteLength > MAX_CAMPAIGN_BYTES) return { ok: false, errors: [`campaign JSON is larger than ${MAX_CAMPAIGN_BYTES} bytes`] };
  let campaign;
  try {
    campaign = JSON.parse(content);
  } catch {
    return { ok: false, errors: ['content is not valid JSON'] };
  }
  const invalid = validateCampaign(campaign);
  if (invalid.length) return { ok: false, errors: invalid };
  if (campaign.version !== version) return { ok: false, errors: [`version tag ${version} does not match the campaign version ${campaign.version}`] };

  return {
    ok: true,
    campaign: {
      address: `${CAMPAIGN_EVENT_KIND}:${event.pubkey}:${id}`,
      pubkey: event.pubkey,
      id,
      name: campaign.name,
      desc: campaign.desc || '',
      rules: campaignRules(campaign),
      stages: campaign.stages.map(normalizeStage),
      image: one('image') || null,
      hashtags: tags.filter((t) => t[0] === 't' && typeof t[1] === 'string').map((t) => t[1]),
      lineage: lineage.lineage,
      createdAt: event.created_at,
      json: campaign,
    },
  };
}

/** NAP-OUTBOX filter that finds a published stage's map event (for its Blossom server hints). */
export function stageMapFilter(stage) {
  if (stage.type !== 'map') throw new Error('built-in stages have no map event');
  return { kinds: [MAP_EVENT_KIND], authors: [stage.pubkey], '#d': [stage.mapId] };
}

/**
 * The reference that `mapResourceUrls` and `readMapBytes` (map-event.mjs) take, for a published
 * stage. The stage's pinned hash always wins over the map event's current x tag; the event (from
 * parseMapEvent) only contributes Blossom servers.
 */
export function stageMapRef(stage, parsedMapEvent = null) {
  if (stage.type !== 'map') throw new Error('built-in stages have no map event');
  return { id: stage.mapId, sha256: stage.x, size: undefined, servers: parsedMapEvent?.servers || [] };
}

function normalizeStage(s) {
  const briefing = s.briefing || '';
  if (s.builtin) return { type: 'builtin', mapId: s.builtin, briefing };
  const [, pubkey, mapId] = s.address.split(':');
  return { type: 'map', address: s.address, pubkey, mapId, x: s.x, relays: s.relays || [], briefing };
}