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/map-event.md
# Black Site custom maps on Nostr (v1)

Custom maps are published as an **addressable Nostr event, kind `35858`**. The map itself is a
[map format v1](map-format.md) JSON file stored on **Blossom**. The event carries metadata and
the file's SHA-256, and napplets fetch the bytes through **NAP-RESOURCE**. Events are read and
published through **NAP-OUTBOX**.

| Piece | Where |
|---|---|
| Event kind | `35858` (addressable: one live version per author and map id). Not yet in [registry-of-kinds](https://github.com/nostr-protocol/registry-of-kinds) |
| Map bytes | Blossom blob, `application/json`, at most 256 KiB |
| Reference code | [`lib/map-event.mjs`](lib/map-event.mjs): `mapEventTemplate`, `parseMapEvent`, `mapResourceUrls`, `readMapBytes`. No dependencies, so editors can copy it |
| Tests | `tests/map-event.test.mjs` in the game's repository |

## Event

```jsonc
{
  "kind": 35858,
  "pubkey": "<author>",
  "content": "",   // optional plain-text notes or credits, shown as text, never as markup
  "tags": [
    ["d", "outpost"],                      // REQUIRED: equals the map JSON `id`
    ["title", "OUTPOST"],                  // REQUIRED: the map `name`
    ["summary", "A small hand-built ..."], // the map `desc`
    ["version", "1"],                      // REQUIRED: the map JSON `version`
    ["x", "376b81f9…671192"],              // REQUIRED: SHA-256 (lowercase hex) of the exact JSON bytes
    ["m", "application/json"],
    ["size", "969"],                       // byte length of the JSON
    ["server", "https://blossom.example.com"], // Blossom servers holding the blob (0 or more)
    ["image", "https://blossom.example.com/<sha256>.png"], // optional preview image
    ["t", "cqb"],                          // optional lowercase hashtags
    // a remix adds NIP-22 style lineage tags: A/E/K/P (root), a/e/k (parent), a p per author
    ["alt", "Black Site map: OUTPOST"]     // NIP-31 fallback text
  ]
}
```

- **Address**: `35858:<pubkey>:<d>`. Share it as an `naddr`. A map id only needs to be unique per author, so the game must identify custom maps by address, not by `id`.
- **Updating** a map means uploading the new JSON and republishing the same `d` with the new `x`. Relays keep only the newest event. The old blob may stay on Blossom.
- **Deleting** is a NIP-09 kind 5 event with an `["a", "35858:<pubkey>:<d>"]` tag.
- **No format tag**: kind 35858 only ever carries Black Site maps, so the kind identifies the format and `version` selects the revision.
- **Remixes** are new maps under the remixer's address. They carry lineage tags that credit the parent map, the original at the root of the remix tree, and every author along the way. See [remix-lineage.md](remix-lineage.md).
- **Unknown tags** are ignored. A `version` newer than the reader supports means "can't load", not "invalid".

## Publishing (map editor)

1. Check `validateMap(map)` returns `[]` (see [map-format.md](map-format.md)).
2. Serialize with `JSON.stringify(map)`, UTF-8 encoded. Any whitespace is fine, because the hash covers the exact bytes that were uploaded.
3. Upload with **NAP-UPLOAD**: `upload.upload({ data: blob, mimeType: 'application/json', filename: '<id>.json', noTransform: true })`, and wait until the status is `complete`. The result gives `sha256`, `size`, `url` and `fallbackUrls`.
4. Build the event with `mapEventTemplate(map, uploadResult, { image, hashtags, content })`. This turns the upload URLs into `server` tags. For a remix, also pass `remixOf` (the signed parent map event) and `remixRelay`, and keep them for later updates (see [remix-lineage.md](remix-lineage.md)).
5. Publish with **NAP-OUTBOX**: `outbox.publish(template)`. The shell signs it and sends it to the author's outbox relays.

Hosts must allow `outbox.publish` for kind 35858. soyLI doesn't grant arbitrary outbox publishing yet, and support is being added. Until then the editor should show the upload result and say publishing isn't available, rather than failing silently.

## Loading (game)

1. **Find maps** with **NAP-OUTBOX** `outbox.query`:
   - browse: `[{ kinds: [35858], limit: 50 }]`, or with `'#t': ['cqb']`
   - by author: `[{ kinds: [35858], authors: [pubkey] }]` with `{ authors: [pubkey] }` options, so the shell uses that author's NIP-65 relays
   - one map from an `naddr`: `[{ kinds: [35858], authors: [pubkey], '#d': [id] }]`, passing the naddr relay hints as `relays`
2. **Read the tags** with `parseMapEvent(result.event)`. Drop events that fail, and list maps with a newer `version` as unsupported. For a remix, `parsed.lineage` names the parent, the root and every author to credit.
3. **Fetch the bytes** with **NAP-RESOURCE**, trying `mapResourceUrls(parsed, authorServers)` in order:
   1. `blossom:<x>`: the shell finds the blob and checks its hash
   2. `https://<server>/<x>.json` for each `server` tag
   3. the same for the author's kind 10063 Blossom server list ([BUD-03](https://github.com/hzrd149/blossom/blob/master/buds/03.md)). Read it with `outbox.query([{ kinds: [10063], authors: [pubkey] }])`

   Branch on `resource.bytes` error codes: try the next URL on `not-found`, `network-error` or `timeout`, and stop on `too-large` or `blocked-by-policy`.
4. **Check the bytes** with `readMapBytes(bytes, parsed, sha256Hex)`. This rejects:
   - JSON over 256 KiB, or a size that doesn't match the `size` tag
   - a hash mismatch (always check it for `https:` downloads)
   - invalid JSON, a map that fails `validateMap`, or a map `id` that differs from `d`
5. **Play**: `buildLevel(map)`.

Custom maps are untrusted data. The validator bounds every number and string. The game never runs anything from a map, and it must escape `name`, `desc`, `title` and `content` when showing them. NAP-RESOURCE and NAP-OUTBOX are optional domains: without them the custom-map browser is hidden and the built-in campaign still works.

## Future

- **Map packs or a community campaign**: a list event (NIP-51 style) of `a` tags pointing at kind 35858 maps, in play order.
- **Ratings and comments**: NIP-25 reactions and NIP-22 comments on the map address.
- **Remix browser**: all remixes of an original with `'#A': [address]`. See [remix-lineage.md](remix-lineage.md).
- **Caching played maps**: store them in NAP-STORAGE keyed by `x`. Storage is 512 KB per napplet, so evict old entries.