osrlib is a Python library implementing the classic 1981 B/X (Basic/Expert) fantasy adventure game rules for turn-based, grid-based dungeon crawlers in the style of the original Bard's Tale. The rules are sourced from the Old-School Essentials SRD, an Open Game Content restatement of B/X — name the ruleset B/X and reserve the trademark for attribution, as README.md does.
docs/spec.mdis the single source of truth. Read it before any implementation work. It is decision-complete: architecture, rules scope, contracts, and a phased roadmap. Implement in phase order.srd/is the scraped OSE SRD (412 markdown files, URL-encoded filenames). It is read-only reference material and the input to the data pipeline. Never edit these files by hand — parser corrections belong intools/srd_compile/overrides/.osrlib/data/is generated bytools/srd_compile/and is never hand-edited. CI regenerates and fails on diff.- When a rules question comes up, the SRD text in
srd/is the authority. Quote it in review discussions rather than working from memory.
Work is divided into chunks that the lead role packets to coder roles, with acceptance tests written before the implementation by a different agent and committed marked expected-to-fail. The method, the roles, the packet, and the worktree rules are in the suite guide at ../CLAUDE.md, and the role definitions are in ../.claude/agents/. Each roadmap phase in docs/spec.md is a batch of chunks. The plans under docs/phase-N-plan.md are the record of phases already built.
- Python ≥ 3.14. Package management with
uvexclusively (uv add,uv sync,uv run) — neverpip. - Format with
ruff format, lint withruff check, test withpytest(not unittest). - Type hints use built-in generics (
list[str],dict[str, int]). Do not importList/Dict/Tuplefromtypingand do not usefrom __future__ import annotations. - Docstrings are Google style, written in Markdown. Maximum line length 120. That limit is a Python source rule enforced by
ruff, and it has never applied to the.mdfiles underdocs/.
osrlib is released: the public API is frozen and semantic versioning governs what a release may change, so breaking the public surface is a major-version event, not a refactor. Behind that surface, code-level backward compatibility is an anti-pattern here, not a virtue. When a better internal factoring appears, refactor to it outright and update every call site — the tests and CI are the safety net. Do the right thing even when it's the hard thing, and question existing code and the decisions behind it as the project matures — what was right then may not be right now. Specifically:
- No re-exports, aliases, or shims whose purpose is keeping an old import path working. One home per symbol; move it and fix the imports. (Precedent: the
Alignmentre-export fromcharacter.pywas cut in PR #8 review as exactly this.) - No deprecation paths,
_v2-style parallel names, or code kept "just in case" a removed path is wanted back — git history is the archive. - No parameters, flags, or branches whose only purpose is preserving a behavior nothing in the repo still uses.
- Prose that justifies a design by "so the old path still works" is the tell: if the sentence names no current consumer, the accommodation should not exist.
Do not overcorrect into breaking the compatibility contracts that are real: the public API surface (semantic versioning, promised in CHANGELOG.md), schema_version for serialized artifacts (saves, commands, events — additive-only within a version), and the determinism contract governing draw sequences and golden files. Honor those; everything else is freely refactorable.
These are contracts, not suggestions — see the corresponding spec sections before touching related code:
- Determinism. All randomness flows through named PCG64 streams from
core/rng.py. Never use therandommodule directly, never iterate sets or other order-unstable structures on an event-emitting path, and never use UUIDs for entity IDs (they are session-scoped and monotonic). - Layering.
osrlib.coremust never import fromosrlib.crawl. - Events. Structured fields plus message codes only — never baked English prose in event models. Respect
player/refereevisibility. - Commands. Validation is a pure pre-phase: a rejected command consumes no RNG draws, no clock time, and mutates nothing.
- Kernel arguments. A kernel function takes the same stance towards its own arguments: it checks them before its first draw and raises
ValueErrorfrom the call, so a refused call costs no draws, no time, and no state, and never returns a plausible-looking result built on a bad argument. - Schema discipline. Serialized models follow the
schema_versionrules: additive-only within a version; renames, removals, and semantic changes bump it. - Frozen data. SRD data models are frozen; play spawns mutable instances from templates.
- No numeric surface counts in prose. Never write "all 44 commands" or "68 events" — the registries and the generated reference pages carry the numbers, and prose counts drift the moment a surface grows.
- The one-way bridge. Common, jobs-to-be-done language locates a concept (navigation labels, headings, opening sentences); the project term is introduced once, job first ("osrlib calls this a gate"), and from that sentence on the docs commit to the term. No page maintains a parallel vocabulary.
- The voice gradient. The funnel top — README, index, quickstart, guide openings — is written plain, every sentence doing instruction; the register stays rich in guide interiors, walkthroughs, and reference prose, where the reader has bought in.
- One paragraph per line. Markdown prose is never hard-wrapped: a paragraph, a list item, or a table row is one line however long it runs, and the editor soft-wraps it. Reflowed prose turns a one-word edit into a paragraph-sized diff, which buries the change under the rewrap. The 120-character limit in the toolchain section governs Python source, not these files.
- Transcripts are captured, never composed. Any quoted program output — TUI transcripts, command output — is re-captured from a real run after every change that could affect it, and never hand-edited.
- Table fidelity tests assert against SRD values directly; golden-seed scenario tests are scoped per RNG stream.
- Run
pytestbefore committing. A change that alters golden files must explain why in the commit message.
The version lives in pyproject.toml alone and the bump procedure is: edit the version, run uv lock, and nothing else. The golden comparisons normalize the engine_version stamp, so regenerating a golden file for a version bump is a defect by definition — the goldens keep the stamp of the engine that produced them. A PR that changes user-visible behavior adds its CHANGELOG.md bullet in the same PR. The full runbook — the annotated tag that drives release.yml, the local dry run, recovery, and the versioned-docs adoption trigger — is .claude/skills/release/SKILL.md.
- SRD-derived content is Open Game Content under OGL 1.0a (Necrotic Gnome); keep the OGL text and Section 15 notice intact and attribute new SRD-derived data accordingly.
- Library code is MIT. Do not mix the two: code under MIT, compiled SRD data under OGL.