# Fortress adventure `.zip` - import format spec

*Current Ink Fortress/Fortress Reader ZIP contract, reconciled against the authoring exporter and the live Fortress Reader import path. May 2026.*

## TL;DR

1. A Fortress Reader export is a ZIP with `manifest.json`, `module.json`, and a cover image file at the archive root.
2. `manifest.contentHash` is the raw 64-character lowercase SHA-256 hex digest of the exact `module.json` bytes in the ZIP. The live Reader does not accept a `sha256:` prefix.
3. `manifest.cover` names a real file in the ZIP, currently `cover.png`. Do not use `cover: "embedded"` for sideload ZIPs.
4. Quantities are supported. A gate like "requires 5 gems" is `condition_type: "gte", value: 5`; a reward like "+1 widget" is `action: "add_value", value: 1`.
5. To handshake with both the live Reader and the local Core6 code, conditions and mutations emit both names for the same artifact: `requires` plus `artifact` on conditions, and `target_id` plus `artifact` on mutations. The two names MUST hold identical values.
6. **Hash the bytes you ship, not a re-serialized copy.** Compute `contentHash` against the exact byte buffer you write to the ZIP and write that same buffer. Different JSON libraries (`json.dumps` in Python, `JSON.stringify` in Node, `serde_json` in Rust, `encoding/json` in Go) emit different bytes for the same logical object; re-serializing between hashing and writing will silently break the hash with no useful error.

## Zip layout

```text
adventure.fortress.zip
├── manifest.json    # library card, hash, cover pointer, series metadata
├── module.json      # playable graph, artifacts, branches, gates, effects
└── cover.png        # cover image named by manifest.cover
```

All files are at the archive root. Extra files may be ignored by the Reader, but the exporter should keep the package minimal.

## `manifest.json`

The manifest is the library-card and integrity wrapper. Fields the live Reader reads:

| field | type | notes |
|---|---|---|
| `schemaVersion` | string | Emit `"1.1"` for quantity-capable exports. |
| `id` / `moduleId` | string | Module identifier. Emit both keys with the **same value**. A reader that detects a mismatch rejects the import. Format: opaque non-empty string, `mod_*` convention, ≤ 64 chars. |
| `title` | string | Adventure title. |
| `author` | object | `{ id, displayName }`. |
| `intro` / `description` | string | Blurb or intro copy. Emit both. |
| `keywords` | string[] | Tags. |
| `cover` | string | Filename of the cover entry in the ZIP, usually `"cover.png"`. |
| `coverColor` | string | **Decimal string** of a 24-bit RGB integer, range `0`–`16777215`. Example: `"7290656"` is `#6F36E0`. Not a hex string, not `"#RRGGBB"`, not ARGB. |
| `coverDataUrl` | string | Optional fallback/preview cover data URL. |
| `version` | integer | Content version of this module. |
| `contentHash` | string | Raw SHA-256 hex digest over the exact `module.json` bytes. |
| `publishedAt` | string | ISO timestamp. |
| `defaultCanonNodeId` | string | Start node when no predecessor socket matches. |
| `seriesId` | string | Series id. |
| `series` | string | Series display title. The live shelf facet currently expects a string. |
| `seriesCollection` | object | Optional richer series metadata: `{ collectionId, title }`. |
| `predecessors` | string[] | Module ids that come before this module. |
| `startSockets` | object[] | Per-predecessor entry points. |
| `engine` | object | Exporter capability advertisement. Currently reads `{ "quantitiesSupported": boolean }`; other keys are ignored. Emit `{ "quantitiesSupported": true }` for `1.1` exports. |

Reader-internal fields should not be emitted: `isDownloaded`, `importedAt`, `lastReadTime`, `sizeMb`, `source`, `progressVal`, `displayName`.

### Hashing `module.json` (read this carefully)

The reader recomputes `sha256(module.json bytes from the ZIP)` and compares it byte-for-byte with `manifest.contentHash`. Any difference rejects the import.

**Rule:** the bytes you hash MUST be the exact bytes you write to the ZIP entry. The simplest correct implementation is:

```text
moduleBytes  = your-serializer(moduleObject)   // ← one Buffer/byte array
contentHash  = sha256_hex(moduleBytes)
zip.add("module.json", moduleBytes)            // ← the same Buffer
manifest.contentHash = contentHash
```

What goes wrong if you don't follow that rule:

