Skip to content

Feature Narrative Loader

Brennan Hatton edited this page Jul 12, 2026 · 3 revisions

Narrative loader — lectern + book per Letter, with world-scoped progression

Since: v0.178.0 — PR #223 · world-scoped + lock-on-first-read: v0.184.0 (PR #230)

Loads ~50 KB of long-form narrative content from the original itch.io game into Minecraft as signed-book + lectern primitives. Each story is split into Letters and shipped as JSON; every Letter renders as a vanilla written book. A narrative_lectern block resolves its book the first time anyone right-clicks it, then locks that content to the BE. Progression is world-scoped — every player on the world shares one cursor, and each new world starts fresh.

What ships

Six long-form narratives (~120 Letters total) authored by Brennan for the original 2020 game:

Story Letters Variants Notes
Aelis the Ronin Shield Maiden, Part One Outline 15 15 —
Augustus Park 15 25 1 editor note
Jay Kuruvilla, The Time Traveler 12 12 —
Maxwell Valiant 13 13 —
The Letters of Madame Ulster, The Answerless Prophet 12 12 1 editor note
The Querys And Life Of Fourteen 14 14 1 editor note

Faulthurst.txt ships in unused/ as a placeholder for future delivery surfaces.

Source format → JSON

Original .txt files use the Unity-era format: Character: / Story: headers, Letter One- / Letter 3- chapter markers, Alt / Alt- / Alt 2- for variant blocks, and [bracketed text] for inline editor notes. A one-shot Python converter at scripts/narrative/txt_to_json.py walks every .txt, parses the structure, strips bracketed notes (capturing (variant, offset, text) tuples per Letter), and emits structured JSON per story.

{
  "id": "augustus_park",
  "character": "Augustus Park",
  "story": "Untitled",
  "letters": [
    { "index": 1, "label": "Letter One",
      "variants": ["First variant body...", "Alt variant..."],
      "notes": [{ "variant": 0, "offset": 245, "text": "editor note" }] }
  ]
}

The originals live at src/main/resources/data/dungeontrain/narratives/originals/ so the converter can be re-run if narratives are edited at the source level. The shipped JSON lives at src/main/resources/data/dungeontrain/narratives/stories/.

Commands

All commands are OP-only (permission level 2), inherited from the dungeontrain root.

Command What it does
/dungeontrain narrative list Print every loaded story with letter + variant counts.
/dungeontrain narrative book Give the player a signed book of the world's next-unread Letter.
/dungeontrain narrative book <story> Same, but prefer the named story (cycles to next uncompleted if complete for the world).
/dungeontrain narrative book <story> <letter> Give a specific Letter as a book. Does not advance progression — testing override.
/dungeontrain narrative lectern [story] [letter] Place a vanilla lectern at the player's look-target with a mounted book (same progression rules as book).
/dungeontrain narrative spawnlectern Place a narrative_lectern block at the player's look-target. Book content resolves on first right-click and then locks.
/dungeontrain narrative progress Chat-print the world's per-story read/total counts and the next pointer.
/dungeontrain narrative reset Wipe the world's narrative progress. (Locked lecterns keep their books — the lock is at BE-level and independent of the progress store.)
/dungeontrain narrative reload Re-scan bundled narratives without restarting the server.

narrative_lectern block

A vanilla-lectern look-alike with lock-on-first-read book resolution. Reuses the vanilla LecternBlockEntity via NeoForge's BlockEntityTypeAddBlocksEvent — no custom block-entity, no custom renderer.

Behaviour (v0.184.0):

  • Always renders with a book on top (HAS_BOOK=true set at placement).
  • First right-click (server-side): resolves the world's next Letter via BookFactory.buildOrRandomForLectern, places it on the BE, marks it read in NarrativeProgressData, then opens the vanilla lectern menu.
  • Subsequent right-clicks: the BE's book is non-empty → skip resolution, just delegate to vanilla so the locked book opens unchanged.
  • BE serialization persists the locked book across save/load. The lock dies with the lectern: onRemove clears the BE's book so block break doesn't drop the transient book.
  • First registered block in the codebase. Available in the Functional Blocks creative tab.

Resolution algorithm (first click only):

  1. Has the world started any story but not finished it? → next-unread Letter of that story.
  2. No in-progress story? → random pick from uncompleted stories, seeded by lecternPos.asLong() so the same lectern always picks the same story (deterministic by position).
  3. All stories complete? → action-bar message "All narratives complete." and a no-op right-click (lectern stays unlocked until more content loads).

