Repository navigation
Feature Narrative Loader
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.
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.
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/.
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. |
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=trueset at placement). -
First right-click (server-side): resolves the world's next Letter via
BookFactory.buildOrRandomForLectern, places it on the BE, marks it read inNarrativeProgressData, 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:
onRemoveclears 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):
- Has the world started any story but not finished it? → next-unread Letter of that story.
- 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). - All stories complete? → action-bar message "All narratives complete." and a no-op right-click (lectern stays unlocked until more content loads).
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.
A new narrative content cell ships alongside the loader, so train carriages can spawn narrative-themed interiors:
-
narrativegroup dispatches to two new cells (dungeonnarrative,seatingnarrative), each with their own NBT + variants. - Whitelisted in
black,cracked,pen,windowedcarriage templates via the existing<id>.contents-allow.jsonsidecar pattern. - Weight 3 in
weights.json. Variants reference vanilla blocks only — no dependency on thenarrative_lecternblock (yet).
- Open a Dungeon Train world.
-
/dungeontrain narrative spawnlecternwhile looking at a flat block — the new lectern appears with a book on top. - 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.
- Read it. The Letter is now marked read for the world.
- Right-click the same lectern again → same Letter (locked). Walk to a different lectern → gives the world's next unread Letter.
- Finish a story → the next unlocked lectern rolls into a fresh story.
-
/dungeontrain narrative progressto see the world's overall read state. -
/dungeontrain narrative resetto clear the world's progress. (Existing locked lecterns keep their books — the lock lives on the BE, independent of the progress store.)
-
Loader pattern: as of PR #735, loads through vanilla's
ResourceManagerserver-data channel —NarrativeDataLoadersregisters a reload listener per registry onAddReloadListenerEvent, 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 samedata/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 onServerStartingEvent, which bypassed datapacks entirely.) -
Pagination:
BookFactorypaginates 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 fromseed % variants.size(). As of v0.184.0, the lectern's variant seed ispos.asLong()— stable per position so a locked lectern's variant choice persists across save/load. -
narrative_lecternBE wiring:NarrativeLecternHookslistens toBlockEntityTypeAddBlocksEventon the mod bus and callsevent.modify(BlockEntityType.LECTERN, ModBlocks.NARRATIVE_LECTERN.get())— the NeoForge-blessed way to extend a vanilla BE type's valid-block set.
-
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 inunused/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_lecternblock — the new block isn't yet referenced in any*.variants.json. The currentnarrativecontent cell uses bookshelves and themed decor only; lectern placement inside carriages is a future iteration.