Skip to content

Feature Community Books

Brennan Hatton edited this page Oct 4, 2026 · 5 revisions

Home > Features > Community Books

Community Books — player-written books as per-player chest loot

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.

What a player experiences

  • 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)

How the slot resolves

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.

The selection priority chain

Resolved by SharedBookSelector — a pure, unit-tested chain over the current pool snapshot:

  1. 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").
  2. Language bucket (hard) — books in your language (MINE) plus translations drain before other-language books (OTHER); an absent language family falls back to English.
  3. 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).
  4. 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).
  5. 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.

Read history is global & client-side

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.

Language matching

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).

Author libraries pick the author in your language

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.

Author rooms are rolled together, then fitted to the writer

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:

  1. The split is rolled first, seeded from the pair key. If the own-books boost chose the pair, the split is pinned to Self.
  2. A room that fits is chosen. "Fits" means the room's own book range (min exclusive, max inclusive, 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).
  3. 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

Configuration

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).

Text safety — what is stripped, and what deliberately is 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.

Implementation

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)

Related

Clone this wiki locally