World-scoped progression

NarrativeProgressData extends SavedData, attached to the overworld at <world>/data/dungeontrain_narratives.dat. Stores storyBasename → Set<int> of read Letter indices at world scope (no per-player keying). Set semantics intentional — out-of-order reads don't lose intermediate Letters.

Read detection (NarrativeBookEvents) fires on two surfaces:

  • PlayerInteractEvent.RightClickBlock — vanilla lectern with a narrative book mounted (placed via /narrative lectern).
  • PlayerInteractEvent.RightClickItem — narrative book held in hand and right-clicked.

Both decode the book's identity NBT (dt_narrative_story + dt_narrative_letter in DataComponents.CUSTOM_DATA, set by NarrativeBookTag) and call markRead(story, letter) — idempotent, safe to fire from multiple paths.

The narrative_lectern block bypasses the event subscriber for its first-click marking — it calls markRead inline at the moment of resolution so the read happens atomically with the book swap, fixing the "one-and-done viewer never advances" lag.

Content-cell integration

A new narrative content cell ships alongside the loader, so train carriages can spawn narrative-themed interiors:

  • narrative group dispatches to two new cells (dungeonnarrative, seatingnarrative), each with their own NBT + variants.
  • Whitelisted in black, cracked, pen, windowed carriage templates via the existing <id>.contents-allow.json sidecar pattern.
  • Weight 3 in weights.json. Variants reference vanilla blocks only — no dependency on the narrative_lectern block (yet).

How to use it (player flow)

  1. Open a Dungeon Train world.
  2. /dungeontrain narrative spawnlectern while looking at a flat block — the new lectern appears with a book on top.
  3. Right-click → opens to Letter 1 of a random story (or the world's in-progress story's next Letter). That lectern is now locked to that Letter.
  4. Read it. The Letter is now marked read for the world.
  5. Right-click the same lectern again → same Letter (locked). Walk to a different lectern → gives the world's next unread Letter.
  6. Finish a story → the next unlocked lectern rolls into a fresh story.
  7. /dungeontrain narrative progress to see the world's overall read state.
  8. /dungeontrain narrative reset to clear the world's progress. (Existing locked lecterns keep their books — the lock lives on the BE, independent of the progress store.)

Architecture notes

  • Loader pattern: as of PR #735, loads through vanilla's ResourceManager server-data channel — NarrativeDataLoaders registers a reload listener per registry on AddReloadListenerEvent, so it fires at world load and on /reload. Because it's the datapack pipeline, a datapack can override any bundled story/random-book/starting-book/death-lore file by shipping the same data/dungeontrain/... path — the top pack wins per id — which is how localizers translate the mod's prose without editing the jar. (Previously loaded via a raw classpath scan on ServerStartingEvent, which bypassed datapacks entirely.)
  • Pagination: BookFactory paginates Letter bodies into ≤ 256 chars/page (vanilla visible cap). Tries paragraph → sentence → word boundaries; only hard-breaks on words longer than a page. Vanilla cap of 100 pages enforced with a warn log for any Letter that overflows.
  • Variants per spawn: Letter.pickVariant(seed) picks deterministically from seed % variants.size(). As of v0.184.0, the lectern's variant seed is pos.asLong() — stable per position so a locked lectern's variant choice persists across save/load.
  • narrative_lectern BE wiring: NarrativeLecternHooks listens to BlockEntityTypeAddBlocksEvent on the mod bus and calls event.modify(BlockEntityType.LECTERN, ModBlocks.NARRATIVE_LECTERN.get()) — the NeoForge-blessed way to extend a vanilla BE type's valid-block set.

Known limitations

  • Two-player concurrent first-click race — if Players A and B click the same unlocked lectern on the same tick, both server-side handlers race to setBook. Whichever runs second sees the BE already non-empty and skips resolution — so the race is benign (the first writer wins, both players see the same locked content). Resolution cost is single-cycle.
  • In-progress story picker is alphabetical-first — when multiple stories have been partially read, the world picker prefers the alphabetically-first started story. A timestamp-driven "most recently read" picker is a small follow-up.
  • NPCS/, Welcome Messages/, Scans/ content — sits in unused/ and is not loaded by the runtime. Each needs its own delivery surface (NPC dialogue, paper notes, procedural pools); planned as future iterations.
  • Carriage placement of the narrative_lectern block — the new block isn't yet referenced in any *.variants.json. The current narrative content cell uses bookshelves and themed decor only; lectern placement inside carriages is a future iteration.

Clone this wiki locally