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

### Added

- [`osrlib.core.creature`][osrlib.core.creature] declares the three protocols a rules function reads its creature argument through: [`Creature`][osrlib.core.creature.Creature] (id, name, alignment, hit points, conditions, stat modifiers), [`Combatant`][osrlib.core.creature.Combatant] (the attack, initiative, and saving-throw numbers), and [`Caster`][osrlib.core.creature.Caster] (level, spell book, memorized spells) (#104). A [`Character`][osrlib.core.character.Character] satisfies all three and a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] the first two, structurally, so a caller passes either without a cast and annotates its own functions with the protocol that names what they read. Every public creature parameter in [`osrlib.core.combat`][osrlib.core.combat], [`osrlib.core.spells`][osrlib.core.spells], [`osrlib.core.effects`][osrlib.core.effects], and [`osrlib.core.items`][osrlib.core.items] now states the protocol the function reads through it, in place of `Any` and `object`: [`attack_roll`][osrlib.core.combat.attack_roll] takes a `Combatant` attacker and defender, [`has_condition`][osrlib.core.effects.has_condition] a `Creature`, [`cast_spell`][osrlib.core.spells.cast_spell] a `Caster` and a sequence of `Creature` values or the location strings a place-targeted spell takes, and a function that reads what one concrete type alone has takes that type, so [`resolve_breath`][osrlib.core.combat.resolve_breath] takes a `MonsterInstance` and [`sword_control_check`][osrlib.core.items.sword_control_check] a `Character`. The signature is what pyright checks a front end's call against and what the reference links. Widening `Any` to a protocol is not a breaking change, and no rule, event, or draw sequence changed.
- [`osrlib.core.creature`][osrlib.core.creature] declares the three protocols a rules function reads its creature argument through: [`Creature`][osrlib.core.creature.Creature] (id, name, alignment, hit points, conditions, stat modifiers), [`Combatant`][osrlib.core.creature.Combatant] (the attack, initiative, and saving-throw numbers), and [`Caster`][osrlib.core.creature.Caster] (level, spell book, memorized spells) (#104). A [`Character`][osrlib.core.character.Character] satisfies all three and a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] the first two, structurally, so a caller passes either without a cast and annotates its own functions with the protocol that names what they read. Every public creature parameter in [`osrlib.core.combat`][osrlib.core.combat], [`osrlib.core.spells`][osrlib.core.spells], [`osrlib.core.effects`][osrlib.core.effects], and [`osrlib.core.items`][osrlib.core.items] now states the protocol the function reads through it, in place of `Any` and `object`: [`attack_roll`][osrlib.core.combat.attack_roll] takes a `Combatant` attacker and defender, [`has_condition`][osrlib.core.effects.has_condition] a `Creature`, [`cast_spell`][osrlib.core.spells.cast_spell] a `Caster` and a sequence of `Creature` values or the location strings a place-targeted spell takes, and a function that reads what one concrete type alone has takes that type, so [`resolve_breath`][osrlib.core.combat.resolve_breath] takes a `MonsterInstance` and [`sword_control_check`][osrlib.core.items.sword_control_check] a `Character`. The signature is what pyright checks a front end's call against and what the reference links. Runtime behaviour is unchanged, no rule, event, or draw sequence moved, and what changes is what a type checker accepts: a caller that passed an object without the protocol's attributes now fails its own pyright run. [`GameSession.combatant`][osrlib.crawl.session.GameSession.combatant] returns `MonsterInstance | Character | None` rather than `object | None`, so a front end can hand its result straight to a rules function.
- [`StreamName`][osrlib.core.rng.StreamName] is the one home for every RNG stream key the library draws from, and each public `*_STREAM` constant takes its value from the matching member, so `COMBAT_STREAM` and `StreamName.COMBAT` are one object (#106). Name a stream through the enum rather than writing its key out: a misspelled key raises nothing, because it forks a stream of its own and draws plausible numbers from it, which is the one defect the determinism contract cannot catch. `StreamName` is a `StrEnum`, so a member is its own string and the values are unchanged: [`RngStreams`][osrlib.core.rng.RngStreams] keys by string, a save file records the same keys, and seed material and draw order are untouched.
- [`MoraleCheckedEvent`][osrlib.core.events.MoraleCheckedEvent]`.held` states whether the side keeps fighting, and [`check_morale`][osrlib.core.combat.check_morale] fills it on every code. `combat.morale.exempt` covers both exemptions, so a front end that wants to tell a side that never fights from one that never breaks reads `held` instead of pairing the code with the score. The default formatter's `combat.morale.exempt` line reads it, and falls back to the score for a log written before schema 4, where the field defaults `None`.
- The API reference has a front page for `osrlib.core` and for `osrlib.crawl`, at the top of each layer's section, rendering that package's docstring, so the crawl package's end-to-end program is on the published site (#101).
Expand All @@ -17,7 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Changed

- [`RefereeView`][osrlib.crawl.views.RefereeView] has one typed field per group a save keeps, each built from the session's own model, so a front end reads `view.monsters[0].current_hp` and `view.flags["key"]` with the types this reference documents instead of indexing a dict of raw JSON by hand (#105). The fields are the groups [`session_state`][osrlib.persistence.session_state] writes, minus `master_seed` and `rng_streams`, so `view.model_dump(mode="json")` is that save payload without the two withheld keys, which is what keeps the two layouts from drifting apart. `view.command_log` and `view.event_log` contain the [`Command`][osrlib.crawl.commands.Command] and [`Event`][osrlib.core.events.Event] subclasses that were issued and emitted, each with its own fields, and an entry a save carried as a raw mapping for an event type this library has no class for stays the mapping it arrived as. The counters a save writes under `exploration` are [`ExplorationCounters`][osrlib.crawl.views.ExplorationCounters], named as the session names them. The view is a snapshot: it contains copies of the session's mutable state, so play going on afterwards leaves it as it was, and building one still reads the session and changes nothing. Persistence is untouched, so `session_state`, [`save_game`][osrlib.persistence.save_game], and every `schema_version` stand as they were.
- [`RefereeView`][osrlib.crawl.views.RefereeView] has one typed field per group a save keeps, each built from the session's own model, so a front end reads `view.monsters[0].current_hp` and `view.flags["key"]` with the types this reference documents instead of indexing a dict of raw JSON by hand (#105). The fields are the groups [`session_state`][osrlib.persistence.session_state] writes, minus `master_seed` and `rng_streams`, so `view.model_dump(mode="json")` is that save payload without the two withheld keys, which is what keeps the two layouts from drifting apart. `view.command_log` and `view.event_log` contain the [`Command`][osrlib.crawl.commands.Command] and [`Event`][osrlib.core.events.Event] subclasses that were issued and emitted, each with its own fields, and an entry a save carried as a raw mapping for an event type this library has no class for stays the mapping it arrived as. The counters a save writes under `exploration` are [`ExplorationCounters`][osrlib.crawl.views.ExplorationCounters], named as the session names them. The view is a snapshot: it contains copies of the session's mutable state, so play going on afterwards leaves it as it was, and building one still reads the session and changes nothing. This change touches no persistence path: `session_state` and [`save_game`][osrlib.persistence.save_game] are as they were, and the view moves no `schema_version`.
- The temple 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, because the check runs before any purse is opened. This is what makes `raise_dead` buyable: the patient is a corpse, nothing hands a corpse coin, and the documented salvage flow (game over, `PlaceParty` to town, raise dead) is funded from the party's pooled wealth. [`HealingPurchasedEvent`][osrlib.crawl.events.HealingPurchasedEvent] carries `payers` and `payments_gp`, which name the purses charged and what each one put in; both default empty, 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 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.
- [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] rejects two authorable configurations that save clean and can 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 one trap's spring would stand for the other's, and an untouched cache could read as already sprung. 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. An author fixes either shape before the adventure passes validation.
- The found-trap rule is 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.
Expand Down
8 changes: 5 additions & 3 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ osrlib/
│ │ # petrification, location-bound areas (oil pools, webs)
│ ├── monsters.py # monster stat blocks, special-ability & category tags,
│ │ # NA/treasure refs
│ ├── creature.py # Creature, Combatant, and Caster: the protocols the rules read
│ │ # a character or a monster instance through
│ ├── items.py # weapons (qualities, materials), armour, gear, magic items,
│ │ # encumbrance
│ ├── treasure.py # treasure types A–V, hoard generation, coin/gem/magic rolls
Expand Down Expand Up @@ -122,7 +124,7 @@ for event in result.events:
view = session.view(Visibility.PLAYER)
```

The player view is a safe projection: party status, explored map cells, known active effects, active quests (id, name, display narrative with the block's speaker attribution beside it, since a wire client holds no adventure document to resolve one from, and visible objectives with their ids, display names, and states), and the journal. It never contains unexplored geometry, trap locations, monster HP, referee-only roll outcomes, session flags, hidden objectives, gate or trigger wiring, or the seed. `Visibility.REFEREE` returns everything, for LLM referees, debugging, and tests.
The player view is a safe projection: party status, explored map cells, known active effects, active quests (id, name, display narrative with the block's speaker attribution beside it, since a wire client holds no adventure document to resolve one from, and visible objectives with their ids, display names, and states), and the journal. It never contains unexplored geometry, trap locations, monster HP, referee-only roll outcomes, session flags, hidden objectives, gate or trigger wiring, or the seed. `Visibility.REFEREE` returns everything, for LLM referees, debugging, and tests: a `RefereeView` with one typed field per group the save keeps, whose JSON dump is the save payload minus the master seed and the RNG stream states.

Full game state, referee-visibility events, and the master seed are server-side secrets: a backend forwards views and player-visible events to clients, never raw state.

Expand Down Expand Up @@ -277,7 +279,7 @@ The kernel implements B/X procedures rules-as-written:
- Equipment destruction when death comes from a destructive attack (lightning bolt, dragon breath), with magic items saving to survive on the owner's save values plus the item's own combat bonus (the SRD makes this save referee-optional; it defaults on behind a `Ruleset` flag)
- Energy drain: level loss as the symmetric counterpart of leveling — HP, saves, THAC0, and spell slots recomputed, XP set halfway between the old and new levels, and the drained-to-zero terminal state
- Morale (2d6 vs ML at first death and half-side casualties; ML 2 or less never fights, ML 12 or more never checks, two passed checks end checking), NPC/monster reaction (2d6, CHA-modified)
- Spell memorization and casting for divine and arcane casters; spell disruption (a declared caster who is hit or fails a save before acting loses the spell as if cast); turning undead
- Spell memorization and casting for divine and arcane casters; spell disruption (a declared caster who is hit or fails a save before acting loses the spell as if cast, and so does a caster whose declaration the magic phase judges again and refuses); turning undead
- Thief skills as a percentile subsystem (climb sheer surfaces, find/remove treasure traps, hide in shadows, move silently, open locks, pick pockets; hear noise on d6), plus back-stab, read languages, and scroll use
- Demi-human class abilities: infravision, detection checks (secret doors, construction tricks, room traps), ghoul-paralysis immunity, halfling hiding (2-in-6 in dungeons, 90% outdoors) and missile/AC/initiative bonuses
- Monster category tags consumed by targeting and effects: person (human-like, up to 4+1 HD — the *charm person*/*hold person* domain), undead (turning target, mind-effect immunities, exempt from *sleep*), enchanted. *Sleep* itself is not person-limited: it takes any living creature within its HD bounds
Expand All @@ -300,7 +302,7 @@ The crawl framework implements the dungeon adventuring procedures:
- Area-of-effect resolution against that combat space: geometric shapes map deterministically to groups and party ranks (how many of a group a 20'-radius *fire ball* catches, which ranks a breath weapon reaches), including friendly fire when an area overlaps a melee — a documented adaptation with `Ruleset` knobs
- Monster actions resolve through a pluggable action policy: the default follows scripted patterns where the SRD defines them (a dragon opens with breath, then breath or melee with equal chance, three breaths per day) and otherwise picks attacks by range; games and LLM referees can substitute a policy per encounter side
- Evasion and pursuit: evasion only before combat begins, speed comparison, pursuit in rounds at running speed, dropped-treasure and food distractions (3-in-6 for intelligent monsters), running exhaustion after 30 rounds (−2 to attacks, damage, and AC until rested 3 turns)
- Battle state machine wrapping kernel combat: declared spells tracked for disruption, morale checks, fleeing and pursuit outcomes, victory/TPK
- Battle state machine wrapping kernel combat: declared spells tracked for disruption, a cast or scroll-read declaration judged again in the magic phase before it resolves, a fighting withdrawal or retreat legal only when every declarer makes it, morale checks, fleeing and pursuit outcomes, victory/TPK
- XP awarded at adventure end per RAW (survive and return to safety), with an immediate-award `Ruleset` adaptation for continuous CRPG play

Out of scope for 1.0 (tracked for later): wilderness and sea adventuring, strongholds and domain play, hirelings/retainers as full NPCs, magical research, procedural dungeon generation (the SRD stocking tables ship as data; the generator that consumes them comes later), Advanced Fantasy content.
Expand Down
5 changes: 3 additions & 2 deletions src/osrlib/crawl/battle.py
Original file line number Diff line number Diff line change
Expand Up @@ -2098,8 +2098,9 @@ def _party_magic(session, by_member, pending_casters, disrupted, acted, state) -
`magic.cast.disrupted` rather than `magic.cast.fizzled`. Disruption is the blow that landed, and it
is the outcome the table saw.

Every other declaration is judged again immediately before it resolves, with the checks it passed at
the top of the round, and one that now fails any of them fizzles instead of reaching the kernel.
A cast, and a scroll read inside `_resolve_scroll_cast`, is judged again immediately before it resolves,
with the checks it passed at the top of the round, and one that now fails any of them fizzles instead of
reaching the kernel. A device use and a turning are not judged again.
"""
events: list[Event] = []
for member, declaration in by_member.values():
Expand Down
2 changes: 1 addition & 1 deletion src/osrlib/crawl/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1042,7 +1042,7 @@ def registry(self) -> dict[str, Any]:
entities.update(self.npcs)
return entities

def combatant(self, combatant_id: str) -> object | None:
def combatant(self, combatant_id: str) -> MonsterInstance | Character | None:
"""Return the monster or NPC adventurer with this id, or `None`.

An [`EncounterGroup`][osrlib.crawl.encounter.EncounterGroup] holds ids that can be either,
Expand Down
Loading