Repository navigation
Feature Community Books
Home > Features > Community Books
Books written and signed by real players are moderated, shared through the mod's relay, and handed back out as chest loot in everyone's worlds. As of v0.485.0 the which book you get choice is made per player, at pickup — matched to your language, biased toward books you haven't read, de-duplicated within a life, and with your read history carried across every new world you start.
Distinct from Random Books (standalone flavour books rolled from a bundled JSON pool): community books are authored by the playerbase, not the mod, and their selection is personal to each player rather than world-scoped.
- Community books turn up as vanilla written books in carriage chests (and resolve out of chiseled bookshelves, hoppers, shift-click — anywhere they reach your inventory).
- You're handed books you haven't read yet first; the same book won't repeat within one life.
- Once you've read a community book, it stays "read" in every new world you start — not just the one you read it in.
- Books are shown in your language where the pool has them, otherwise they fall back to English rather than leaving the slot empty.
- On a multiplayer world, each player gets their own selection, matched to their own language.
- Shelving a book & quill you wrote in but never signed into a chiseled bookshelf prints a gray line saying the train won't carry it — signing is what sends a book into the pool, and an unsigned one only ever sits on the shelf. Flavour only: the book still goes onto the shelf and can be taken back out. (v0.568.0)
A loot slot meant for a community book is filled by the editor-only placeholder dungeontrain:random_playerbook. Rather than pick a book at container-load (shared by everyone), the placeholder is baked pending and resolved per player when it first reaches their inventory — a per-second inventory sweep upgrades any pending placeholder to a real community book. If the pool has no book to offer (relay unreachable or empty), the pending placeholder is a real built-in book, so the slot never ends up empty.
The random_book taper (a small chance for a generic loot book to become a community book) defers the same way.
Resolved by SharedBookSelector — a pure, unit-tested chain over the current pool snapshot:
- Eligibility — skip a book already served this life, unless it's still unread and its carriage has scrolled two full carriage groups behind you (the "far-behind escape").
-
Language bucket (hard) — books in your language (
MINE) plus translations drain before other-language books (OTHER); an absent language family falls back to English. - Unread-first (soft) — within the bucket, prefer books you haven't read. This preference persists across lives and worlds. When the current window holds only already-read books, it serves a read one rather than nothing (the unread tail arrives as the pool accumulates more tiers).
- Weight tier (hard) — keep only the top curation-weight group (a translated book is docked one weight so a native book of equal raw weight ranks above it).
- Seeded random among the survivors, varied per stack so a sweep resolving several placeholders in one tick doesn't hand out the same book.
When the servable pool is exhausted, the per-life de-dup relaxes and repeats a community book — it never degrades to a built-in book while the pool has content.
Since v0.485.0, read history is not a per-world save — it lives in the client's dungeontrain-client.toml and is mirrored to the server on login (SharedBookReadSyncPacket → SharedBookReadMirror). Because DT death launches a fresh-seed world, this is what lets a book you read in one run still read as "read" in the next. The relay also personalises the pool per account (/books/pool?uuid=), dropping your already-read books server-side; the client mirror is the fallback that makes cross-world reads work even before the relay does.
Legacy per-world read data (the old shared_books_read_by_player NBT) is intentionally dropped on load — an old save simply starts its read history fresh.
The pool is fetched scoped to the world host's locale; which of those books count as "your language" is then decided per player from your client locale. Chinese variants collapse to one family (zh_cn / zh_tw / lzh are served together). Note that a book's language tag is stamped from the author's client locale at sign-time, not from its text, so a small fraction of books can carry a mismatched tag (#812).
A portal room can be one author's library — its chiseled bookshelves stocked with that person's
whole catalogue, every shelf past the end of it left empty. Since v0.639.0
(PR #1072, relay
PR #469) the room only ever locks to somebody writing
in the reader's language family. This is decided when the author is chosen, not filtered
afterwards: picking a writer nobody in the room can read and then narrowing their catalogue leaves
bare shelves, so /books/authors itself is now language-scoped.
- The author's book count — the number the room announces, and the number the room's min/max band is judged against — is the count in that language, not their global total.
- When nobody in the family has written enough, the room falls back to an English author rather than standing empty. That is the same fallback ordinary book loot already uses.
- Your own library room is exempt. A room serving you your own writing serves all of it, whatever language you wrote it in.
- The locale used is the world host's, since one room's shelves serve everybody standing in it.
Since v0.1139.0 (PR #1804) every author room is a
sub-variant of one parent, author_rooms. The parent has an empty template that is never stamped, and it holds:
- its top-level lottery weight (2, minLevel 5);
- the split, as its Books setting:
mix:1:2:0:2:0:0= Self 1 : Player 2 : Signature 0 : Stats 2.
When a pair lands on an author room:
- The split is rolled first, seeded from the pair key. If the own-books boost chose the pair, the split is pinned to Self.
-
A room that fits is chosen. "Fits" means the room's own book range (
minexclusive,maxinclusive,0= no ceiling) holds the relevant count:- Player: an author is drawn from the relay directory first (one page over the union of the rooms' ranges). The room must hold their count, and that author is pre-locked to it.
- Self: the reader's own count, from their self page. The nearest room wins when none fits exactly, because your own shelf is exempt from the floor. If the page isn't cached yet, the room stays Self and is drawn by weight. The page is prefetched when network consent arrives and dropped after a signing.
- Stats: the room must hold the full tally (every stat + leaderboard book).
- Among the rooms that fit, each room's own Books weight for the rolled share decides. A room with Player 20 stands a Player library ten times as often as one with Player 2, and a room with weight 0 for a share never stands it. The group's member weights are not used for this draw.
The chosen share is pinned on the room's settings, and the room plus share is remembered per pair in world data
(authorRoomPicks), so a restart re-plans the same room. Use /dungeontrain debug author-rooms [count] [player] to tally
the split and fitted rooms for this world's seed; run it twice with a player, because the first call warms the relay pages.
| Room | Self | Player | Sig | Stats | Range |
|---|---|---|---|---|---|
author_new |
4 | 4 | 0 | 1 | >5 |
smalllibrary |
1 | 2 | 1 | 1 | >10 |
midlibrary |
1 | 2 | 1 | 1 | >24, ≤120 |
author_fancy |
20 | 20 | 0 | 0 | >1, ≤12 |
author_fancy24 |
20 | 20 | 0 | 1 | >10, ≤24 |
author_fancy_126 |
20 | 20 | 0 | 1 | >24, ≤126 |
config/dungeontrain-server.toml:
| Key | Default | Meaning |
|---|---|---|
discoverSharedBooksEnabled |
true |
Master switch for serving community books as loot |
sharedBookRepeatCarriages |
2 (groups) |
How far behind a served-but-unread book must be before the far-behind escape re-offers it |
Discovery needs only this server master — no per-player network consent (contributing/signing a book does need consent; reading loot does not).
Community book text is written by other players, so it is untrusted in both directions: the relay
sanitizes what it serves, and the mod sanitizes again at BookFactory.buildPlainBook rather than
trusting the relay. That second layer is not belt-and-braces theatre — a compromised relay, or the
plain-HTTP DUNGEONTRAIN_RELAY_BASE_URL override, puts arbitrary text on the wire, and the
sign-book packet a client sends when a player signs their own book is untrusted regardless.
Removed (BookSafeText, mirrored by sanitizeText in the relay's books.js):
| What | Why |
|---|---|
| C0 controls, DEL, C1 | A newline in a title splits item lore onto extra lines; a NUL in an item name is a renderer hazard |
| Lone surrogates | The real crash vector — an unpaired surrogate throws when it reaches NBT's modified-UTF-8 encoder on the client |
| ZWSP (U+200B), BOM (U+FEFF) | Invisible padding; no script needs either |
| Combining-mark runs past 8 | "Zalgo" stacking, which overflows a page into the ones around it |
§ in titles and author names
|
Those strings reach item names, chat lines and tooltips — renderers belonging to the rest of the UI, not to the book |
Deliberately kept, because a book is a creative medium and most "suspicious" characters are load-bearing for somebody:
-
§formatting codes in page bodies. Colouring and styling your own book is a feature players use on purpose. The worst a page can do with it is make its own text hard to read, and that is contained to that book. - Bidi overrides, isolates and LRM/RLM marks. Right-to-left text needs them, and DT ships 19 locales — stripping them mangles legitimate Arabic and Hebrew writing. A book that reverses its own display is cosmetic and cannot escape the book.
- ZWNJ and ZWJ (U+200C, U+200D). Required by Persian, Arabic and Indic scripts, and by emoji ZWJ sequences — dropping U+200D alone breaks every family and profession emoji.
Separately, every length cap on the book path truncates through BookSafeText.clampCp, which never
cuts between the halves of a surrogate pair. Before that, an emoji straddling the title or page cap
produced a lone surrogate — the one purely-structural crash a player could trigger directly.
The relay applies the same sanitizer on serve as well as intake, so books already stored before this existed are cleaned as they are handed out; the stored rows themselves are left untouched.
| Piece | Role |
|---|---|
SharedBookSelector |
pure priority-chain selection (unit-tested) |
SharedBookPool |
the accumulating per-language pool snapshot fetched from the relay (highest weight tier per fetch, bounded) |
PlayerRunState |
per-life served-set (resets each new run/world) — drives de-dup + the far-behind escape |
SharedBookReadMirror / SharedBookReadSyncPacket
|
the global, client-side read set mirrored to the server |
ContainerContentsRoller |
bakes the pending random_playerbook placeholder at loot time |
NarrativeBookEvents |
the pickup sweep that resolves pending placeholders per player |
UnsignedBookShelfNotice / UnsignedBookMessage
|
the gray "you left it unsigned" line — written-in-and-unsigned test, five variants, 3s per-player cooldown |
ChiseledBookShelfBlockUnsignedBookMixin |
injects at the head of vanilla's private ChiseledBookShelfBlock#addBook, the one point where a player-shelved book is known to be going in |
BookSafeText |
the mod's own sanitizer + surrogate-safe clamp, applied at BookFactory.buildPlainBook (covers relay books and client-signed books alike) |
- Feature: Random Books — the bundled-JSON flavour-book track this sits alongside
- Feature: Narrative Lectern — ordered long-form player narratives
- Feature: Starting Books — the read-and-burn welcome book lifecycle
- Feature: Feedback Survey — another relay-backed community feature