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.
// 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 };
}