- **Re-serializing kills the hash.** `Buffer.from(JSON.stringify(obj))` in Node and `json.dumps(obj).encode()` in Python produce different bytes for the same object (different key order, different indentation, different unicode escaping, different floating-point formatting). Computing the hash from one serialization and writing the file with another is the single most common reason a homemade exporter fails.
- **Pretty-print vs minify is a choice; *consistency* is not.** You may emit pretty (2-space) or minified `module.json` — readers don't care — as long as the bytes hashed and the bytes shipped are identical.
- **No BOM. UTF-8.** Write `module.json` as UTF-8 without a byte-order mark.
- **No prefix on the hash.** `manifest.contentHash` is exactly 64 lowercase hex characters. No `sha256:`, no `0x`, no uppercase.
- **No newline normalization on write.** Whatever bytes your serializer produced, write them as-is.

### `startSockets[]`

Each socket says "if the reader arrives here after predecessor X, start at node Y." When no socket matches, the Reader falls back to `defaultCanonNodeId`.

```json
"startSockets": [
  { "predecessorId": "mod_hobbit_pt1", "startNodeId": "n_arrival_from_pt1" }
]
```

**Precedence when multiple sockets could match** (e.g. the reader has completed several listed predecessors): the **first matching socket in array order** wins. Emit your highest-priority predecessor first. A socket whose `startNodeId` does not exist in `module.nodes` is treated as "no match."

`predecessors[]` and `startSockets[]` serve different purposes: `predecessors` is informational/UX (used by the library to draw "Sequel to X" affordances), while `startSockets` is what the runtime consults to choose the starting node. Modules with no predecessors should emit both as `[]`.

### Cover image

The ZIP MUST contain a real cover image file whose filename matches `manifest.cover` (conventionally `cover.png`). Specifics:

- **Format:** PNG (preferred) or JPEG. The reader picks the type from the file extension; do not rely on content sniffing.
- **Dimensions:** recommended portrait around 600×900 (2:3 ratio, "book cover" shape). Minimum readable is roughly 200×300; very large images are downscaled by the reader.
- **File size:** keep under 500 KB. Larger covers inflate the ZIP and slow library list rendering.
- **No transparency required.** PNG alpha is preserved if present.
- `manifest.cover` MUST be just the filename (e.g. `"cover.png"`), not a path. Do not nest the cover in a subfolder. Do not use the literal value `"embedded"` for sideload ZIPs (that mode exists only for in-app authoring previews).
- `manifest.coverColor` is the fallback solid colour shown while the image loads (and in places that prefer a swatch). Always emit it — see the table for the encoding.

## `module.json`

Top-level keys the Reader reads: `schemaVersion`, `moduleId`, `rootNodeId`, `defaultCanonNodeId`, `nodes`, `artifacts`, and `startSockets`. Edges are embedded in each node as `node.edges`.

```json
{
  "schemaVersion": "1.1",
  "moduleId": "mod_library_murder",
  "rootNodeId": "n_start",
  "defaultCanonNodeId": "n_start",
  "artifacts": {
    "clue_points": { "id": "clue_points", "label": "Clue points", "visibility": "visible" }
  },
  "nodes": {}
}
```

**`module.nodes` is an object keyed by node id**, not an array. Each entry's key is the node id, and the value is a Node object whose embedded `id` field MUST equal that key. The same shape rule applies to `module.artifacts` (keyed by artifact id). The current importer also accepts the older array form for backwards compatibility, but new exporters MUST emit the object form because the live Reader validates references via object lookup.

**`schemaVersion` MUST be identical to `manifest.schemaVersion`.** A mismatch is grounds for rejection.

**`moduleId` MUST be identical to `manifest.moduleId`.** A mismatch is grounds for rejection.

**`rootNodeId` vs `defaultCanonNodeId`.** Both are node ids in `module.nodes`. In the current Reader they serve the same role — the "canonical" starting node when no `startSocket` matches — and exporters MUST set them to the same value. They exist as two fields for forward-compatibility with future "story-graph anchor" routing; until that ships, keep them in sync. If they disagree, the Reader prefers `defaultCanonNodeId` and emits a warning.

**`startSockets` may appear in both the manifest and `module.json`.** When both are present they MUST match. Emit both for compatibility; the Reader cross-checks.

### Artifacts

For live Reader ZIPs, `module.artifacts` is an object keyed by artifact id:

```json
"artifacts": {
  "gems": { "id": "gems", "label": "Gems", "visibility": "visible" },
  "skeleton_key": { "id": "skeleton_key", "label": "Skeleton key", "visibility": "visible" }
}
```

The local importer also accepts the older array form, but the export target should emit the object form because the live Reader validates references through object lookup.

### Node

