Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added

- `Adventure.monsters` — an adventure document can bundle its own custom `MonsterTemplate`s, which join the shipped catalog for that adventure's sessions everywhere the engine resolves template ids: keyed encounters, `SpawnMonsters`, inline wandering tables, listen checks, and `GameSession.spawn`. Downstream of spawn nothing changes — combat, XP, treasure, persistence, and replay carry bundled monsters unmodified. Bundled ids must not collide with the shipped catalog or each other; collisions fail `validate_adventure` (and, for doctored saves, `load_game`) with `ContentValidationError`. The session exposes the union as the read-only `GameSession.effective_monsters` property.

### Changed

- `validate_adventure` now checks inline wandering-table monster ids: an adventure whose level wandering table names a dangling monster id — previously accepted by the gate and left to crash at play time — fails validation up front.

## [1.1.0] - 2026-07-05

### Added
Expand Down
139 changes: 127 additions & 12 deletions docs/guides/authoring-custom-content.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
# Authoring custom classes and spells

The seven classes and the spell list that ship with osrlib are compiled from the OSE SRD into the
package's data files by a build pipeline — that pipeline is not the extension point, and there is no
way to feed your own content into it. What *is* supported is authoring your own class and spell
definitions in code, validating them the same way the shipped catalogs validate their own content, and
running them through the same kernel that plays a fighter or a magic-user: creation, advancement,
memorization, and casting. A class's `race` and a spell's `spell_list` are both open, validated string
ids for exactly this reason — nothing in the kernel restricts them to the values the shipped classes
happen to use. This page builds one small custom class and one custom spell for it, and [the complete
program](#the-complete-program) at the end runs every step shown along the way.
# Authoring custom classes, spells, and monsters

The seven classes, the spell list, and the monster catalog that ship with osrlib are compiled from the
OSE SRD into the package's data files by a build pipeline — that pipeline is not the extension point,
and there is no way to feed your own content into it. What *is* supported is authoring your own class,
spell, and monster definitions in code, validating them the same way the shipped catalogs validate
their own content, and running them through the same kernel that plays a fighter or a goblin: creation,
advancement, memorization, casting, and — for monsters — spawning, combat, XP, and treasure. A class's
`race` and a spell's `spell_list` are both open, validated string ids for exactly this reason — nothing
in the kernel restricts them to the values the shipped classes happen to use. This page builds one
small custom class and one custom spell for it ([the complete program](#the-complete-program) runs
every step shown along the way), then [a custom monster bundled into an
adventure](#bundling-custom-monsters-with-an-adventure) for the crawl layer.

## The shape of a class definition

Expand Down Expand Up @@ -426,12 +428,125 @@ assert cast_result.affected_ids == (wounded.id,)
assert warden.memorized_spells == ()
```

## Bundling custom monsters with an adventure

Monsters take a different transport than classes and spells, because the crawl layer already has a
document that carries content: the adventure. `Adventure.monsters` bundles your own
[`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s with the adventure document, and every
session running that adventure resolves them everywhere it resolves a shipped template id — keyed
encounters, [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters], inline wandering tables, listen
checks, and [`GameSession.spawn`][osrlib.crawl.session.GameSession.spawn]. No loader reassignment, no
registration: the document carries the content, and
[`GameSession.effective_monsters`][osrlib.crawl.session.GameSession.effective_monsters] is the shipped
catalog plus the bundle. Downstream of spawning nothing is different for a bundled monster — a spawned
[`MonsterInstance`][osrlib.core.monsters.MonsterInstance] embeds its full template, so combat, morale,
XP, treasure, saves, and replay never look the id up again.

A template is a frozen model you build with `model_validate`, exactly like the class and spell above.
Three table helpers derive the stat-block numbers the SRD would print so your creation matches the
attack matrix, the monster save bands, and the XP awards table:
[`thac0_for_hd`][osrlib.core.tables.thac0_for_hd],
[`monster_save_band_label`][osrlib.core.tables.monster_save_band_label], and
[`monster_xp`][osrlib.core.tables.monster_xp]. The one rule is the collision rule: a bundled id must
not collide with the shipped catalog or another bundled id —
[`validate_adventure`][osrlib.crawl.adventure.validate_adventure] rejects collisions outright, never
overrides (an adventure that wants a variant orc names a variant id). Note that [the monster id
index][monsters-index] documents the shipped catalog only; bundled ids live in the adventure that
carries them:

```python
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.monsters import MonsterHitDice, MonsterTemplate
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.core.tables import monster_save_band_label, monster_xp, thac0_for_hd
from osrlib.crawl.adventure import Adventure, TownSpec, validate_adventure
from osrlib.crawl.dungeon import AreaSpec, DungeonSpec, KeyedEncounter, KeyedMonster, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
from osrlib.data import load_combat_tables, load_equipment, load_monsters

# 2+1 HD with one special ability: the helpers derive THAC0 (the +1 attacks one
# HD higher), the save band, and the XP award from the printed tables.
hd = MonsterHitDice(count=2, modifier=1, asterisks=1)
thac0, attack_bonus = thac0_for_hd(hd.count, bonus_modifier=hd.modifier > 0)

BONE_WARDEN = MonsterTemplate.model_validate(
{
"id": "bone_warden",
"name": "Bone Warden",
"page": "Custom",
"ac": 5,
"ac_ascending": 14,
"hit_dice": hd.model_dump(),
"attacks": ({"attacks": ({"name": "halberd", "damage": "1d10"},)},),
"thac0": thac0,
"attack_bonus": attack_bonus,
"movement": ({"rate_feet": 60, "encounter_rate_feet": 20},),
"saves": {
"values": {"death": 12, "wands": 13, "paralysis": 14, "breath": 15, "spells": 16},
"save_as": monster_save_band_label(hd),
},
"morale": 12,
"alignment": {"options": ("chaotic",)},
"xp": monster_xp(load_combat_tables(), hd),
"number_appearing": {"dungeon": {"dice": "1d4"}, "lair": {"fixed": 1}},
"categories": ("undead",),
}
)

# Bundle it: the adventure document carries the template, and a keyed area
# references it like any shipped id.
level = LevelSpec(
number=1,
width=2,
height=1,
entrance=(0, 0),
areas=(
AreaSpec(
id="ossuary",
name="The ossuary",
cells=((1, 0),),
encounter=KeyedEncounter(monsters=(KeyedMonster(template_id="bone_warden", count_fixed=1),)),
),
),
)
adventure = Adventure(
name="The Bone Warden's Vigil",
town=TownSpec(name="Threshold"),
dungeons=(DungeonSpec(id="crypt", name="The Crypt", levels=(level,)),),
monsters=(BONE_WARDEN,),
)

# The same gate the shipped content passes — the base catalog goes in unchanged,
# and validation unions it with the bundle internally.
validate_adventure(adventure, load_monsters(), load_equipment())

rng = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hero = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=Ruleset(), stream=rng)
session = GameSession.new(Party(members=[hero.character]), adventure, seed=7)

# The session's effective catalog resolves the bundled id — the very object the
# adventure carries — and spawning embeds it in each instance.
assert session.effective_monsters.get("bone_warden") is BONE_WARDEN
guards = session.spawn("bone_warden", 2)
assert [guard.template.id for guard in guards] == ["bone_warden", "bone_warden"]
assert all(guard.max_hp >= 3 for guard in guards) # 2d8+1 rolls at least 3
```

Bundled equipment, classes, and spells have no adventure-document seam yet — monsters earned theirs
first, and the same shape is the template if a future need is demonstrated. For classes and spells the
catalog-extension pattern above is the supported path.

## What's not supported

There is no merge path into the shipped content. `load_classes` and `load_spells` are cached loaders
that read the generated `classes.json`/`spells.json` shipped inside the package; there is no append or
register call, so an extended catalog is always a value your own code builds and holds — `classes` and
`spells` above, never something fed back into the loaders themselves.
`spells` above, never something fed back into the loaders themselves. `load_monsters` is just as
closed: [bundling](#bundling-custom-monsters-with-an-adventure) unions per session through the
adventure document that carries the templates, and the shipped catalog object never changes.

[`create_character`][osrlib.core.character.create_character], the one-call convenience wrapper used in
[the quickstart](../getting-started/quickstart.md), resolves its `class_id` argument straight through
Expand Down
5 changes: 4 additions & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ A session runs an adventure, not a dungeon: the party, clock, active effects, an
- An **adventure** is one or more dungeons plus a base town and scenario metadata (name, description, hooks). The base town anchors the XP rule's "survive and return to safety" and safe day-level rest — the rules source is the XP-awarding procedure; the SRD's base-town page is a referee checklist, not mechanics. In 1.0 the town is a marker offering the services the SRD names — selling treasure (recovered gp becomes XP) and healing — plus the equipment lists; it is not a simulated town.
- A **dungeon** is one or more **levels** — the SRD's term for the deeper and deeper floors joined by stairs, trapdoors, and chutes. The level number is rules-visible: it keys the wandering encounter tables, scales unguarded treasure, and sets the danger expectation (1 HD monsters on level 1, 2 HD on level 2, and so on).
- A **level** is a grid of 10' cells — the SRD's typical mapping scale, fixed as the cell size here — with wall and door edges.
- A **keyed area** (a room or cave) is a named region over cells, matching the SRD's numbered-area convention. Areas carry content bindings — monsters, treasure, traps, specials, description IDs — and area-oriented procedures (searching, room vs. treasure traps, keyed encounters) resolve against them. Areas annotate the grid; cells remain the single source of spatial truth.
- A **keyed area** (a room or cave) is a named region over cells, matching the SRD's numbered-area convention. Areas carry content bindings — monsters, treasure, traps, specials, description IDs — and area-oriented procedures (searching, room vs. treasure traps, keyed encounters) resolve against them; an adventure may also carry custom `MonsterTemplate`s of its own, which join the shipped catalog for its sessions everywhere the engine resolves template ids, with colliding ids rejected at validation, never overridden. Areas annotate the grid; cells remain the single source of spatial truth.

Movement between levels and dungeons happens through commands like any other movement; the session persists across all of it.

Expand Down Expand Up @@ -205,6 +205,7 @@ Pipeline rules:

- Output is deterministic and diff-reviewable; regeneration is a one-command `uv run` task, and CI regenerates and fails on any diff so `srd/`, the compiler, and `osrlib/data/` cannot silently drift.
- Bad or ambiguous parses are corrected by patch files in `tools/srd_compile/overrides/`, merged after parsing with provenance recorded in the output (e.g. the dungeon encounter table's "Basic Adventures" typo for Basic Adventurers). `osrlib/data/` is never hand-edited.
- Adventure-bundled monster templates are adventure data, not SRD data: the shipped catalog stays frozen and generated, and bundled templates never merge into it — they join per-session through the adventure document that carries them.
- Every generated file validates against the typed models at build time; tests assert entry counts and spot-check known values (e.g. Troll is AC 4 [15], HD 6+3*).
- Where prose can't be mechanized (e.g. referee-judgment abilities), the data keeps the prose and a `manual` tag so games and narrators can still present it.

Expand Down Expand Up @@ -321,3 +322,5 @@ Each phase ends with working, tested, documented code.
**Phase 7 — documentation.** The docstring overhaul to shippable new-user quality: development-history language and reviewer-directed rationale purged, runnable examples on the entry points and one cross-seam quickstart (character → adventure → session → events → save), every command documenting its modes, rejection codes, and emitted events, named types with cross-references replacing duck-typed `object` prose, and module docstrings that orient. On top of it, the documentation site (mkdocs-material + mkdocstrings, strict builds) with guides and walk-throughs for both example front ends, the command/event JSON Schema reference, and the README rewrite.

**Phase 8 — release.** Release engineering — version 1.0.0, the changelog, packaging audits, the tag-driven trusted-publishing workflow — and publication to PyPI as `osrlib`.

**Phase 9 — adventure-bundled monsters.** Adventure documents carry custom monster templates, resolved everywhere the engine resolves template ids; released as 1.2.0.
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ nav:
- Determinism, saves, and replay: guides/determinism-saves-replay.md
- The kernel à la carte: guides/kernel-a-la-carte.md
- Listeners and flags: guides/listeners-and-flags.md
- Authoring custom classes and spells: guides/authoring-custom-content.md
- Authoring custom classes, spells, and monsters: guides/authoring-custom-content.md
- Ruleset options: guides/ruleset-options.md
- Front ends:
- The TUI crawler: front-ends/tui-crawler.md
Expand Down
6 changes: 4 additions & 2 deletions src/osrlib/core/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,8 +326,10 @@ def reaction_result(table: ReactionTable, total: int) -> ReactionResult:
class MonsterEncounterEntry(BaseModel):
"""An encounter-table cell resolving to monster template ids.

`monster_ids` are monster ids from [`load_monsters`][osrlib.data.load_monsters] —
see [the monster id index][monsters-index]. A single id is the common case.
`monster_ids` resolve against the session's effective catalog — shipped ids from
[`load_monsters`][osrlib.data.load_monsters] (see
[the monster id index][monsters-index]) or, in an adventure's inline wandering
table, ids the adventure bundles. A single id is the common case.
Multiple ids are a packed-variant pool (`Veteran` over `veteran_1..3`): the
tabletop table leaves the pick to the referee, so osrlib has each spawned
individual pick uniformly from the pool on the wandering stream — deterministic,
Expand Down
Loading
Loading