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/campaign-format.md
# Black Site campaigns (v1)

A campaign is an ordered list of maps played back to back. It's published as an **addressable Nostr event, kind `35859`**, whose `content` is the campaign JSON. Each stage is either a map that ships with the game or a published [kind 35858 map](map-event.md) **pinned to an exact version by its hash**, so a campaign plays the same even after a map's author updates the map.

| File | Role |
|---|---|
| `schemas/black-site-campaign.schema.json` | JSON Schema (draft 2020-12) for the campaign JSON |
| `lib/validate-campaign.mjs` | `validateCampaign(campaign)`, `campaignRules(campaign)`, `DEFAULT_RULES`, `BUILTIN_MAP_IDS` |
| `lib/campaign-event.mjs` | `campaignEventTemplate`, `parseCampaignEvent`, `stageMapFilter`, `stageMapRef` |
| `lib/lineage.mjs` | `remixTags`, `parseLineage` for remixes ([remix-lineage.md](remix-lineage.md)) |
| `examples/campaigns/black-site.json` | The game's own campaign written in this format |
| `examples/campaigns/border-run.json` | Built-in and published maps mixed, with custom rules and briefings |

## Campaign JSON

```jsonc
{
  "version": 1,                   // required
  "name": "BORDER RUN",           // required: 1-32 chars
  "desc": "A short hard-mode run…", // up to 200 chars
  "rules": {                      // optional, every field optional
    "completionBonus": 5000,      // default 3000
    "quitPenalty": 1000,          // default 400
    "difficulty": "hard",         // easy | normal | hard. Omit to let the player choose
    "carryInventory": true,       // default true
    "restoreHealth": false        // default true
  },
  "stages": [                     // required: 1-50, in play order
    { "builtin": "town", "briefing": "Get through town…" },
    {
      "address": "35858:<author pubkey hex>:outpost",
      "x": "<sha256 of the map JSON>",
      "relays": ["wss://relay.example.com"],
      "briefing": "The outpost holds the intel…"
    },
    { "builtin": "refinery" }
  ]
}
```

Unknown fields are rejected. The campaign's id is the event's `d` tag, not a JSON field.

### Rules

| Rule | Default | Meaning |
|---|---|---|
| `completionBonus` | `3000` | Paid on top of the funds hold when the last map is cleared, multiplied by the difficulty's money factor (easy ⅓, normal 1, hard 3) |
| `quitPenalty` | `400` | Taken from the hold (never below 0) when the player quits, restarts or closes the game mid-map |
| `difficulty` | none | Locks the campaign to one difficulty. Without it, the player's chosen difficulty is fixed when the campaign starts |
| `carryInventory` | `true` | Weapons and items carry from map to map. Intel is always cashed into the hold at the end of each map. When `false`, every map starts with the player's loadout |
| `restoreHealth` | `true` | Health and armour are restored between maps. When `false`, they carry over |

These rules are fixed for every campaign:
- Kills and intel from each cleared map go into a funds hold.
- The hold is paid out only when the last map is cleared.
- Dying ends the campaign and loses the hold.
- Progress is saved after every cleared map.

### Stages

| Stage | Fields | Notes |
|---|---|---|
| Built-in | `builtin`: `town` \| `docks` \| `desert` \| `snow` \| `refinery`, and optional `briefing` | Plays the map that ships with the game |
| Published | `address` (`35858:<pubkey>:<d>`), `x` (SHA-256 of the map JSON), optional `relays` (up to 5 `wss://`), optional `briefing` | `x` is the map event's `x` tag at the time the campaign was made |

`briefing` is up to 500 characters of plain text, shown before the map starts. A map may appear in more than one stage.

## Event

```jsonc
{
  "kind": 35859,
  "content": "<the campaign JSON, JSON.stringify'd, at most 64 KiB>",
  "tags": [
    ["d", "border-run"],                // REQUIRED: campaign id, ^[a-z0-9][a-z0-9-]{0,31}$
    ["title", "BORDER RUN"],            // the campaign name
    ["summary", "A short hard-mode run…"],
    ["version", "1"],                   // REQUIRED: equals the JSON version
    ["a", "35858:<pubkey>:outpost", "wss://relay.example.com"], // one per published map, for discovery
    ["image", "https://…/cover.png"],   // optional cover
    ["t", "hard"],                      // optional lowercase hashtags
    // a remix adds NIP-22 style lineage tags: A/E/K/P (root), a/e/k (parent), a p per author,
    // with kind 35859 addresses, e.g. ["a", "35859:<parent pubkey>:<parent d>", "wss://…"]
    ["alt", "Black Site campaign: BORDER RUN (3 maps)"]
  ]
}
```

- **Address**: `35859:<pubkey>:<d>`, shared as an `naddr`. Republishing the same `d` replaces the campaign, and a NIP-09 deletion with an `a` tag removes it.
- **The content is authoritative.** Tags only help discovery. `a` tags let a client find every campaign that uses a map: `{ kinds: [35859], '#a': ['35858:<pubkey>:<d>'] }`.
- **No format tag**: kind 35859 only ever carries Black Site campaigns.
- **Remixes** are new campaigns under the remixer's address, with lineage tags crediting the parent campaign, the original and every author. Only `A`/`a` tags with a kind `35859` address are lineage, so the stage map `a` tags (kind `35858`) never count as a parent. See [remix-lineage.md](remix-lineage.md).

## Publishing

1. Make sure `validateCampaign(campaign)` returns `[]`. To pin a published map, use the map event's current `x` and add relay hints where it was found.
2. Build the event with `campaignEventTemplate(id, campaign, { image, hashtags })`. For a remix, also pass `remixOf` (the signed parent campaign event) and `remixRelay`, and keep them for later updates. Remixing your own campaign needs a new `id`.
3. Publish it with **NAP-OUTBOX** `outbox.publish(template)`. As with maps, the host must allow kind 35859 publishing.

## Loading

1. Find campaigns with **NAP-OUTBOX** `outbox.query`:
   - browse: `[{ kinds: [35859], limit: 50 }]`
   - by author: `authors: [pubkey]`
   - from an `naddr`: `authors` plus `'#d'`, with its relay hints
2. Read each event with `parseCampaignEvent(event)`. It checks the kind, the tags (lineage included), the 64 KiB limit and the JSON, and fills in default rules. `lineage` is `null` for an original, or names the parent, the root and every author to credit.
3. For each published stage, before the stage starts (or all of them up front, to show availability):
   1. Try **NAP-RESOURCE** `resource.bytes('blossom:<x>')` first. The shell finds the blob and checks its hash, so no map event is needed.
   2. If that fails, look up the map event with `outbox.query([stageMapFilter(stage)], { relays: stage.relays })`, read it with `parseMapEvent`, and try `mapResourceUrls(stageMapRef(stage, parsedMapEvent), authorServers)`.
   3. Check the bytes with `readMapBytes(bytes, stageMapRef(stage), sha256Hex)`. This checks against the **pinned** `x`, never the map event's newer one.
4. If a stage's map can't be found, show the campaign as unavailable rather than skipping the stage.
5. Play: `buildLevel(map)` for each stage, applying `campaign.rules`.

Campaign text (`name`, `desc`, `briefing`, tags) is untrusted. Show it as escaped plain text.

## Status

The game doesn't load or publish campaigns yet. Its built-in campaign still uses `src/game/maps/index.json` and the fixed rules above. `examples/campaigns/black-site.json` is that same campaign in this format, and the tests keep the two in sync.