| field | type | required? | notes |
|---|---|---|---|
| `id` | string | **required** | Opaque non-empty string; MUST equal the key under which this node is stored in `module.nodes`. Recommended `n_*` convention, ≤ 64 chars, ASCII letters/digits/underscores. |
| `title` | string | required | Scene title (plain text, no HTML). |
| `content.prose` | string | required | Scene body as sanitized HTML — see "Prose HTML subset" below. |
| `type` | string | omit | Cosmetic/legacy. **New exports SHOULD omit this field.** The reader ignores it and derives runtime behavior from gates and effects. |
| `isEnding` | boolean | required | `true` marks the node as a terminal ending. Series-chaining caches state at ending nodes. |
| `endingTitle` | string | required when `isEnding: true`, else `""` | Ending label shown to the reader. |
| `arrivalConditions` | condition[] | required | Gate on entering this node. Emit `[]` when none. |
| `arrivalMatch` | `"all"` \| `"any"` | required | How `arrivalConditions` combine. Defaults to `"all"` if omitted, but exporters SHOULD always emit it explicitly. |
| `state_mutations` | mutation[] | required | Effects applied on arrival. Emit `[]` when none. |
| `edges` | edge[] | required | Outgoing choices. Emit `[]` on ending nodes (none) or sinks. |

**Prose HTML subset.** The reader sanitizes `content.prose` on render. Exporters MAY include these tags safely: `<p>`, `<br>`, `<em>`, `<strong>`, `<i>`, `<b>`, `<u>`, `<a href="...">`, `<ul>`, `<ol>`, `<li>`, `<blockquote>`. Other tags are stripped. Forbidden under all circumstances: `<script>`, `<style>`, `<iframe>`, `<object>`, `<embed>`, inline event handlers (`onclick=…`), and inline `style="…"` attributes. External `<a href>` targets are rendered as safe outbound links (the reader adds `rel="noopener"` and may rewrite `target`).

### Edge

| field | type | required? | notes |
|---|---|---|---|
| `target_node_id` | string | required | Destination node id. MUST exist as a key in `module.nodes`. |
| `label` | string | required | Choice button text (plain text, no HTML). |
| `conditions` | condition[] | required | Gate on this choice. Emit `[]` when none. |
| `conditionMatch` | `"all"` \| `"any"` | required | How `conditions` combine. Defaults to `"all"` if omitted, but exporters SHOULD always emit it explicitly. |
| `lockedDisplay` | `"hidden"` \| `"greyed"` | required | What the choice does when its gate isn't met. `"hidden"` — the choice doesn't render. `"greyed"` — the choice renders disabled with `lockedMessage`. Has no observable effect when `conditions` is empty, but the field MUST still be present. |
| `lockedMessage` | string | required | Text shown beside a `"greyed"` locked choice. Emit `""` when not used. Plain text, no HTML. |

### Condition

Conditions carry the **dual-name compatibility handshake**: both `requires` and `artifact` are emitted with the **identical** value. If the two disagree, behavior is undefined — some readers prefer `requires`, others `artifact` — so authors and tools MUST keep them in sync.

| field | type | required? | notes |
|---|---|---|---|
| `requires` | string | **required** | Artifact key (Core6 name). Must reference an id present in `module.artifacts`. |
| `artifact` | string | **required** | Same value as `requires` (live Reader alias). |
| `condition_type` | `"has"` \| `"lacks"` \| `"gte"` \| `"lte"` \| `"eq"` | required | Gate operator. `has` ≡ quantity ≥ 1; `lacks` ≡ quantity = 0; `gte`/`lte`/`eq` compare against `value`. |
| `value` | number | required for `gte`/`lte`/`eq`; omit for `has`/`lacks` | The threshold for numeric operators. Non-negative integer in practice; the runtime does not enforce a ceiling. |

Example:

```json
{ "requires": "gems", "artifact": "gems", "condition_type": "gte", "value": 5 }
```

### Mutation

Mutations carry the **dual-name compatibility handshake**: both `target_id` and `artifact` are emitted with the **identical** value. The two MUST agree.

| field | type | required? | notes |
|---|---|---|---|
| `target_id` | string | **required** | Artifact key (Core6 name). Must reference an id present in `module.artifacts`. |
| `artifact` | string | **required** | Same value as `target_id` (live Reader alias). |
| `action` | `"add"` \| `"remove"` \| `"toggle"` \| `"add_value"` \| `"subtract_value"` \| `"set_value"` | required | Boolean (`add`/`remove`/`toggle`) or quantity (`*_value`) mutation. |
| `value` | number | required for `add_value`/`subtract_value`/`set_value`; omit for boolean actions | The delta or absolute. Non-negative integer in practice. |
| `is_unique` | boolean | required | Apply at most once per **run** (see below). Use `true` for item grants inside loops the reader can revisit so revisits don't double-apply. |
| `visibility` | `"visible"` \| `"hidden"` | required | `"visible"` shows the artifact in the reader's inventory; `"hidden"` keeps it as story memory only. |

**What "run" means.** A run is a single playthrough from the chosen starting node (the root, or a matching socket node) to either an ending node or an explicit restart. The reader tracks `is_unique` per (run, node-id, target_id) — i.e. *this mutation, on this node, in this playthrough*. Restarting the adventure resets the tracking; opening a new save / Fortress Sync resumption preserves it.

