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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Changed

- The found-trap rule is now pinned rather than inherited, in the adaptations register and on [`TrapEvent`][osrlib.crawl.events.TrapEvent] (#48). A found *room* trap never springs, at its area's edge or at one of its doors, while a found *treasure* trap still rolls its 2-in-6 on every [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] until a thief removes it, because in B/X anyone can find and avoid a room trap and only a thief can take a treasure trap out. The consequence a front end needs is that `exploration.trap.safe` is a cache outcome only: a door past a found room trap rolls no die and so has nothing to report, and a known-trap-fails-to-fire beat rendered off that code will never fire at a door. Documentation only; no behaviour, event, or draw sequence changed.
- `commands.json` and `events.json`, the downloadable discriminated-union JSON Schemas under the command and event reference, now describe every property: each one's `description` comes from its attribute docstring, where before pydantic left it blank because it never reads that form (#103). Every `description` in both files, the model's own and each property's, also drops mkdocstrings cross-reference syntax down to the plain name it links, since a schema reaches a reader, or a tool-calling LLM, as text rather than through the site's markdown pipeline; the same cleanup applies to the JSON Schema block on each command's and event's own reference page. The API reference also gains a front page for `osrlib.core` and for `osrlib.crawl`, at the top of each layer's section, rendering the package docstring that a module without its own `__all__` never reached the site with before (#101).
- `validate_adventure` now catches two authorable configurations that saved clean but could never fire (#46, #47). An area id colliding with a feature id on the same level is rejected: the two share the trap-reference namespace `"<dungeon>:<level>:<id>"` that gates found, sprung, and removed trap state, so an untouched cache trap could read as already sprung because a room trap sharing its id had gone off. An `open`-trigger room trap on an area none of whose boundary edges is a door is rejected too, because opening a door is the only action that springs one and an area with no door edge could never roll for it; a door that `starts_open` still counts, since a referee command or a trigger can close it later. Neither check touches an `enter`-trigger trap, which springs on the party stepping into the area and needs no door at all.
- The temple now charges the party for a healing service rather than the patient (#73, part of #108). [`PurchaseHealing`][osrlib.crawl.commands.PurchaseHealing] draws the fee from the treated member's purse first and then from the other members in marching order, dead members included. Each purse pays in whole gold pieces, as much of what is still owed as its gold covers, so a purse that cannot cover the rest hands over all of its gold and keeps what it is worth below a gold piece, and the last purse charged pays the outstanding remainder alone. What the party can spend is therefore the whole gold pieces in its purses rather than their total worth, and a party whose whole gold pieces fall short is refused with `items.purchase.insufficient_funds` and keeps every coin, the funds check staying the pure pre-phase it was. Charging the patient alone made `raise_dead` unbuyable in practice: the patient is a corpse, nothing hands a corpse coin, and a party that splits its treasure never holds 1,500 gp in one purse, so the documented salvage flow (game over, `PlaceParty` to town, raise dead) could not be funded even by a party whose pooled wealth cleared the 1,500 gp fee several times over. [`HealingPurchasedEvent`][osrlib.crawl.events.HealingPurchasedEvent] gains `payers` and `payments_gp`, which name the purses charged and what each one put in, additive within the schema with empty defaults so an older log still parses, and the default English line names the other payers only when the patient could not cover the fee alone. The service names are now one exported type, [`HealingService`][osrlib.crawl.commands.HealingService], which both the command field and the `HEALING_SERVICES` price list use, so the offered list and the accepted list cannot drift apart. No draw sequence changed; the phase 5 milestone golden moved only by the two added event fields.
Expand All @@ -25,6 +26,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Fixed

- Discovering a secret door on a cell now clears that cell's `room_traps` search attempts, so every member may search the cell again (#45). A [`Search`][osrlib.crawl.commands.Search] for `room_traps` covers the searched cell's door edges, and an undiscovered secret door hides the trap beyond it along with itself, so a member who searched while the door was wall spent their one attempt with no chance at that trap. Once every member had spent theirs, the trap the door-trap rule promises is findable could only be learned by opening the door and taking the 2-in-6. The refund fires on a `secret_doors` search that finds the door and on the referee's [`SetDoorState`][osrlib.crawl.commands.SetDoorState] with `discovered=True`, and it clears nothing else: `secret_doors` and `construction` attempts on that cell, and every attempt on every other cell, stand.
- [`check_fatigue`][osrlib.crawl.exploration.check_fatigue] now attaches the unrested-fatigue penalty to each living member who lacks it, not only when nobody in the party has it yet (#100). A member who joined an already-tired party, or who lost the effect some other way, stayed unfatigued beside exhausted companions until the next [`Rest`][osrlib.crawl.commands.Rest] reset the counter. `exploration.fatigue.gained` is emitted when at least one member gained the effect on the call, and the call returns nothing when every living member already carries it, so a party that is fully fatigued still reports the check as quiet.
- A scroll read is now judged at the scroll's caster level by the crawl layer too, in exploration and in battle (#117). [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] validates and resolves at the spell's minimum caster level, while [`UseItem`][osrlib.crawl.commands.UseItem] and the `use_item` battle declaration pre-validated with the reading character, so a read the crawl accepted could raise `ValueError` out of the kernel after the scroll was already spent. The kernel now exports the pre-check itself as [`validate_scroll_cast`][osrlib.core.spells.validate_scroll_cast], which builds the scroll-level caster and returns the rejections `cast_from_scroll` would raise on; `cast_from_scroll` runs it, and so do both crawl pre-checks, so there is one statement of the rule and a front end can ask it too. The crawl therefore refuses exactly what the kernel would refuse, before any state change, draw, or event: a 6th-level magic-user reading a *magic missile* scroll is refused `magic.cast.target_count` for three targets and keeps the scroll.
- [`generate_treasure`][osrlib.core.treasure.generate_treasure], [`generate_treasure_entries`][osrlib.core.treasure.generate_treasure_entries], and [`generate_unguarded_treasure`][osrlib.core.treasure.generate_unguarded_treasure] refuse a `tier` that is neither `"basic"` nor `"expert"` before their first draw, the check [`generate_magic_item`][osrlib.core.treasure.generate_magic_item] already made. A bad tier now costs no draws and yields no hoard, where before it could return coins and gems that looked like a generated result. Valid calls draw exactly as before.
- [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] judges a read's legality at the scroll's own caster level, the level the spell resolves at, so the two things that scale with caster level follow the scroll rather than the reader: a *magic missile* scroll takes one target however high the reader's level, and a per-level range is measured from the scroll's reach. A high-level reader no longer gets a high-level cast off a 1st-level scroll.

Expand Down
2 changes: 1 addition & 1 deletion docs/adaptations.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ Any door-forcing attempt sets the noise flag for the next wandering check (the b

### Searching and traps

Searching covers the party's current cell (the 10' × 10' area is the cell), costs a turn, is once per character per cell per kind, is rolled regardless of contents, and reveals every matching hidden feature on the cell. Treasure-trap find and removal are once per trap per character each, and a failed removal springs the trap (the classic reading; RAW says only "attempted once per trap"). Found traps stop springing on movement (the party walks around the known pit); the player-facing `exploration.trap.safe` emits only for a *known* trap whose trigger resolved without springing — an unknown trap's spring die stays referee-only. A trap transition (slides) relocates the whole party — the party model has one location (pinned simplification). The darts volley resolves as one damage application whose rolls are every dart's die. `TakeTreasure`'s treasure trap springs on the character who reaches in: the command's `recipient_id` when one is named, otherwise the first living member in marching order (pinned). A room trap's `trigger` names its springing action: `enter` rolls on entering a cell of the area, `open` rolls on opening a door of the area — `OpenDoor` or a successful `ForceDoor` — with the same 2-in-6 on every opening until the one spring. A door belongs to the area from either adjoining cell (pinned: the blade over the lintel drops on the way out too), the far side rolling first should one door join two trapped areas, and each spring lands on the opener — the forcing character, else the first living member in marching order — passing to the next member standing if an earlier spring killed the opener. A trap needs a living victim: a party with no one left rolls no spring die, and a spring whose effect carries the party away from the door (a chute) cancels the springs behind it — the party is no longer performing the opening (all pinned). A `room_traps` search covers the searched cell's door edges, so the `open`-trigger trap beyond a known door is findable from the threatened side before the door is opened; an undiscovered secret door hides its trap along with itself (the no-leak doctrine), and a found door trap never springs, like the walked-around pit. A treasure trap's only springing action is opening its cache, so the model rejects `trigger="enter"` on `kind="treasure"` — schema version 3, with a lossless migration rewriting the dead value on load. Locked by `test_exploration.py::TestSearching`, `TestTreasureTraps`, `TestDoorTraps`, `TestTwoTrappedAreas`, and `TestTrapResolutionCensus`.
Searching covers the party's current cell (the 10' × 10' area is the cell), costs a turn, is once per character per cell per kind, is rolled regardless of contents, and reveals every matching hidden feature on the cell. Treasure-trap find and removal are once per trap per character each, and a failed removal springs the trap (the classic reading; RAW says only "attempted once per trap"). Found traps stop springing on movement (the party walks around the known pit); the player-facing `exploration.trap.safe` emits only for a *known* trap whose trigger resolved without springing — an unknown trap's spring die stays referee-only. Finding is worth different things by kind, pinned: a found *room* trap never springs at all, at its area's edge or at one of its doors, while a found *treasure* trap still rolls its 2-in-6 on every `TakeTreasure` until a thief removes it, because in B/X anyone can find and avoid a room trap and only a thief can take a treasure trap out. `exploration.trap.safe` is therefore a cache outcome only: a door or an area edge past a found room trap rolls no die and so has none to report. A trap transition (slides) relocates the whole party — the party model has one location (pinned simplification). The darts volley resolves as one damage application whose rolls are every dart's die. `TakeTreasure`'s treasure trap springs on the character who reaches in: the command's `recipient_id` when one is named, otherwise the first living member in marching order (pinned). A room trap's `trigger` names its springing action: `enter` rolls on entering a cell of the area, `open` rolls on opening a door of the area — `OpenDoor` or a successful `ForceDoor` — with the same 2-in-6 on every opening until the one spring. A door belongs to the area from either adjoining cell (pinned: the blade over the lintel drops on the way out too), the far side rolling first should one door join two trapped areas, and each spring lands on the opener — the forcing character, else the first living member in marching order — passing to the next member standing if an earlier spring killed the opener. A trap needs a living victim: a party with no one left rolls no spring die, and a spring whose effect carries the party away from the door (a chute) cancels the springs behind it — the party is no longer performing the opening (all pinned). A `room_traps` search covers the searched cell's door edges, so the `open`-trigger trap beyond a known door is findable from the threatened side before the door is opened; an undiscovered secret door hides its trap along with itself (the no-leak doctrine), and a found door trap never springs, like the walked-around pit. Because those two rules compose into an unfindable trap, discovering a secret door on a cell clears that cell's `room_traps` attempts, whether the discovery came from a `secret_doors` search or from the referee's `SetDoorState` with `discovered=True` (pinned: the discovery is new information about the 10' square, so a re-search is earned). `secret_doors` and `construction` attempts on that cell, and every attempt on every other cell, are untouched. A treasure trap's only springing action is opening its cache, so the model rejects `trigger="enter"` on `kind="treasure"` — schema version 3, with a lossless migration rewriting the dead value on load. Locked by `test_exploration.py::TestSearching`, `TestTreasureTraps`, `TestDoorTraps`, `TestTwoTrappedAreas`, `TestTrapResolutionCensus`, and `TestDiscoveringASecretDoorRefundsTheCellsTrapSearch`.

### A recovered haul spreads across the party: usable items, even wealth, everyone mobile

Expand Down
Loading
Loading