Example:

```json
{ "target_id": "widgets", "artifact": "widgets", "action": "add_value", "value": 1, "visibility": "visible", "is_unique": true }
```

## Worked example

```json
// manifest.json
{
  "schemaVersion": "1.1",
  "id": "mod_gem_gate",
  "moduleId": "mod_gem_gate",
  "title": "Gem Gate",
  "author": { "id": "author_george", "displayName": "George" },
  "intro": "Collect enough gems to open the gate.",
  "description": "Collect enough gems to open the gate.",
  "keywords": ["test"],
  "cover": "cover.png",
  "coverColor": "7290656",
  "version": 1,
  "defaultCanonNodeId": "start",
  "contentHash": "64 lowercase hex chars over module.json",
  "publishedAt": "2026-05-28T00:00:00.000Z",
  "seriesId": "ser_gem_gate",
  "series": "Gem Gate Tests",
  "seriesCollection": { "collectionId": "ser_gem_gate", "title": "Gem Gate Tests" },
  "predecessors": [],
  "startSockets": []
}
```

```json
// module.json
{
  "schemaVersion": "1.1",
  "moduleId": "mod_gem_gate",
  "rootNodeId": "start",
  "defaultCanonNodeId": "start",
  "artifacts": {
    "widgets": { "id": "widgets", "label": "Widgets", "visibility": "visible" },
    "gems": { "id": "gems", "label": "Gems", "visibility": "visible" }
  },
  "nodes": {
    "start": {
      "id": "start",
      "type": "narrative",
      "title": "The Workbench",
      "content": { "prose": "You pick up one widget and five gems." },
      "arrivalConditions": [],
      "arrivalMatch": "all",
      "state_mutations": [
        { "target_id": "widgets", "artifact": "widgets", "action": "add_value", "value": 1, "visibility": "visible", "is_unique": true },
        { "target_id": "gems", "artifact": "gems", "action": "add_value", "value": 5, "visibility": "visible", "is_unique": true }
      ],
      "edges": [
        { "target_node_id": "gate", "label": "Approach the gem gate", "conditions": [], "conditionMatch": "all", "lockedDisplay": "hidden" }
      ]
    },
    "gate": {
      "id": "gate",
      "type": "narrative",
      "title": "The Gem Gate",
      "content": { "prose": "The gate opens because you have five gems." },
      "arrivalConditions": [
        { "requires": "gems", "artifact": "gems", "condition_type": "gte", "value": 5 }
      ],
      "arrivalMatch": "all",
      "state_mutations": [],
      "edges": []
    }
  }
}
```

## Exporter responsibilities

- Compile the authoring graph with `compileModule(...)`.
- Preserve branches as embedded `node.edges[]` with `target_node_id`.
- Emit quantity-capable Core6 actions and conditions.
- Add live Reader compatibility aliases: condition `artifact` and mutation `artifact`.
- Emit `module.artifacts` as an object registry keyed by artifact id.
- Hash the exact `module.json` text and write the raw hex digest to `manifest.contentHash`.
- Include a real cover image entry whose filename matches `manifest.cover`.
- Keep `manifest.series` as a string for the current live shelf UI, with optional `seriesCollection` for richer metadata.

## Versioning

`schemaVersion` is a SemVer-style `"MAJOR.MINOR"` string carried in **both** `manifest.json` and `module.json`. The Reader gates on the major: today's Reader accepts `"1.x"` and rejects anything else with *"Unknown major version N. Requires app update."*

- **`1.0`** — initial format. Boolean-only conditions (`has`/`lacks`) and boolean-only mutations (`add`/`remove`/`toggle`). No `value` field on conditions or mutations.
- **`1.1`** (current) — adds quantity support: `condition_type` of `gte`/`lte`/`eq` with a `value` field, and `action` of `add_value`/`subtract_value`/`set_value` with a `value` field. Modules using only `1.0` features are still valid at `1.1`.
- **`1.x` future minors** — additive only. New optional fields readers may ignore safely. Existing fields keep their meaning.
- **`2.x`** — reserved for breaking changes. Exporters MUST NOT emit `2.x` until the Reader announces support.

**Pinning rule:** an exporter that uses any `1.1` feature (numeric operators, `value` fields) MUST emit `schemaVersion: "1.1"`. Otherwise emit `"1.0"`. Mismatched values across `manifest.json` / `module.json` are rejected.

## Static exports

HTML, DOCX, and PDF are static gamebook outputs: they render "if you choose X, go to Section Y" references or same-page anchors, plus visible note-taking instructions for gained/lost artifacts. The Fortress Reader ZIP is the authoritative machine-readable package for inventory, quantities, locked choices, branches, endings, start-over behavior, and module chaining.
