From caa4efdab50823f5f3a23074c173607e2efde729 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 22:05:30 -0700 Subject: [PATCH 1/2] Rewrite the core effects and events API reference Every public member of osrlib.core.effects and osrlib.core.events now answers what it does, why you would call it, what you need first, and what to call next, and every pydantic field and enum member has an attribute docstring so its rendered row is not a bare name. Examples run under the docs harness. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/core/effects.py | 1130 ++++++++++++++++++++++++++++-------- src/osrlib/core/events.py | 895 ++++++++++++++++++++++------ 2 files changed, 1625 insertions(+), 400 deletions(-) diff --git a/src/osrlib/core/effects.py b/src/osrlib/core/effects.py index 6073ae7..ad9fadd 100644 --- a/src/osrlib/core/effects.py +++ b/src/osrlib/core/effects.py @@ -1,31 +1,71 @@ -"""Named conditions and the effect lifecycle engine. - -This module ships in two layers. The condition layer — -[`Condition`][osrlib.core.effects.Condition] and -[`ActiveCondition`][osrlib.core.effects.ActiveCondition] — is pure vocabulary: -creatures carry a tuple of active conditions so a serialized creature is honest on its -own. The engine layer — [`EffectsLedger`][osrlib.core.effects.EffectsLedger] — owns -durations, periodic ticks, expiry, and stacking, and is the *single writer* of -creature conditions: combat reads conditions locally, and only the engine's helpers -(plus the kernel's death routine, for `dead`) mutate them. - -At each round boundary, expirations resolve before ticks, and simultaneous effects -resolve in attachment order, tie-broken by effect id. While a target is petrified, its -other attached effects suspend — no ticks, durations frozen — so a poisoned, -petrified adventurer is a problem for after *stone to flesh*. - -Effect-internal randomness (revival delays, onset dice, duration dice) draws from the -[`EFFECTS_STREAM`][osrlib.core.effects.EFFECTS_STREAM] stream, so battle-resolution -draws never shift effect draws and vice versa. - -Part of the core kernel. Start with -[`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach] to apply a spell or -ability's effect to a creature, item, or location, and with -[`grant_condition`][osrlib.core.effects.grant_condition] / -[`remove_condition`][osrlib.core.effects.remove_condition] for condition changes -outside the ledger's timed lifecycle, as the kernel's death routine does for `dead`. -The `target`/`registry` parameters below are duck-typed per the combatant convention -(see [`osrlib.core.combat`][osrlib.core.combat]). +"""Attach timed effects to creatures, items, and locations, and run them against the game clock. + +Three kinds of caller reach this module. A [`GameSession`][osrlib.crawl.session.GameSession] calls +[`EffectsLedger.advance`][osrlib.core.effects.EffectsLedger.advance] every time it moves the game clock, which is +what makes durations run out and periodic effects fire. [`cast_spell`][osrlib.core.spells.cast_spell] calls +[`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach] when a cast lands, so a spell's printed +duration becomes a live effect. You call both yourself when you run the rules without a session: you keep the +ledger, the clock, and the registry, and you advance them in your own loop. + +The module has two layers. The condition layer is vocabulary. A condition is a named state a creature is in, +like `asleep` or `petrified`, and [`Condition`][osrlib.core.effects.Condition] is the closed set of them. Each +creature has its own tuple of [`ActiveCondition`][osrlib.core.effects.ActiveCondition] records and its own tuple +of [`ActiveModifier`][osrlib.core.effects.ActiveModifier] records, so a serialized creature says what is wrong +with it without the ledger beside it, and combat reads both tuples directly through +[`has_condition`][osrlib.core.effects.has_condition] and the `modifier_` helpers below. + +The engine layer is [`EffectsLedger`][osrlib.core.effects.EffectsLedger], which runs durations, periodic ticks, +expiry, and stacking. It is the only writer of a creature's conditions and modifiers, apart from +[`grant_condition`][osrlib.core.effects.grant_condition], +[`remove_condition`][osrlib.core.effects.remove_condition], and [`kill`][osrlib.core.effects.kill], which handle +the states no timed effect owns. Go through those helpers rather than assigning to `creature.conditions` +yourself, or a creature ends up with a condition that nothing will ever take away. + +Every round boundary resolves in a fixed order: effects suspend first, then expirations, then ticks, and within +each phase effects resolve in attachment order, tie-broken by effect id. A creature petrified by one effect +suspends its other effects, which neither tick nor age while the stone lasts, so an adventurer who was poisoned +before being turned to stone is still poisoned after *stone to flesh*. + +Effect-internal randomness (rolled durations, onset delays, a troll's revival countdown) draws from the stream +named by [`EFFECTS_STREAM`][osrlib.core.effects.EFFECTS_STREAM], so adding a draw to combat never shifts an +effect's roll. + +The `target`, `combatant`, and `registry` parameters below are duck-typed: any object with the attributes the +call reads works, and in play that means a [`Character`][osrlib.core.character.Character] or a +[`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. + +Typical usage: + +```python +from osrlib.core.clock import GameClock, TimeUnit +from osrlib.core.effects import EFFECTS_STREAM, Condition, EffectDefinition, EffectsLedger, has_condition +from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster +from osrlib.core.rng import RngStreams +from osrlib.data import load_monsters + +streams = RngStreams(master_seed=3) +goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=streams.get(MONSTER_SPAWN_STREAM)) +registry = {"monster-0001": goblin} + +ledger = EffectsLedger() +clock = GameClock() +sleep = EffectDefinition( + kind="sleep", + duration_unit=TimeUnit.TURN, + duration_amount=4, + condition=Condition.ASLEEP, + dispellable=True, +) +effect, events = ledger.attach(sleep, "monster-0001", clock=clock, allocator=IdAllocator(), registry=registry) +assert [event.code for event in events] == ["effects.effect.attached", "effects.condition.gained"] +assert has_condition(goblin, Condition.ASLEEP) + +# Four turns later the duration runs out and the ledger takes the condition back. +expiry = ledger.advance(clock, 4, TimeUnit.TURN, registry, stream=streams.get(EFFECTS_STREAM)) +assert [event.code for event in expiry] == ["effects.effect.expired", "effects.condition.removed"] +assert not has_condition(goblin, Condition.ASLEEP) +assert ledger.effects == [] +``` """ from collections.abc import Mapping @@ -73,7 +113,14 @@ ] EFFECTS_STREAM = "effects" -"""Stream key convention for effect-internal draws: durations, onsets, revivals.""" +"""The random-number stream name for effect-internal draws: rolled durations, onsets, and revival countdowns. + +Build an [`RngStreams`][osrlib.core.rng.RngStreams] from your session's master seed and pass +`streams.get(EFFECTS_STREAM)` wherever [`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach] and +[`EffectsLedger.advance`][osrlib.core.effects.EffectsLedger.advance] ask for a stream. Each subsystem draws from +its own named stream, so an extra attack roll never shifts the round on which a charmed creature saves itself +free. +""" _ROUNDS_PER_UNIT: dict[TimeUnit, int] = { TimeUnit.ROUND: 1, @@ -83,67 +130,173 @@ class Condition(StrEnum): - """The named conditions. - - The wire values are lowercase — they serialize into creatures and saves; changing - them is a `schema_version` bump. Combat hooks exist for the subset the kernel - consumes (paralysed, asleep, blind, averted_eyes, petrified, poisoned, diseased, - dead; the silenced/feebleminded/weakened casting gates, the weakened attack - gate, and the entangled movement predicate); the rest are additive-safe - vocabulary — `afraid`, `turned`, `confused`, and `invisible` are marker states - consumed by the battle machine and by games. + """The closed set of named states a creature can be in. + + Read a creature's conditions with [`has_condition`][osrlib.core.effects.has_condition]. Put one on a creature + by attaching an effect that brings it, through + [`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach], so the ledger takes it away again when the + duration runs out. Reach for [`grant_condition`][osrlib.core.effects.grant_condition] only for a state no + timed effect owns. + + Some members drive rules in [`osrlib.core.combat`][osrlib.core.combat] and + [`osrlib.core.spells`][osrlib.core.spells], and the rest are vocabulary the rest of the game acts on. The + member docstrings below say which is which, so you know whether granting one changes a roll or only tells + your interface what to show. A condition a creature's template lists in its `condition_immunities` is never + granted to it. + + The values are the lowercase strings, and they serialize into creatures and saved games. A renamed value is a + `schema_version` bump, not an edit. """ PARALYSED = "paralysed" + """Frozen in place. The creature cannot attack, cast, or move, it counts toward a side's morale check for + half the side being incapacitated, and a melee attack against it hits automatically.""" + ASLEEP = "asleep" + """Unconscious. Everything `paralysed` does, and one more rule of its own: a melee hit with a bladed weapon + kills the sleeper outright, with no damage roll.""" + BLIND = "blind" + """Unable to see. [`validate_attack`][osrlib.core.combat.validate_attack] rejects the creature's attacks with + `combat.attack.attacker_blind`.""" + CHARMED = "charmed" + """Under a charm. Nothing in the core rules reads it: the charmed creature's obedience is yours to play out, + and the periodic save that can end the charm rides the effect's tick instead.""" + PETRIFIED = "petrified" + """Turned to stone. Everything `paralysed` does, and it suspends the creature's other effects, which neither + tick nor age until the stone is undone. Stone is not dead, so the creature is recoverable.""" + DISEASED = "diseased" + """Sick with a disease. Magical healing is refused outright, and natural rest heals on the slower cadence the + effect names, or not at all when you pass no ledger to + [`natural_healing`][osrlib.core.combat.natural_healing].""" + EXHAUSTED = "exhausted" + """Spent from a forced march or a night without rest. The penalties ride the effect's modifiers rather than + the condition, so nothing in the core rules reads the condition and your interface can show it.""" + LYCANTHROPY_INCUBATION = "lycanthropy_incubation" + """Infected by a lycanthrope's bite and not yet transformed. Vocabulary only: nothing in the core rules + grants it or reads it, and the transformation is yours to run.""" + AVERTED_EYES = "averted_eyes" + """Fighting with eyes turned away from a gaze attack. [`resolve_gaze`][osrlib.core.combat.resolve_gaze] skips + the creature, and the attack penalty for fighting blind is yours to pass in the attack context.""" + POISONED = "poisoned" + """Poisoned. Vocabulary only: nothing in the core rules reads it, because a poison's bite is its effect, whose + `expiry` of `death` kills when the onset runs out.""" + DEAD = "dead" + """Killed. Granted by [`kill`][osrlib.core.effects.kill] rather than by any effect, so its `effect_id` is + `None`. It blocks acting and healing, the battle machine passes the creature over when it picks targets, and + only a spell that removes the condition brings the creature back.""" + SILENCED = "silenced" + """Unable to speak. [`validate_cast`][osrlib.core.spells.validate_cast] rejects the creature's casting with + `magic.cast.caster_incapacitated`.""" + ENTANGLED = "entangled" + """Held fast, as by a *web*. [`cannot_move`][osrlib.core.combat.cannot_move] reports True, and the creature + can still attack and cast.""" + AFRAID = "afraid" + """Panicked by a fear effect. Nothing in the core rules reads it. The battle machine in + [`osrlib.crawl.battle`][osrlib.crawl.battle] treats the creature as routed.""" + FEEBLEMINDED = "feebleminded" + """Robbed of the wit to cast. [`validate_cast`][osrlib.core.spells.validate_cast] rejects the creature's + casting with `magic.cast.caster_incapacitated`.""" + INVISIBLE = "invisible" + """Unseen. Nothing in the core rules reads it. The battle machine leaves the creature out of the ranks an + enemy picks targets from.""" + TURNED = "turned" + """Driven off by a cleric's turning. Nothing in the core rules reads it. The battle and encounter machines + treat the creature as fleeing.""" + CONFUSED = "confused" + """Acting at random. Nothing in the core rules reads it. The battle machine chooses the creature's action + instead of letting you choose.""" + WEAKENED = "weakened" + """Drained of strength. It blocks attacking, blocks casting, and blocks all healing.""" class ActiveCondition(BaseModel): - """A condition a creature currently has, with the effect that owns it. - - `effect_id` is `None` only for conditions no ledger effect owns: `dead`, written by - the kernel's death routine — death is a kernel outcome, not a timed effect. + """One condition a creature currently has, paired with the effect that owns it. + + You read these off a creature's `conditions` tuple rather than building them: + [`grant_condition`][osrlib.core.effects.grant_condition] and + [`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach] put them there. To ask whether a creature + has a condition without caring which effect granted it, call + [`has_condition`][osrlib.core.effects.has_condition] instead of scanning the tuple. + + The pairing is what lets two effects grant the same condition and each take back only its own: a creature + charmed twice has two records, and releasing one leaves the other standing. Records compare by value, so + the same condition from the same effect is never stored twice. + + Examples: + ```python + from osrlib.core.effects import ActiveCondition, Condition + + active = ActiveCondition(condition=Condition.ASLEEP, effect_id="effect-0001") + assert active.condition is Condition.ASLEEP + assert active.model_dump(mode="json") == {"condition": "asleep", "effect_id": "effect-0001"} + ``` """ model_config = ConfigDict(frozen=True) condition: Condition + """The condition the creature has.""" + effect_id: str | None = None + """The id of the [`ActiveEffect`][osrlib.core.effects.ActiveEffect] that granted the condition and will take + it back. `None` marks a condition no timed effect owns, which in the core rules means `dead`.""" def _int_param(params: Mapping[str, Any], key: str, default: int = 0) -> int: - """Read an integer param — schema-validated data whose union the checker can't key by name.""" + """Read an integer param. The params are schema-validated, but the checker can't key their union by name.""" return int(params.get(key, default)) def has_condition(target: Any, condition: Condition) -> bool: - """Return whether a creature currently has `condition`. + """Return whether a creature currently has a condition. + + This is the read side of the condition layer, and the call combat itself makes. Use it wherever your code + asks "is this creature asleep", instead of scanning the creature's `conditions` tuple, so a creature with + the same condition from two effects still reads as having it once. + + It doesn't care which effect granted the condition. When you need that, read the creature's `conditions` + tuple of [`ActiveCondition`][osrlib.core.effects.ActiveCondition] records directly. Args: - target: The creature to check: a [`Character`][osrlib.core.character.Character] - or a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] — any object - carrying a `conditions` tuple works. + target: The creature to check. Any object with a `conditions` tuple works, and an object without one + reads as having no conditions. condition: The condition to look for. Returns: - True when any active condition matches. + True when the creature has that condition from any source. + + Examples: + ```python + from osrlib.core.effects import Condition, grant_condition, has_condition + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.data import load_monsters + + streams = RngStreams(master_seed=5) + spawn = streams.get(MONSTER_SPAWN_STREAM) + goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn) + assert not has_condition(goblin, Condition.AFRAID) + + grant_condition(goblin, Condition.AFRAID, "effect-0001") + assert has_condition(goblin, Condition.AFRAID) + ``` """ return any(active.condition is condition for active in getattr(target, "conditions", ())) @@ -154,22 +307,51 @@ def _entity_id(target: Any) -> str: def grant_condition(target: Any, condition: Condition, effect_id: str | None) -> list[Event]: - """Grant a condition to a creature — the single-writer mutation point. + """Put a condition on a creature and return the event that says so. + + Call this for a state no timed effect owns, the way [`kill`][osrlib.core.effects.kill] does for `dead`. When + the state has a duration, put the condition on an + [`EffectDefinition`][osrlib.core.effects.EffectDefinition] and attach that with + [`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach] instead, and the ledger takes the + condition back on its own when the duration runs out. A condition granted here stays until you call + [`remove_condition`][osrlib.core.effects.remove_condition] with the same `effect_id`. + + The call replaces the creature's `conditions` tuple, so pass a live creature rather than a copy, and append + the returned events to whatever log your caller is building. - A condition the creature is immune to (its defenses' `condition_immunities`) is - not granted and nothing is emitted; duplicate grants from the same effect are - no-ops. + Two cases grant nothing and return no events. A creature whose template lists the condition in its + defenses' `condition_immunities` is never affected, which is how a skeleton shrugs off *sleep*. A second + grant of the same condition from the same effect changes nothing, because the creature already has that + record. Args: - target: The creature to grant the condition to: a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]; its - `conditions` tuple is replaced. + target: The creature to affect. Its `conditions` tuple is replaced in place. condition: The condition to grant. - effect_id: The owning effect, or `None` for `dead`. + effect_id: The id of the effect that owns the condition and will take it back, or `None` for a state no + effect owns. Returns: - The condition-gained event, or nothing when immune or duplicate. + A list of one [`ConditionGainedEvent`][osrlib.core.events.ConditionGainedEvent], or an empty list when + the creature is immune or already has the same record. + + Examples: + ```python + from osrlib.core.effects import Condition, grant_condition, has_condition + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.data import load_monsters + + streams = RngStreams(master_seed=5) + spawn = streams.get(MONSTER_SPAWN_STREAM) + goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn) + + events = grant_condition(goblin, Condition.AFRAID, "effect-0001") + assert [event.code for event in events] == ["effects.condition.gained"] + assert has_condition(goblin, Condition.AFRAID) + + # The same grant a second time changes nothing and says nothing. + assert grant_condition(goblin, Condition.AFRAID, "effect-0001") == [] + ``` """ defenses = getattr(getattr(target, "template", None), "defenses", None) if defenses is not None and condition in defenses.condition_immunities: @@ -182,18 +364,46 @@ def grant_condition(target: Any, condition: Condition, effect_id: str | None) -> def remove_condition(target: Any, condition: Condition, effect_id: str | None) -> list[Event]: - """Remove the condition owned by `effect_id` from a creature. + """Take back the condition one effect granted, and return the event that says so. + + This is the other half of [`grant_condition`][osrlib.core.effects.grant_condition], and it matches on the + pair: the condition and the `effect_id` you granted it under. Pass the same `effect_id` you granted with, or + nothing is removed. A creature charmed by two effects keeps the second charm after you remove the first, + which is the point of recording the owner. + + You call this for conditions you granted yourself. A condition that came from an attached effect is taken + back for you when the effect expires or you release it through + [`EffectsLedger.release`][osrlib.core.effects.EffectsLedger.release]. Args: - target: The creature to remove the condition from: a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]; its - `conditions` tuple is replaced. - condition: The condition to remove. - effect_id: The owning effect (`None` for `dead`). + target: The creature to affect. Its `conditions` tuple is replaced in place. + condition: The condition to take back. + effect_id: The id the condition was granted under, or `None` for a state no effect owns. Returns: - The condition-removed event, or nothing when the creature didn't have it. + A list of one [`ConditionRemovedEvent`][osrlib.core.events.ConditionRemovedEvent], or an empty list when + the creature has no matching record. + + Examples: + ```python + from osrlib.core.effects import Condition, grant_condition, has_condition, remove_condition + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.data import load_monsters + + streams = RngStreams(master_seed=5) + spawn = streams.get(MONSTER_SPAWN_STREAM) + goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn) + grant_condition(goblin, Condition.AFRAID, "effect-0001") + + # A different owner removes nothing. + assert remove_condition(goblin, Condition.AFRAID, "effect-0002") == [] + assert has_condition(goblin, Condition.AFRAID) + + events = remove_condition(goblin, Condition.AFRAID, "effect-0001") + assert [event.code for event in events] == ["effects.condition.removed"] + assert not has_condition(goblin, Condition.AFRAID) + ``` """ active = ActiveCondition(condition=condition, effect_id=effect_id) if active not in target.conditions: @@ -203,7 +413,7 @@ def remove_condition(target: Any, condition: Condition, effect_id: str | None) - def _grant_modifiers(target: Any, specs: tuple[ModifierSpec, ...], effect_id: str) -> None: - """Grant an effect's stat modifiers — the single-writer mutation point.""" + """Grant an effect's stat modifiers. This is the one place they're written.""" if not hasattr(target, "stat_modifiers"): return granted = tuple(ActiveModifier(**spec.model_dump(), effect_id=effect_id) for spec in specs) @@ -220,19 +430,49 @@ def _remove_modifiers(target: Any, effect_id: str) -> None: def kill(target: Any, *, permanent: bool = False) -> list[Event]: - """Kill a creature: hit points to 0, the `dead` condition, and the death event. + """Kill a creature outright: hit points to zero, the `dead` condition, and the death events. + + B/X kills a creature the moment it is reduced to zero hit points or fewer, and + [`deal_damage`][osrlib.core.combat.deal_damage] calls this for you when damage takes a creature that far. + Call it yourself for a death that skips the damage pipeline: a failed save against *finger of death*, a + delayed poison whose onset ran out, a creature you're removing from play by fiat. - "A character or monster reduced to 0 hit points or less is killed." Idempotent — - a creature already dead emits nothing. + Death is granted here rather than through an effect, so the `dead` condition has no `effect_id`. Calling + twice is safe: a creature that's already dead returns no events and isn't killed again. Args: - target: The creature to kill: a [`Character`][osrlib.core.character.Character] - or a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. - permanent: True for a regenerating creature's permanent death (the troll's - non-regenerable ledger reaching max HP). + target: The creature to kill. Its `current_hp` and `conditions` are written in place. + permanent: True when a regenerating creature can no longer come back, which for a troll means its + non-regenerable damage has reached its maximum hit points. It changes the death event's code, not the + outcome. Returns: - The death, condition, and referee hit-point events. + The [`ConditionGainedEvent`][osrlib.core.events.ConditionGainedEvent] for `dead`, the + [`DeathEvent`][osrlib.core.events.DeathEvent], and the referee-visible + [`HitPointsReportedEvent`][osrlib.core.events.HitPointsReportedEvent], in that order. An empty list when + the creature was already dead. + + Examples: + ```python + from osrlib.core.effects import Condition, has_condition, kill + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.data import load_monsters + + streams = RngStreams(master_seed=5) + spawn = streams.get(MONSTER_SPAWN_STREAM) + goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn) + + events = kill(goblin) + assert [event.code for event in events] == [ + "effects.condition.gained", + "combat.death.died", + "combat.state.hit_points", + ] + assert goblin.current_hp == 0 + assert has_condition(goblin, Condition.DEAD) + assert kill(goblin) == [] # already dead + ``` """ if has_condition(target, Condition.DEAD): return [] @@ -267,43 +507,95 @@ def kill(target: Any, *, permanent: bool = False) -> list[Event]: "magical_healing_half", } ) -"""The closed vocabulary of stat-modifier kinds combat consults. - -`ac_bonus`, `strength_set`, and the damage multipliers serve the magic items: -`ac_bonus` improves AC by its value (descending down, ascending up), `ac_set` -sets it outright, `strength_set` replaces the STR score combat modifiers derive from -(Gauntlets of Ogre Power's 18, the Ring of Weakness's 3), and the multipliers double -weapon damage (giant strength) or melee damage only (growth) after the roll. +"""The closed set of statistic names a modifier can adjust. + +These are the only values a [`ModifierSpec`][osrlib.core.effects.ModifierSpec] accepts for its `kind`. +Combat looks each of these up by name while it resolves a roll, so a kind nothing reads changes nothing. +Constructing a `ModifierSpec` with a name outside this set raises a validation error rather than failing +silently, which is why the set is closed. Adding a kind means teaching combat to read it, so when you're +authoring your own content, express what you want with a kind already here. + +What each one does: + +- `attack_bonus` adjusts the bearer's own attack rolls, and `damage_bonus` its damage rolls. +- `attack_penalty_of_attackers` adjusts the rolls of anyone attacking the bearer, which is how a ward works. +- `save_bonus` adjusts the bearer's saving throws, narrowed by `element`, `save_categories`, or + `versus_other_alignment`. +- `morale_bonus` adjusts the bearer's side's morale checks through + [`morale_modifier`][osrlib.core.combat.morale_modifier]. +- `ac_bonus` improves the bearer's armour class by its value, `ac_set` replaces the armour class outright when + the set value is better, and `ac_set_vs_missile` does the same against missile attacks only. +- `damage_reduction_per_die` takes points off incoming damage, one per die rolled, for the named `element`. +- `damage_multiplier` multiplies the bearer's weapon damage and `melee_damage_multiplier` its melee damage, + after the flat bonuses are added. +- `weapon_damage_dice_bonus` adds its own `dice` to the bearer's weapon damage. +- `strength_set` replaces the strength score the bearer's melee modifiers derive from, which is how Gauntlets of + Ogre Power grant a fixed 18 and a Ring of Weakness a fixed 3. +- `counts_as_magical` makes the bearer's attacks count as magical, and `missile_immunity_nonmagical` absorbs + non-magical missiles aimed at the bearer. +- `magical_healing_half` halves the hit points magical healing restores to the bearer. """ class ModifierSpec(BaseModel): - """One stat modifier an effect grants while active. - - `value` is the signed adjustment (*bless*'s +1, *protection from evil*'s −1 to - attackers, *shield*'s AC-set values); `dice` carries dice-valued bonuses - (*striking*'s +1d6 weapon damage). Scopes: `element` restricts save bonuses and - per-die reductions to one element (*resist fire*), `versus_other_alignment` - restricts save bonuses to attacks from creatures of another alignment - (*protection from evil*), `save_categories` restricts save bonuses to named - categories (the Displacer Cloak's petrification/rods/spells/staves/wands list), - and `melee_only` restricts an attacker penalty to melee attacks (the cloak's −2 - leaves missiles unaffected, RAW). `from_item` marks item-sourced modifiers - (potion effects, ward scrolls): they are exempt from the cumulative - largest-bonus cap — RAW's carve-out covers magic items generally, not just - worn ones. + """One adjustment to a combat statistic that an effect grants for as long as it lasts. + + You write these when you author a spell, a magic item, or an effect of your own, and put them in an + [`EffectDefinition`][osrlib.core.effects.EffectDefinition]'s `modifiers`. Attaching that definition turns + each spec into an [`ActiveModifier`][osrlib.core.effects.ActiveModifier] on the creature, and combat reads + them back through [`modifier_total`][osrlib.core.effects.modifier_total] and its siblings. Nothing takes a + bare spec: attaching an effect is the only way one reaches a creature. A spec is frozen, so the same one can + sit in several definitions. + + The scope fields narrow when the modifier counts. Leave them at their defaults and the modifier applies to + every roll of its kind. + + Examples: + ```python + from osrlib.core.effects import ModifierSpec + + bless = ModifierSpec(kind="attack_bonus", value=1) + resist_fire = ModifierSpec(kind="save_bonus", value=2, element="fire") + assert bless.dice is None and not bless.from_item + assert resist_fire.element == "fire" + ``` """ model_config = ConfigDict(frozen=True) kind: str + """Which statistic the modifier adjusts. Must be one of [`MODIFIER_KINDS`][osrlib.core.effects.MODIFIER_KINDS] + or construction raises a validation error.""" + value: int = 0 + """The signed adjustment: *bless*'s +1 attack bonus, *protection from evil*'s -1 on attackers, the armour + class a `ac_set` kind sets. Leave it at 0 for a kind that uses dice or acts as a flag.""" + dice: str | None = None + """A dice expression rolled instead of adding `value`, for the kinds that grant dice: *striking*'s `"1d6"` of + extra weapon damage. Parsed at construction by [`parse`][osrlib.core.dice.parse], so a malformed expression + raises a validation error rather than failing at the table.""" + element: str | None = None + """Narrows the modifier to one damage or save element, like `"fire"` for *resist fire*. A modifier scoped + to an element counts only when the caller names that element in the roll.""" + versus_other_alignment: bool = False + """True narrows the modifier to rolls against creatures of a different alignment, which is how *protection + from evil* works. It counts only when the caller attests that the alignments differ.""" + save_categories: tuple[str, ...] = () + """Narrows a save bonus to the named saving throw categories, as a Displacer Cloak covers petrification, + rods, spells, staves, and wands but nothing else. Empty means every category.""" + melee_only: bool = False + """True narrows the modifier to melee attacks, which is how the Displacer Cloak's -2 on attackers leaves + missile attacks alone. It counts only when the caller attests the attack is melee.""" + from_item: bool = False + """True marks the modifier as coming from a magic item rather than a spell, which exempts it from the rule + that only the largest spell bonus counts. Item modifiers add up on top of the capped spell total. Set it on + potion effects and ward scrolls as well as worn items: the rule covers magic items in general.""" @field_validator("kind") @classmethod @@ -321,15 +613,22 @@ def _dice_must_parse(cls, value: str | None) -> str | None: class ActiveModifier(ModifierSpec): - """A live stat modifier on a creature, with the effect that owns it. + """One live modifier on a creature, paired with the effect that granted it. - Creatures carry a `stat_modifiers` tuple so a serialized creature is honest on - its own, mirroring conditions. **Only the effects engine writes it** (attach - grants, expiry and release remove) — the single-writer rule extends; combat - reads it locally through the `modifier_*` helpers below. + You read these off a creature's `stat_modifiers` tuple. Attaching an effect turns each of its + [`ModifierSpec`][osrlib.core.effects.ModifierSpec] entries into one of these, and expiry or release takes + them back, the same way conditions work. Nothing else writes the tuple, so a creature's modifiers always + trace to a live effect. + + Read them through [`modifier_total`][osrlib.core.effects.modifier_total], + [`modifier_values`][osrlib.core.effects.modifier_values], [`modifier_dice`][osrlib.core.effects.modifier_dice], + and [`has_modifier`][osrlib.core.effects.has_modifier] rather than scanning the tuple: those helpers apply + the scope filters and the rule that spell bonuses don't add up. """ effect_id: str + """The id of the [`ActiveEffect`][osrlib.core.effects.ActiveEffect] that granted the modifier and will take + it back.""" def modifier_values( @@ -341,26 +640,56 @@ def modifier_values( save_category: str | None = None, melee: bool = False, ) -> list[int]: - """Return the matching modifier values on a creature, scope-filtered. + """Return every modifier value of one kind that applies to the situation you describe. + + Use this when you need the individual values rather than a single number: the armour class rules read the + `ac_set` values one at a time and keep the best. For the ordinary case, where you want one number to add to a + roll, call [`modifier_total`][osrlib.core.effects.modifier_total], which also applies the rule that spell + bonuses don't add up. - Element-scoped modifiers match only their element; alignment-scoped modifiers - match only when the caller attests the source's alignment differs - (`versus_differs`); category-scoped save bonuses match only their categories; - melee-only modifiers match only when the caller attests a melee attack. + The keyword arguments describe the roll in play, and a modifier narrowed to something you don't name is + left out. An element-scoped modifier counts only when you pass its `element`, an alignment-scoped one only + when you pass `versus_differs=True`, a category-scoped save bonus only when you pass one of its categories, + and a melee-only modifier only when you pass `melee=True`. Args: - target: The creature to read modifiers from: a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. - kind: The modifier kind to look for. - element: The damage or save element in play, if any. - versus_differs: True when the source creature's alignment differs from the - target's. - save_category: The saving throw category in play, if any. + target: The creature to read modifiers from. An object with no `stat_modifiers` tuple reads as having + none. + kind: The statistic to look for, one of [`MODIFIER_KINDS`][osrlib.core.effects.MODIFIER_KINDS]. + element: The damage or save element in play, like `"fire"`. Leave it None outside an elemental roll. + versus_differs: True when the other creature in the roll has a different alignment from the target. + save_category: The saving throw category in play. Leave it None outside a saving throw. melee: True when the attack in play is melee. Returns: - The matching values, in attachment order. + The signed values of the modifiers that apply, in the order their effects were attached. Empty when none + apply. + + Examples: + ```python + from osrlib.core.clock import GameClock, TimeUnit + from osrlib.core.effects import EffectDefinition, EffectsLedger, ModifierSpec, modifier_values + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.data import load_monsters + + streams = RngStreams(master_seed=5) + spawn = streams.get(MONSTER_SPAWN_STREAM) + goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn) + registry = {"monster-0001": goblin} + + resist_fire = EffectDefinition( + kind="resist_fire", + duration_unit=TimeUnit.TURN, + duration_amount=12, + modifiers=(ModifierSpec(kind="save_bonus", value=2, element="fire"),), + ) + ledger = EffectsLedger() + ledger.attach(resist_fire, "monster-0001", clock=GameClock(), allocator=IdAllocator(), registry=registry) + + assert modifier_values(goblin, "save_bonus", element="fire") == [2] + assert modifier_values(goblin, "save_bonus", element="cold") == [] + ``` """ matching = _matching_modifiers(target, kind, element, versus_differs, save_category, melee) return [modifier.value for modifier in matching] @@ -394,28 +723,64 @@ def modifier_total( save_category: str | None = None, melee: bool = False, ) -> int: - """Return a creature's cumulative modifier for one statistic. + """Return the one number to add to a roll for a creature's modifiers of one kind. + + This is the call combat makes, and the one you want when you're resolving a roll of your own. It reads the + same modifiers [`modifier_values`][osrlib.core.effects.modifier_values] returns and folds them into a single + signed adjustment, applying the rule that spells affecting the same statistic don't combine: only the + largest bonus and the largest penalty count. Two *blesses* give +1, not +2, while a *bless* and a *blight* + cancel out. - The cumulative-effects rule, from the OSE SRD ("Multiple spells affecting the - same game statistic do not combine"): only the single largest bonus and the - single largest penalty apply — a *bless* and a *blight* offset; two *blesses* - don't stack. Spell modifiers combine freely with non-spell modifiers (the RAW - carve-out for magic items — item-sourced modifiers ride equipped-item queries - and item-kind effects, both outside this cap; see - [`osrlib.core.combat`][osrlib.core.combat]). + Modifiers marked `from_item` sit outside that rule and are added on top, all of them, because the + no-stacking rule covers spells rather than magic items. The scope arguments work exactly as they do for + [`modifier_values`][osrlib.core.effects.modifier_values]. Args: - target: The creature to total the modifier for: a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. - kind: The modifier kind to total. - element: The damage or save element in play, if any. - versus_differs: True when the source creature's alignment differs. - save_category: The saving throw category in play, if any. + target: The creature to total modifiers for. + kind: The statistic to total, one of [`MODIFIER_KINDS`][osrlib.core.effects.MODIFIER_KINDS]. + element: The damage or save element in play, like `"fire"`. Leave it None outside an elemental roll. + versus_differs: True when the other creature in the roll has a different alignment from the target. + save_category: The saving throw category in play. Leave it None outside a saving throw. melee: True when the attack in play is melee. Returns: - The signed cumulative modifier. + The signed adjustment to add to the roll, and 0 when nothing applies. + + Examples: + Two blessings and one blight, all on the same goblin, come to a single point of bonus and a single point + of penalty: + + ```python + from osrlib.core.clock import GameClock, TimeUnit + from osrlib.core.effects import EffectDefinition, EffectsLedger, ModifierSpec, modifier_total, modifier_values + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.data import load_monsters + + streams = RngStreams(master_seed=5) + spawn = streams.get(MONSTER_SPAWN_STREAM) + goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn) + registry = {"monster-0001": goblin} + + ledger, clock, allocator = EffectsLedger(), GameClock(), IdAllocator() + bless = EffectDefinition( + kind="bless", + duration_unit=TimeUnit.TURN, + duration_amount=6, + modifiers=(ModifierSpec(kind="attack_bonus", value=1),), + ) + blight = EffectDefinition( + kind="blight", + duration_unit=TimeUnit.TURN, + duration_amount=6, + modifiers=(ModifierSpec(kind="attack_bonus", value=-1),), + ) + for definition in (bless, bless, blight): + ledger.attach(definition, "monster-0001", clock=clock, allocator=allocator, registry=registry) + + assert modifier_values(goblin, "attack_bonus") == [1, 1, -1] + assert modifier_total(goblin, "attack_bonus") == 0 + ``` """ matching = _matching_modifiers(target, kind, element, versus_differs, save_category, melee) spell_values = [modifier.value for modifier in matching if not modifier.from_item] @@ -426,19 +791,48 @@ def modifier_total( def modifier_dice(target: Any, kind: str) -> str | None: - """Return the dice of the first matching dice-valued modifier (*striking*'s +1d6). + """Return the dice expression of a creature's dice-valued modifier of one kind. + + A few modifiers grant dice instead of a flat number, *striking*'s extra `"1d6"` of weapon damage among them. + Call this to find them, then roll the expression yourself with [`roll`][osrlib.core.dice.roll]. For flat + adjustments, call [`modifier_total`][osrlib.core.effects.modifier_total] instead. - First-only is the cumulative rule for dice bonuses: two *strikings* don't - combine. + Only the first matching modifier is returned, which is the no-stacking rule applied to dice: a creature under + two *strikings* rolls one extra die, not two. Args: - target: The creature to read modifiers from: a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. - kind: The modifier kind to look for. + target: The creature to read modifiers from. + kind: The statistic to look for, one of [`MODIFIER_KINDS`][osrlib.core.effects.MODIFIER_KINDS]. Returns: - The dice expression, or `None` when no matching modifier is active. + The dice expression, in the notation [`parse`][osrlib.core.dice.parse] accepts, or `None` when the + creature has no dice-valued modifier of that kind. + + Examples: + ```python + from osrlib.core.clock import GameClock, TimeUnit + from osrlib.core.effects import EffectDefinition, EffectsLedger, ModifierSpec, modifier_dice + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.data import load_monsters + + streams = RngStreams(master_seed=5) + spawn = streams.get(MONSTER_SPAWN_STREAM) + goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn) + registry = {"monster-0001": goblin} + + striking = EffectDefinition( + kind="striking", + duration_unit=TimeUnit.TURN, + duration_amount=6, + modifiers=(ModifierSpec(kind="weapon_damage_dice_bonus", dice="1d6"),), + ) + ledger = EffectsLedger() + ledger.attach(striking, "monster-0001", clock=GameClock(), allocator=IdAllocator(), registry=registry) + + assert modifier_dice(goblin, "weapon_damage_dice_bonus") == "1d6" + assert modifier_dice(goblin, "damage_bonus") is None + ``` """ for modifier in getattr(target, "stat_modifiers", ()): if modifier.kind == kind and modifier.dice is not None: @@ -447,52 +841,146 @@ def modifier_dice(target: Any, kind: str) -> str | None: def has_modifier(target: Any, kind: str) -> bool: - """Return whether a creature carries any modifier of `kind` (the flag kinds). + """Return whether a creature has any modifier of one kind. + + Use this for the kinds that act as flags rather than numbers, where the presence of the modifier is the whole + rule: `counts_as_magical`, `missile_immunity_nonmagical`, and `magical_healing_half`. For a kind that uses + a number, call [`modifier_total`][osrlib.core.effects.modifier_total], whose 0 means "no adjustment" rather + than "not present". + + It ignores the scope fields, so a modifier narrowed to one element still reports True here. Where the scope + matters, go through [`modifier_values`][osrlib.core.effects.modifier_values]. Args: - target: The creature to read modifiers from: a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. - kind: The modifier kind to look for. + target: The creature to read modifiers from. + kind: The statistic to look for, one of [`MODIFIER_KINDS`][osrlib.core.effects.MODIFIER_KINDS]. Returns: - True when any active modifier matches. + True when the creature has at least one modifier of that kind. + + Examples: + ```python + from osrlib.core.clock import GameClock + from osrlib.core.effects import EffectDefinition, EffectsLedger, ModifierSpec, has_modifier + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.data import load_monsters + + streams = RngStreams(master_seed=5) + spawn = streams.get(MONSTER_SPAWN_STREAM) + goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn) + registry = {"monster-0001": goblin} + + enchanted = EffectDefinition( + kind="striking", + modifiers=(ModifierSpec(kind="counts_as_magical", value=1),), + ) + ledger = EffectsLedger() + ledger.attach(enchanted, "monster-0001", clock=GameClock(), allocator=IdAllocator(), registry=registry) + + assert has_modifier(goblin, "counts_as_magical") + assert not has_modifier(goblin, "missile_immunity_nonmagical") + ``` """ return any(modifier.kind == kind for modifier in getattr(target, "stat_modifiers", ())) class EffectDefinition(BaseModel): - """A frozen effect blueprint: duration, ticks, stacking, expiry, and condition. - - Durations are `duration_amount` (fixed) or `duration_dice` (rolled at attach from - the effects stream) counts of `duration_unit`; both `None` means indefinite (until - released) and `permanent=True` marks effects only magic removes (petrification — - stone is not dead). `tick` names a periodic behavior the ledger executes every - `tick_interval_rounds`; `expiry` names an outcome resolved when the duration runs - out (`death` for delayed poison, `splash_damage` for the douse's second - application). `condition` is granted at attach and removed at expiry or release; - `modifiers` are granted and removed the same way. `dispellable=True` marks - spell-attached effects *dispel magic* can end: every effect - [`cast_spell`][osrlib.core.spells.cast_spell] attaches is dispellable, including - permanent ones (`permanent=True` means "no duration expiry", not - "undispellable"), while monster-inflicted effects stay non-dispellable. + """The blueprint for an effect: how long it lasts, what it does while it lasts, and what happens when it ends. + + Write one of these for each spell, ability, or hazard you want to put on a creature, then hand it to + [`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach] with the entity id or location it applies + to. Attaching turns the blueprint into a live [`ActiveEffect`][osrlib.core.effects.ActiveEffect]. The + blueprint itself is frozen, so one definition serves every creature you attach it to. The compiled spell and + magic item data already includes definitions for the published content, so you write your own only when you're + authoring something new. + + Give the effect a duration through `duration_unit` with either `duration_amount` or `duration_dice`. Leave + the unit out and the effect runs until you release it. Set `permanent` for something only magic undoes. + + Examples: + A four-turn sleep that grants a condition, and an indefinite +1 to attacks that grants a modifier: + + ```python + from osrlib.core.clock import TimeUnit + from osrlib.core.effects import Condition, EffectDefinition, ModifierSpec + + sleep = EffectDefinition( + kind="sleep", + duration_unit=TimeUnit.TURN, + duration_amount=4, + condition=Condition.ASLEEP, + dispellable=True, + ) + bless = EffectDefinition(kind="bless", modifiers=(ModifierSpec(kind="attack_bonus", value=1),)) + + assert sleep.stacking == "stack" # the default: a second sleep is a second effect + assert bless.duration_unit is None # no unit means it runs until released + ``` """ model_config = ConfigDict(frozen=True) kind: str = Field(min_length=1) + """The effect's name, like `"sleep"` or `"regeneration"`. Stacking compares kinds, and + [`EffectsLedger.active_on`][osrlib.core.effects.EffectsLedger.active_on] filters on it, so pick one name per + thing and use it everywhere. Any non-empty string is accepted.""" + duration_unit: TimeUnit | None = None + """The unit the duration is counted in: rounds, turns, or days. `None` means the effect has no duration and + runs until you release it.""" + duration_amount: int | None = None + """A fixed duration, counted in `duration_unit`. Use this or `duration_dice`, not both.""" + duration_dice: str | None = None + """A dice expression rolled once at attach time to set the duration, like `"2d6"`. Rolling one needs the + effects stream, so attaching a definition that uses dice without passing `stream` raises `ValueError`. + Parsed at construction by [`parse`][osrlib.core.dice.parse].""" + permanent: bool = False + """True means the effect never expires on its own, which is how petrification lasts until someone casts + *stone to flesh*. It says nothing about whether *dispel magic* can end it: that is `dispellable`.""" + tick: str | None = None + """The name of a periodic behavior the ledger runs while the effect lasts. There are two. + `"regeneration"` heals the bearer and can bring a troll back from death. `"charm_resave"` rolls a saving + throw that ends the effect when it passes. Any other name raises `ValueError` at the first tick.""" + tick_interval_rounds: int = Field(default=1, ge=1) + """How many rounds pass between ticks. The default of 1 ticks every round. A charm sets this from the + subject's intelligence, so the dull re-save monthly and the bright daily.""" + stacking: Literal["stack", "refresh", "ignore"] = "stack" + """What happens when the same kind is attached to a target that already has one. `"stack"` adds a second + effect. `"refresh"` restarts the existing effect's duration and attaches nothing new. `"ignore"` does + nothing at all, and the attach returns no effect.""" + expiry: str | None = None + """The name of an outcome the ledger resolves when the duration runs out, instead of the effect ending on + its own. There are three. `"death"` kills the bearer, which is how a delayed poison works. + `"splash_damage"` deals the second application of burning oil or holy water. `"weakness_strength_set"` + replaces the finished onset with the curse itself. Any other name raises `ValueError` at expiry.""" + condition: Condition | None = None + """A [`Condition`][osrlib.core.effects.Condition] granted when the effect attaches and taken back when it + expires or is released. A target immune to the condition is never affected, and the attach returns no + effect.""" + modifiers: tuple[ModifierSpec, ...] = () + """The [`ModifierSpec`][osrlib.core.effects.ModifierSpec] adjustments granted while the effect lasts and + taken back when it ends.""" + dispellable: bool = False + """True marks the effect as something *dispel magic* can end. Everything + [`cast_spell`][osrlib.core.spells.cast_spell] attaches is dispellable, permanent effects included, while what + a monster inflicts is not.""" + params: dict[str, int | str | bool | tuple[int | str, ...]] = {} + """Whatever else the effect's tick or expiry behavior needs to read: regeneration's `per_round`, + `delay_rounds`, and `revive`, a splash douse's `dice` and `element`, a slowed-healing effect's + `healing_rest_days`. Each behavior documents the keys it reads, and keys it doesn't recognize are left + untouched.""" @field_validator("duration_dice") @classmethod @@ -503,46 +991,126 @@ def _dice_must_parse(cls, value: str | None) -> str | None: class ActiveEffect(BaseModel): - """A live effect on a creature, item, or location. - - `target_ref` is an entity id or a location string (a burning oil pool attaches to - a location, a stationary *silence* to a cell). `expires_round` is the absolute round - the effect expires on (`None` for indefinite and permanent effects); petrification - suspension pushes it forward. `state` is the effect's own bookkeeping (revival - round, counted rest days). `caster_level` records the casting caster's level on - spell-attached effects — *dispel magic*'s survival roll compares against it. + """One effect currently running on a creature, item, or location. + + [`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach] returns one of these and keeps it in the + ledger's `effects` list, and [`EffectsLedger.active_on`][osrlib.core.effects.EffectsLedger.active_on] finds + them again. You read one to ask how long an effect has left or what it's tracking, and you pass its + `effect_id` to [`EffectsLedger.release`][osrlib.core.effects.EffectsLedger.release] to end it early. Build + one yourself only when you're restoring a saved game. In play, attaching is what creates them. """ model_config = ConfigDict(validate_assignment=True) effect_id: str + """The effect's id, allocated at attach time by the + [`IdAllocator`][osrlib.core.monsters.IdAllocator] you passed, in the form `effect-0001`. The conditions and + modifiers the effect granted record this id, which is how they are matched back when it ends.""" + definition: EffectDefinition + """The [`EffectDefinition`][osrlib.core.effects.EffectDefinition] this effect was attached from, kept here + so the ledger can tick and expire it without looking anything up.""" + target_ref: str + """What the effect is on: an entity id for a creature, or a location string for something that sits in a + place, as a burning oil pool or a stationary *silence* does. A location reference is not a key in the + registry, so an effect on a location grants no conditions or modifiers.""" + attached_round: int = Field(ge=0) + """The absolute round the effect was attached on, counted from the start of the game clock. Ticks are + counted from here, and it is the first key effects are ordered by when several resolve in one round.""" + expires_round: int | None = None + """The absolute round the effect expires on, or `None` when it has no duration or is permanent. Suspension + pushes it forward one round for each round the bearer spends petrified, so a suspended effect keeps the time + it had left.""" + caster_level: int | None = None + """The level of the caster whose spell attached this effect, recorded when the attach passed one. + *Dispel magic* rolls against it to decide whether the effect survives.""" + state: dict[str, int] = {} + """The effect's own running bookkeeping, written by its tick and expiry behaviors: the round a troll revives + on, the number of consecutive rest days a slowed-healing effect has counted. Read it if you want to show a + countdown, and leave the writing to the ledger.""" -# An invariant test asserts ledger effects and the creature conditions/stat_modifiers -# they grant never desync: every mutation must flow through this class's helpers (or -# the kernel's death routine, for `dead`). Keep it that way when extending the engine. +# An invariant test asserts that ledger effects and the conditions and stat modifiers +# they grant never fall out of step: every mutation must flow through this class's +# helpers, or through kill() for `dead`. Keep it that way when extending the engine. class EffectsLedger(BaseModel): - """The serializable effect engine: attach, release, and clock-driven advance.""" + """The engine that contains every live effect and runs it against the game clock. + + One ledger covers a whole game: a [`GameSession`][osrlib.crawl.session.GameSession] creates one and keeps it + for the life of the session, and a caller running the rules without a session creates one and keeps it + alongside the [`GameClock`][osrlib.core.clock.GameClock]. It is a pydantic model, so saving a game is saving + the ledger along with the clock and the creatures. + + Four calls are the whole interface. [`attach`][osrlib.core.effects.EffectsLedger.attach] puts an effect on a + target, [`active_on`][osrlib.core.effects.EffectsLedger.active_on] asks what is on one, + [`release`][osrlib.core.effects.EffectsLedger.release] ends an effect early, and + [`advance`][osrlib.core.effects.EffectsLedger.advance] moves the clock and resolves everything the passing + time triggers. Nothing happens without `advance`: an effect with a duration sits there until the clock + reaches its expiry round, so advance the clock through the ledger rather than writing to the clock directly. + + Examples: + ```python + from osrlib.core.clock import GameClock, TimeUnit + from osrlib.core.effects import EFFECTS_STREAM, Condition, EffectDefinition, EffectsLedger, has_condition + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.data import load_monsters + + streams = RngStreams(master_seed=3) + spawn = streams.get(MONSTER_SPAWN_STREAM) + goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn) + registry = {"monster-0001": goblin} + + ledger = EffectsLedger() + clock = GameClock() + web = EffectDefinition( + kind="web", + duration_unit=TimeUnit.TURN, + duration_amount=2, + condition=Condition.ENTANGLED, + ) + effect, _ = ledger.attach(web, "monster-0001", clock=clock, allocator=IdAllocator(), registry=registry) + assert effect is not None and effect.expires_round == 120 # two turns of sixty rounds + + # One turn on, the web still holds. + ledger.advance(clock, 1, TimeUnit.TURN, registry, stream=streams.get(EFFECTS_STREAM)) + assert has_condition(goblin, Condition.ENTANGLED) + + # One more, and it lets go. + ledger.advance(clock, 1, TimeUnit.TURN, registry, stream=streams.get(EFFECTS_STREAM)) + assert not has_condition(goblin, Condition.ENTANGLED) + ``` + """ model_config = ConfigDict(validate_assignment=True) effects: list[ActiveEffect] = [] + """Every live [`ActiveEffect`][osrlib.core.effects.ActiveEffect], in the order they were attached. Read it to + see everything running at once. To find the effects on one target, call + [`active_on`][osrlib.core.effects.EffectsLedger.active_on]. Attaching, releasing, and expiry maintain the + list, so leave the writing to them.""" def active_on(self, target_ref: str, kind: str | None = None) -> list[ActiveEffect]: - """Return the live effects on a target, optionally filtered by kind. + """Return the effects currently running on one target. + + Use this to answer questions about a creature's situation that the condition and modifier helpers cannot: + whether a *mirror image* is still up, how many rounds a light source has left, whether an anti-magic + shell is blocking a cast. Pass `kind` when you know which effect you're after, and you get either an + empty list or the ones that match. Args: - target_ref: The entity id or location string. - kind: An effect kind to filter by. + target_ref: The entity id or location string the effects are attached to. + kind: An [`EffectDefinition`][osrlib.core.effects.EffectDefinition] `kind` to narrow to. Leave it out + for everything on the target. Returns: - The matching effects, in attachment order. + The matching effects, in the order they were attached. The list is new, but the effects in it are the + ledger's own, so a change to one changes what the ledger runs. """ return [ effect @@ -561,27 +1129,45 @@ def attach( stream: RngStream | None = None, caster_level: int | None = None, ) -> tuple[ActiveEffect | None, list[Event]]: - """Attach an effect, resolving stacking, duration dice, conditions, and modifiers. + """Put an effect on a target and return it with the events the attach produced. + + This is the way an effect starts. Build an [`EffectDefinition`][osrlib.core.effects.EffectDefinition], + call this with the target's entity id, and the ledger works out when the effect expires, grants the + condition and modifiers it brings, and starts counting its ticks. Afterwards, keep the clock moving + through [`advance`][osrlib.core.effects.EffectsLedger.advance] or nothing further happens. + + When a spell is what attaches the effect, [`cast_spell`][osrlib.core.spells.cast_spell] makes this call + for you and hands back the same events. + + The call can hand back `None` instead of an effect, so check before you use it. Two cases produce it: + the definition's `stacking` is `"ignore"` and the target already has that kind, or the target's template + lists the definition's condition among its `condition_immunities`. A `stacking` of `"refresh"` is + different again: you get the existing effect back with its duration restarted, and no events. Args: - definition: The effect blueprint. - target_ref: The entity id or location string to attach to. - clock: The game clock (the attach round anchors the duration). - allocator: The [`IdAllocator`][osrlib.core.monsters.IdAllocator] granting - effect ids. - registry: Live combatants by entity id — a - [`Character`][osrlib.core.character.Character] or - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] per id — for - condition and modifier grants; a location ref simply isn't a key. - stream: The effects stream; required when the definition rolls duration - dice. - caster_level: The casting caster's level, recorded on spell-attached - effects for *dispel magic*'s survival roll. + definition: The blueprint to attach. + target_ref: The entity id of the creature, or the location string of the place, to attach to. An id + that is not a key in `registry` attaches the effect but grants nothing. + clock: The game clock. The current round anchors the duration and the tick count. The clock is + read, not advanced. + allocator: The [`IdAllocator`][osrlib.core.monsters.IdAllocator] that grants the effect its id. A + session keeps one. Create your own otherwise. + registry: The live creatures by entity id, so the attach can grant conditions and modifiers. Pass + None, or leave the target out of it, and the effect runs with nothing to write to. + stream: The effects stream from [`EFFECTS_STREAM`][osrlib.core.effects.EFFECTS_STREAM]. Needed only + when the definition has `duration_dice`. + caster_level: The casting caster's level, recorded on the effect for *dispel magic* to roll against. Returns: - The attached effect and its events — or `(None, [])` when stacking says - `ignore` and the kind is already present, or the target is immune to the - effect's condition. + A pair of the attached effect and its events. The events are the + [`EffectAttachedEvent`][osrlib.core.events.EffectAttachedEvent] and, when the definition brings a + condition the target takes, the + [`ConditionGainedEvent`][osrlib.core.events.ConditionGainedEvent]. The pair is `(None, [])` when + nothing was attached, and on a refresh it is the existing effect with an empty event list. + + Raises: + ValueError: If the definition has `duration_dice` and no `stream` was passed, or if it names a + `duration_unit` with neither an amount nor dice. """ existing = self.active_on(target_ref, definition.kind) if existing and definition.stacking == "ignore": @@ -619,20 +1205,31 @@ def attach( return effect, events def release(self, effect_id: str, registry: Mapping[str, Any] | None = None) -> list[Event]: - """Release an effect before expiry, removing its condition. + """End an effect before its duration runs out. + + Call this when something in the game cuts an effect short: a *dispel magic*, a charmed creature making + its save, a light source put out, an invisible creature attacking and losing the invisibility. The + condition and the modifiers the effect granted come off with it. + + Find the id first with [`active_on`][osrlib.core.effects.EffectsLedger.active_on], and copy the list + before you release from it, since releasing changes the ledger's own list as you go. To end an effect + because time ran out, do nothing: [`advance`][osrlib.core.effects.EffectsLedger.advance] expires it for + you and emits [`EffectExpiredEvent`][osrlib.core.events.EffectExpiredEvent] instead. Args: - effect_id: The effect to release. - registry: Live combatants by entity id — a - [`Character`][osrlib.core.character.Character] or - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] per id — for - condition removal. + effect_id: The id of the effect to end, from its + [`ActiveEffect`][osrlib.core.effects.ActiveEffect]. + registry: The live creatures by entity id, so the condition and modifiers can be taken back. Leave it + out and the effect is dropped from the ledger with the creature still under them. Returns: - The released and condition-removed events. + The [`EffectReleasedEvent`][osrlib.core.events.EffectReleasedEvent] and, when the effect granted a + condition to a creature in the registry, the + [`ConditionRemovedEvent`][osrlib.core.events.ConditionRemovedEvent]. Raises: - ValueError: If no live effect has that id. + ValueError: If the ledger has no effect with that id, which means it already expired or was + already released. """ effect = next((candidate for candidate in self.effects if candidate.effect_id == effect_id), None) if effect is None: @@ -658,29 +1255,73 @@ def advance( stream: RngStream, allocator: Any | None = None, ) -> list[Event]: - """Advance the clock and resolve every round boundary in the span. - - The canonical tick order, locked by test: at each boundary, expirations - resolve before ticks; simultaneous effects resolve in attachment order, - tie-broken by effect id. Suspended effects (target petrified by another - effect) neither tick nor age — their expiry pushes forward one round per - suspended round. + """Move the game clock forward and resolve everything the passing time triggers. + + This is what makes an effect with a duration actually end, and a regenerating troll actually heal. + Advance the clock through this call rather than writing to + [`GameClock.rounds`][osrlib.core.clock.GameClock] yourself: the clock records elapsed time and nothing + about what is attached to whom, so time you add behind the ledger's back resolves no effects at all. A + [`GameSession`][osrlib.crawl.session.GameSession] makes this call for you inside + [`advance_rounds`][osrlib.crawl.session.GameSession.advance_rounds] and + [`advance_turns`][osrlib.crawl.session.GameSession.advance_turns]. + + Every round in the span is resolved, one at a time, in the same order. Effects whose bearer is petrified + by another effect suspend first, and a suspended effect neither ticks nor ages: its expiry moves forward + one round for each round it spends suspended. Then expirations resolve, then ticks. Within each of those + phases, effects go in attachment order, tie-broken by effect id, so the same span always produces the + same events in the same order. Args: - clock: The game clock; advanced in place. - n: How many units to advance. - unit: The unit to advance in. - registry: Live combatants by entity id — a - [`Character`][osrlib.core.character.Character] or - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] per id. A - [`GameSession`][osrlib.crawl.session.GameSession] holds one across - play; a plain dict works too. - stream: The effects stream for effect-internal draws. - allocator: The [`IdAllocator`][osrlib.core.monsters.IdAllocator], needed - only by behaviors that attach follow-on effects. + clock: The game clock. It is advanced in place, so it shows the new time when the call returns. + n: How many units to advance. Advancing a long span resolves every round in it, so a day is + thousands of rounds of work. + unit: The unit `n` counts: rounds, turns, or days. + registry: The live creatures by entity id, so conditions, modifiers, and hit points can be written. A + session keeps one across play, and a plain dict works. + stream: The effects stream from [`EFFECTS_STREAM`][osrlib.core.effects.EFFECTS_STREAM], for the draws + that ticks and expiries make. + allocator: The [`IdAllocator`][osrlib.core.monsters.IdAllocator]. Needed only by the expiry behaviors + that attach a follow-on effect, which raise `ValueError` without one. Returns: - Every event the advance produced, in resolution order. + Every event the span produced, in the order it was produced, ready to append to your log. + + Raises: + ValueError: If a tick or expiry behavior names something osrlib doesn't define, or if a follow-on + attach needed an `allocator` and none was passed. + + Examples: + A troll that took ten points of damage regenerates three of them a round: + + ```python + from osrlib.core.clock import GameClock, TimeUnit + from osrlib.core.effects import EFFECTS_STREAM, EffectsLedger, regeneration_definition + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.data import load_monsters + + streams = RngStreams(master_seed=9) + template = load_monsters().get("troll") + troll = spawn_monster(template, id="monster-0001", stream=streams.get(MONSTER_SPAWN_STREAM)) + registry = {"monster-0001": troll} + troll.current_hp -= 10 + + ledger = EffectsLedger() + clock = GameClock() + definition = regeneration_definition(template.abilities[0].params) + ledger.attach(definition, "monster-0001", clock=clock, allocator=IdAllocator(), registry=registry) + + events = ledger.advance(clock, 2, TimeUnit.ROUND, registry, stream=streams.get(EFFECTS_STREAM)) + assert [event.code for event in events] == [ + "effects.effect.ticked", + "combat.healing.applied", + "combat.state.hit_points", + "effects.effect.ticked", + "combat.healing.applied", + "combat.state.hit_points", + ] + assert troll.current_hp == troll.max_hp - 4 # six of the ten points back + ``` """ start = clock.rounds clock.advance(n, unit) @@ -718,11 +1359,11 @@ def _resolve_round( self, current_round: int, registry: Mapping[str, Any], stream: RngStream, allocator: Any | None = None ) -> list[Event]: events: list[Event] = [] - # Round-resolution order (suspension, then expiry, then ticks, tie-broken by - # effect id within each phase) is asserted by an invariant test; changing it + # An invariant test asserts the round-resolution order: suspension, then + # expiry, then ticks, tie-broken by effect id within each phase. Changing it # is a rules decision, not a refactor. # Suspension first: a suspended effect neither expires nor ticks this round, - # and its remaining duration is preserved by pushing expiry forward. + # and pushing its expiry forward preserves the duration it has left. suspended_ids = set() for effect in self._ordered(): if self._suspended(effect, registry): @@ -825,11 +1466,10 @@ def _tick_charm_resave( registry: Mapping[str, Any], stream: RngStream, ) -> list[Event]: - """The charm's periodic saving throw: a passed save releases the charm. + """Roll the charm's periodic saving throw, releasing the charm when it passes. - The re-save is a tick-time draw, so it comes from the effects stream per the - stream convention; the interval was fixed at attach from the subject's INT - band (`tick_interval_rounds`). + The re-save is a tick-time draw, so it comes from the effects stream. The interval was fixed at attach + time from the subject's INT band, and rides `tick_interval_rounds`. """ # Deferred import: combat.py imports from this module, so importing it at # module scope would create a cycle. @@ -865,8 +1505,8 @@ def _tick_regeneration( if while_alive or revive_dice is None or regenerable_max < 1: return [] if "revive_at" not in effect.state: - # Pinned: the 2d6-round countdown anchors to the round the killing - # damage landed (the instance's damage ledger), falling back to this + # The 2d6-round countdown anchors to the round the killing damage + # landed, which the instance records, and falls back to this # boundary when no clocked damage was recorded. base = getattr(target, "last_damaged_round", None) anchor = base if base is not None else current_round @@ -901,14 +1541,40 @@ def _tick_regeneration( def regeneration_definition(params: Mapping[str, Any]) -> EffectDefinition: - """Build a regeneration effect from a monster's `regeneration` ability params. + """Build the effect definition for a monster that regenerates. + + A regenerating monster like a troll has a `regeneration` ability whose params say how fast it heals + and whether it comes back from death. Pass those params here and attach the result with + [`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach] when the monster enters play, and every + [`advance`][osrlib.core.effects.EffectsLedger.advance] heals it on its own. Find the params on the template's + ability with the tag `regeneration`. + + The definition it builds has no duration, so the regeneration runs until you release it, and its `stacking` + is `"ignore"`, so attaching it twice to the same monster is harmless. Args: - params: The compiled tag params — `per_round`, `delay_rounds`, `blocked_by`, - `revive`, `while_alive`. + params: The ability's params. The tick reads four keys. `per_round` is how many hit points come back + each round. `delay_rounds` is how many rounds of quiet the monster needs after being damaged before + healing resumes. `revive` is a dice expression for how long the monster lies dead before getting + back up. `while_alive` is True for a monster that heals only while living. Anything else is left + untouched, and any list becomes a tuple so the definition stays hashable. Returns: - An indefinite per-round regeneration effect definition. + An [`EffectDefinition`][osrlib.core.effects.EffectDefinition] of kind `"regeneration"` with the + `"regeneration"` tick. + + Examples: + ```python + from osrlib.core.effects import regeneration_definition + from osrlib.data import load_monsters + + ability = next(a for a in load_monsters().get("troll").abilities if a.tag == "regeneration") + definition = regeneration_definition(ability.params) + + assert definition.kind == "regeneration" and definition.tick == "regeneration" + assert definition.duration_unit is None # it runs until released + assert definition.params["per_round"] == 3 + ``` """ return EffectDefinition( kind="regeneration", diff --git a/src/osrlib/core/events.py b/src/osrlib/core/events.py index ab15b5b..bdb6ac9 100644 --- a/src/osrlib/core/events.py +++ b/src/osrlib/core/events.py @@ -1,30 +1,55 @@ -"""The event base class, the emission contract, and the first kernel events. - -Every rules resolution emits typed events, and this module locks the rules all of them -obey: - -- Events carry structured fields and a message code — dotted snake_case namespaced by - subsystem (`combat.attack.hit`, `exploration.torch.expired`) — never baked English - prose. The default English message formatter ships outside the event models (in - [`osrlib.messages`][osrlib.messages]), so front ends can localize and LLM narrators - get facts rather than canned text. -- Events carry a visibility level, because B/X hides some rolls by design: monster hit - points and morale rolls are the referee's. Front ends filter on it; an LLM referee - sees everything. -- Consumers must tolerate unknown event types and unknown fields: within a - `schema_version`, the event schema grows additively only. The base class enforces - `extra="ignore"` and `frozen=True` on every subclass at class-definition time, so no - subclass can silently break that guarantee with `extra="forbid"` or a mutable config. - -The serialized type discriminator is declared here: -every kernel event class declares a single-valued `event_type: Literal[...]` wire -field (snake_case, schema-stable, additive-only). Pydantic discriminates the -[`KernelEvent`][osrlib.core.events.KernelEvent] union on it, giving native tagged-union -JSON Schema for API consumers and mechanical "ignore unknown event types" — see -[`parse_event`][osrlib.core.events.parse_event]. Message codes stay free to be -*outcome-bearing*: one event class may emit several codes from its declared closed set -(`combat.attack.hit` / `combat.attack.missed` on the attack event), so formatters and -narration key off codes while consumers discriminate on `event_type`. +"""Read what the rules did: the event base class, the emission contract, and the core rules' own event types. + +Every rules call in osrlib returns events rather than printing anything, and this module defines the base class +they all share along with the events the core rules emit themselves. You receive these, you don't construct +them: an attack, a cast, a clock advance each hand you a list, and what your program does with that list is the +game. +[`osrlib.crawl.events`][osrlib.crawl.events] adds the crawl's own types on the same base, and +[`parse_any_event`][osrlib.crawl.events.parse_any_event] reads back a log that contains both. + +Three things are true of every event, and they are what you can build on. + +An event has structured fields and a message code, never a sentence. A code is a dotted lowercase string +namespaced by subsystem, like `combat.attack.hit`, and it names the outcome. Turn one into English with +[`format_message`][osrlib.messages.format_message], or write your own formatter, or hand the fields to a +narrator. The events themselves stay language-free, so any of those work. + +An event has a visibility, because B/X hides some rolls from the players on purpose. Monster hit points and +morale rolls are the referee's business. Filter on +[`Visibility`][osrlib.core.events.Visibility] before you show a log to a player, or let +[`GameSession.view`][osrlib.crawl.session.GameSession.view] do it for you. + +An event tolerates growth. Within a `schema_version` the event schema only gains things, unknown fields are +ignored rather than rejected, and an `event_type` your copy of the library has never heard of parses to `None` +instead of raising. Write your consumer to skip what it doesn't recognize and a log from a newer release still +replays. + +Each class declares an `event_type`, a fixed string that names the type on the wire. Discriminate on that, not on +the code: one class can use several codes, since the code names the outcome and the type names the shape. +[`KernelEvent`][osrlib.core.events.KernelEvent] is the union pydantic discriminates, +[`KERNEL_EVENT_CLASSES`][osrlib.core.events.KERNEL_EVENT_CLASSES] is the same set as a tuple you can iterate, and +[`parse_event`][osrlib.core.events.parse_event] turns a saved mapping back into the right class. + +Typical usage: + +```python +from osrlib.core.events import DamageDealtEvent, MoraleCheckedEvent, Visibility, parse_event +from osrlib.messages import format_message + +# A rules call hands you events like these. +log = [ + DamageDealtEvent(target_id="monster-0001", attacker_id="hild", amount=5, rolls=(5,)), + MoraleCheckedEvent(code="combat.morale.held", subject="goblins", score=8, roll=6), +] + +# The players see their own half of it. +player_lines = [format_message(event) for event in log if event.visibility is Visibility.PLAYER] +assert player_lines == ["monster-0001 takes 5 damage from hild."] + +# And the whole log round-trips through JSON and back. +restored = [parse_event(event.model_dump(mode="json")) for event in log] +assert restored == log +``` """ import re @@ -77,39 +102,87 @@ class Visibility(StrEnum): - """Who may see an event. - - The wire values are `"player"` and `"referee"` — lowercase, serialized into every - event; changing them is a `schema_version` bump. + """Who is allowed to see an event. + + Every event has one of these two values, and the split exists because B/X keeps some rolls behind the + referee's screen. Filter a log on it before you show anything to a player, or call + [`GameSession.view`][osrlib.crawl.session.GameSession.view], which builds the player's and the referee's + views for you. A narrator playing referee reads both. + + The values are the lowercase strings and they serialize into every event, so a renamed value is a + `schema_version` bump. + + Examples: + ```python + from osrlib.core.events import DamageDealtEvent, HitPointsReportedEvent, Visibility + + log = [ + DamageDealtEvent(target_id="monster-0001", amount=5), + HitPointsReportedEvent(target_id="monster-0001", current_hp=2, max_hp=7), + ] + shown = [event.code for event in log if event.visibility is Visibility.PLAYER] + assert shown == ["combat.damage.dealt"] # the goblin's remaining hit points stay hidden + ``` """ PLAYER = "player" + """Anyone may see it. The players learn what happened in the fiction: a hit, a spell, a door opening.""" + REFEREE = "referee" + """Only the referee may see it. These are the numbers B/X keeps hidden: monster hit points, morale and + reaction rolls, surprise and detection rolls, and the effects ledger's bookkeeping.""" class Event(BaseModel): - """Base class for all osrlib events. + """What every osrlib event is: a frozen record of one thing the rules did. + + Use this as the type you annotate with. A rules call returns `list[Event]`, and the two fields declared here + are the two you can read on anything in that list: the `code` that says what happened and the `visibility` + that says who may see it. Everything else is on the subclass, which you reach by checking `event_type` or by + an `isinstance` test. - Events are frozen: they are records of what happened, appended to the session log, - never mutated. Subclasses add structured fields only — entity IDs, roll results, - quantities — and must never bake in English prose. + Subclass it to add your own events for rules osrlib doesn't cover. Add structured fields only, keep the + code discipline, and give the class an `event_type` of its own if you want it to parse back from a saved + log. A subclass that turns off `frozen`, or sets `extra` to anything but `"ignore"`, raises `TypeError` as + soon as it is defined, because either one would break the guarantees above. - `code` is the event's message code: two or more dot-separated segments, each - matching `[a-z][a-z0-9_]*`, namespaced by subsystem (`combat.attack.hit`). A - subclass declaring an `allowed_codes` class attribute pins its outcome-bearing - code set: instances must carry one of them. + Events never change once made, so keep a list of them as a log and read it whenever you like. + + Examples: + ```python + from pydantic import ValidationError + + from osrlib.core.events import DamageDealtEvent + + event = DamageDealtEvent(target_id="monster-0001", amount=5) + assert event.code == "combat.damage.dealt" + + # A code the class doesn't declare is refused. + try: + DamageDealtEvent(code="combat.damage.absorbed", target_id="monster-0001", amount=5) + except ValidationError as error: + assert "combat.damage.dealt" in str(error) + ``` """ model_config = ConfigDict(frozen=True, extra="ignore") allowed_codes: ClassVar[frozenset[str]] = frozenset() + """The codes a subclass is allowed to use, checked when an instance is built. Empty on the base class, + which accepts any well-formed code. Read it to find out what outcomes a type can report without constructing + one, and declare it on a subclass of your own to get the same check.""" code: str + """What happened, as two or more lowercase segments separated by dots and namespaced by subsystem, like + `combat.attack.hit`. This is what you branch on and what + [`format_message`][osrlib.messages.format_message] looks up. Anything else raises a validation error.""" + visibility: Visibility + """Who may see the event. Filter on it before showing a log to a player.""" @classmethod def __pydantic_init_subclass__(cls, **kwargs: object) -> None: - """Reject subclasses that weaken the emission contract via `model_config`.""" + """Reject a subclass whose `model_config` would weaken the emission contract.""" super().__pydantic_init_subclass__(**kwargs) if cls.model_config.get("extra") != "ignore": raise TypeError( @@ -137,547 +210,981 @@ def _code_within_declared_set(self) -> Event: class InitiativeRoll(BaseModel): - """One participant's (or side's) initiative rolls: re-rolls included, ties re-roll.""" + """One initiative roll on an [`InitiativeRolledEvent`][osrlib.core.events.InitiativeRolledEvent]. + + You read these off the event's `entries` to show what each side or combatant rolled. Which of the two it is + depends on the event's `mode`. + """ model_config = ConfigDict(frozen=True) key: str + """Who rolled: a side's name under side initiative, or a combatant's entity id under individual + initiative.""" + rolls: tuple[int, ...] + """Every d6 rolled for this participant, oldest first. A tie is re-rolled, so a tuple longer than one entry + is the record of a tie and the rolls that broke it. The last entry is the one that counts.""" + modifier: int = 0 + """The adjustment added to the final roll. Under individual initiative a character's comes from dexterity + and class, from [`participant_modifier`][osrlib.core.combat.participant_modifier], and a monster's is + whatever the caller passed.""" + total: int + """The last roll plus the modifier, which is the number the order was sorted on.""" class InitiativeRolledEvent(Event): - """Initiative rolled for a round: every roll (re-rolls included) and the acting order. + """Who acts first this round, and what everyone rolled to get there. - Emitted once per round by - [`roll_initiative`][osrlib.core.combat.roll_initiative]. `mode` is `"side"` when - one roll sets the order for an entire side, or `"individual"` when each - combatant rolls separately. + [`roll_initiative`][osrlib.core.combat.roll_initiative] emits one of these at the top of each combat round. + Read `order` to know whose turn comes next, and `entries` to show the dice. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"combat.initiative.rolled"}) + """The only code this event uses.""" event_type: Literal["initiative_rolled"] = "initiative_rolled" + """The wire name for this event type.""" + code: str = "combat.initiative.rolled" + """Fixed at `combat.initiative.rolled`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the order of play is something everyone at the table can see.""" + mode: Literal["side", "individual"] + """How initiative was rolled: `"side"` when one roll sets the order for a whole side, `"individual"` when + every combatant rolled for itself.""" + entries: tuple[InitiativeRoll, ...] + """One [`InitiativeRoll`][osrlib.core.events.InitiativeRoll] per participant, in the order they were passed + in rather than the order they act.""" + order: tuple[str, ...] + """The acting order for the round, first to last. The entries are side names or combatant entity ids, + matching `mode`.""" class AttackRolledEvent(Event): - """An attack roll resolved: the die, the modifiers, and what it needed. + """One attack roll and everything that went into it. - Emitted by [`attack_roll`][osrlib.core.combat.attack_roll]. `code` is - `combat.attack.hit` or `combat.attack.missed` for a rolled attack, or - `combat.attack.auto_hit` for a helpless target (no roll required); `roll`, - `total`, and `required` are `None` in the auto-hit case. `natural` carries 1 or - 20 when the natural roll overrode the modified total. + [`attack_roll`][osrlib.core.combat.attack_roll] emits one of these for every swing, and + [`resolve_attack`][osrlib.core.combat.resolve_attack] passes it on with the damage that followed. Read `code` + for the outcome. The numeric fields are there so you can show the arithmetic. + + An attack against a helpless target needs no roll, and then `roll`, `total`, and `required` are all `None`. """ allowed_codes: ClassVar[frozenset[str]] = frozenset( {"combat.attack.hit", "combat.attack.missed", "combat.attack.auto_hit"} ) + """The three outcomes: a hit, a miss, or an automatic hit against a helpless target.""" event_type: Literal["attack_rolled"] = "attack_rolled" + """The wire name for this event type.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: attack rolls happen in the open.""" + attacker_id: str + """The entity id of whoever attacked.""" + defender_id: str + """The entity id of whoever was attacked.""" + attack_name: str + """What was attacked with, by name: a weapon, a monster's natural attack, or `"unarmed"`.""" + roll: int | None = None + """The d20 as it landed, before modifiers. `None` on an automatic hit.""" + modifier: int = 0 + """Everything added to the roll: strength, range band, magic, spells, the situation you attested.""" + total: int | None = None + """The roll plus the modifier, which is the number compared against `required`. `None` on an automatic + hit.""" + required: int | None = None + """The total the attacker needed for a hit, worked out from its attack table and the defender's armour + class. `None` on an automatic hit.""" + defender_ac: int | None = None + """The defender's armour class as the attack saw it, after shields, spells, and the situation.""" + natural: int | None = None + """Set to 1 or 20 when the unmodified d20 settled the outcome against what the total said, since a natural + 20 always hits and a natural 1 always misses. `None` otherwise, including when the natural roll and the total + agreed.""" class DamageDealtEvent(Event): - """Damage applied to a creature. + """Damage landed on a creature. + + [`deal_damage`][osrlib.core.combat.deal_damage] emits one of these for every packet of damage, whether it + came from a weapon, a spell, or an effect like burning oil. - Emitted by [`deal_damage`][osrlib.core.combat.deal_damage], from a weapon attack, a - spell, or an effect such as splash damage. Carries the amount only — never the - target's remaining hit points: monster HP is hidden by design, and the - referee-visibility - [`HitPointsReportedEvent`][osrlib.core.events.HitPointsReportedEvent] carries it. + It says how much was dealt and never how much the target has left, because B/X hides monster hit points. The + remaining total rides the referee-visible + [`HitPointsReportedEvent`][osrlib.core.events.HitPointsReportedEvent] that follows it. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"combat.damage.dealt"}) + """The only code this event uses.""" event_type: Literal["damage_dealt"] = "damage_dealt" + """The wire name for this event type.""" + code: str = "combat.damage.dealt" + """Fixed at `combat.damage.dealt`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: how hard something was hit is plain to see.""" + target_id: str + """The entity id of whoever took the damage.""" + attacker_id: str | None = None + """The entity id of whoever dealt it, or `None` when nothing did: a fall, a trap, a hazard.""" + amount: int + """The hit points actually taken off, after every resistance and reduction.""" + rolls: tuple[int, ...] = () + """The individual dice the damage was rolled on, before modifiers. Empty for damage that was not rolled.""" + keys: tuple[str, ...] = () + """What the damage presented to the target's defenses: material and enchantment keys like `silver`, + `magic`, or `holy`, plus the energy element when there was one.""" + non_regenerable: bool = False + """True when this damage cannot be regenerated away, which happens when its element is one the target's + regeneration is blocked by, as fire and acid are for a troll.""" class DamageAbsorbedEvent(Event): - """A hit absorbed by an immunity gate: no damage was rolled. + """A hit that landed and did nothing, because the target cannot be harmed by that kind of attack. - Emitted in place of - [`DamageDealtEvent`][osrlib.core.events.DamageDealtEvent] when a - `harmed_only_by` or energy defense excludes the source. + This arrives instead of [`DamageDealtEvent`][osrlib.core.events.DamageDealtEvent] when the target's defenses + shut the source out altogether: a creature that only silver or magic can touch, or one immune to the energy + in play. No damage was rolled, so treat it as a hit that accomplished nothing rather than as a miss. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"combat.damage.absorbed"}) + """The only code this event uses.""" event_type: Literal["damage_absorbed"] = "damage_absorbed" + """The wire name for this event type.""" + code: str = "combat.damage.absorbed" + """Fixed at `combat.damage.absorbed`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the blow landing and doing nothing is something the players watch happen.""" + target_id: str + """The entity id of the creature the attack could not harm.""" + attacker_id: str | None = None + """The entity id of the attacker, or `None` when nothing was attacking.""" + keys: tuple[str, ...] = () + """What the attack presented to the defenses, which is what they turned away: keys like `silver` or + `magic`, plus the energy element when there was one.""" class SavingThrowRolledEvent(Event): - """A saving throw resolved; `roll` and `required` are `None` for auto-save defenses. + """A saving throw and how it went. + + [`saving_throw`][osrlib.core.combat.saving_throw] emits one of these whenever a creature gets a chance to + avoid something. What passing or failing means belongs to whatever called for the save, so read this event + alongside the ones around it. - Emitted by [`saving_throw`][osrlib.core.combat.saving_throw]. `code` is - `combat.save.passed` or `combat.save.failed` for a rolled save, or - `combat.save.auto` when a defense auto-passes it. + A creature whose defenses pass the save for it rolls nothing, and then `roll` and `required` are both `None`. """ allowed_codes: ClassVar[frozenset[str]] = frozenset( {"combat.save.passed", "combat.save.failed", "combat.save.auto"} ) + """The three outcomes: passed, failed, or passed automatically without a roll.""" event_type: Literal["saving_throw_rolled"] = "saving_throw_rolled" + """The wire name for this event type.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: saving throws are rolled in the open, monsters included.""" + target_id: str + """The entity id of whoever saved.""" + category: str + """Which of the five saving throws it was, as a + [`SaveCategory`][osrlib.core.combat.SaveCategory] value: `death`, `wands`, `paralysis`, `breath`, or + `spells`.""" + roll: int | None = None + """The d20 as it landed, before modifiers. `None` on an automatic save.""" + modifier: int = 0 + """Everything added to the roll: a ward, a ring, the situation the caller attested.""" + required: int | None = None + """The number the creature needed to meet or beat. `None` on an automatic save.""" class MoraleCheckedEvent(Event): - """A morale check: referee visibility — players learn the outcome from behavior. + """A side's nerve tested, and whether it held. + + [`check_morale`][osrlib.core.combat.check_morale] emits one of these when a fight gives a side reason to + reconsider. A broken side flees or surrenders, and acting on that is the caller's job. - Emitted by [`check_morale`][osrlib.core.combat.check_morale]. `code` is - `combat.morale.held` or `combat.morale.broke` for a rolled check, or - `combat.morale.exempt` when the score is 2 (never fights) or 12 (never checks) - and no roll is made. + Some morale scores never roll at all. A score of 2 means the creature never fights, and 12 means it never + checks, and both report as exempt with no roll. """ allowed_codes: ClassVar[frozenset[str]] = frozenset( {"combat.morale.held", "combat.morale.broke", "combat.morale.exempt"} ) + """The three outcomes: the side held, the side broke, or the score exempted it from checking.""" event_type: Literal["morale_checked"] = "morale_checked" + """The wire name for this event type.""" + visibility: Visibility = Visibility.REFEREE + """Referee visibility: the players find out a side has broken by watching it run.""" + subject: str + """Whose morale was checked, by the side key the caller passed in.""" + score: int + """The side's morale score, from 2 to 12.""" + roll: int | None = None + """The 2d6 total, before modifiers. `None` when the score exempted the side from rolling.""" + modifier: int = 0 + """The situational adjustment applied, which B/X caps at plus or minus 2.""" class ReactionRolledEvent(Event): - """A monster reaction roll: referee visibility — players learn reactions from behavior. + """How a monster takes to meeting the party. - Emitted by [`roll_reaction`][osrlib.core.combat.roll_reaction]. + [`roll_reaction`][osrlib.core.combat.roll_reaction] emits one of these at the start of an encounter that is + not already a fight. The result tells you how to play the monster. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"encounter.reaction.rolled"}) + """The only code this event uses.""" event_type: Literal["reaction_rolled"] = "reaction_rolled" + """The wire name for this event type.""" + code: str = "encounter.reaction.rolled" + """Fixed at `encounter.reaction.rolled`.""" + visibility: Visibility = Visibility.REFEREE + """Referee visibility: the players read a monster's mood from how it behaves, not from the dice.""" + roll: int + """The 2d6 total, before the modifier.""" + modifier: int = 0 + """The speaking character's charisma reaction adjustment, when one applied.""" + total: int + """The roll plus the modifier, which is what the reaction table was read with. A total outside 2 to 12 reads + as the nearest end of the table.""" + result: str + """The band the total fell in, as a [`ReactionResult`][osrlib.core.tables.ReactionResult] value: `attacks`, + `hostile`, `uncertain`, `indifferent`, or `friendly`.""" class ConditionGainedEvent(Event): - """A creature gained a condition. + """A creature has fallen asleep, been turned to stone, or otherwise taken on a named state. - Emitted by [`grant_condition`][osrlib.core.effects.grant_condition] — directly, or - through [`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach] - attaching an effect that carries one. + [`grant_condition`][osrlib.core.effects.grant_condition] emits this, whether you called it yourself or + [`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach] called it for you while attaching an + effect that brings a condition with it. A creature immune to the condition produces no event at all. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"effects.condition.gained"}) + """The only code this event uses.""" event_type: Literal["condition_gained"] = "condition_gained" + """The wire name for this event type.""" + code: str = "effects.condition.gained" + """Fixed at `effects.condition.gained`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: a creature going rigid or falling asleep is plain to see.""" + target_id: str + """The entity id of the creature that gained the condition.""" + condition: str + """The condition, as a [`Condition`][osrlib.core.effects.Condition] value like `asleep` or `petrified`.""" + effect_id: str | None = None + """The id of the effect that granted it and will take it back, or `None` for a state no effect owns, which + in the core rules means `dead`.""" class ConditionRemovedEvent(Event): - """A creature lost a condition. + """A creature is out of a named state: awake again, unfrozen, no longer entangled. - Emitted by [`remove_condition`][osrlib.core.effects.remove_condition] — directly, - or through [`EffectsLedger.release`][osrlib.core.effects.EffectsLedger.release] - or expiry removing the effect that granted it. + [`remove_condition`][osrlib.core.effects.remove_condition] emits this, whether you called it yourself or the + effects ledger called it for you when the owning effect expired or was released. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"effects.condition.removed"}) + """The only code this event uses.""" event_type: Literal["condition_removed"] = "condition_removed" + """The wire name for this event type.""" + code: str = "effects.condition.removed" + """Fixed at `effects.condition.removed`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: a creature coming out of it is plain to see.""" + target_id: str + """The entity id of the creature that lost the condition.""" + condition: str + """The condition, as a [`Condition`][osrlib.core.effects.Condition] value.""" + effect_id: str | None = None + """The id of the effect that had granted it, or `None` for a state no effect owned.""" class EffectAttachedEvent(Event): - """An effect attached to a creature, item, or location (referee bookkeeping). + """An effect has started running on a creature, item, or location. - Emitted by [`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach]. + [`EffectsLedger.attach`][osrlib.core.effects.EffectsLedger.attach] emits this first, before any condition or + modifier the effect grants. Keep the `effect_id` if you plan to end the effect early through + [`EffectsLedger.release`][osrlib.core.effects.EffectsLedger.release]. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"effects.effect.attached"}) + """The only code this event uses.""" event_type: Literal["effect_attached"] = "effect_attached" + """The wire name for this event type.""" + code: str = "effects.effect.attached" + """Fixed at `effects.effect.attached`.""" + visibility: Visibility = Visibility.REFEREE + """Referee visibility: this is the ledger's bookkeeping. What the players notice is the condition the effect + granted, which arrives as its own event.""" + effect_id: str + """The id the ledger allocated for the effect, in the form `effect-0001`.""" + kind: str + """The effect's kind, from its definition: `"sleep"`, `"regeneration"`, `"light"`, and so on.""" + target_ref: str + """What it attached to: an entity id for a creature, or a location string for something that sits in a + place.""" + expires_round: int | None = None + """The absolute round the effect is due to end on, or `None` when it has no duration or is permanent.""" class EffectTickedEvent(Event): - """An effect's periodic tick resolved (referee bookkeeping). + """An effect did its periodic thing this round. - Emitted by [`EffectsLedger.advance`][osrlib.core.effects.EffectsLedger.advance] - (regeneration, a charm's re-save) or - [`pop_mirror_image`][osrlib.core.spells.pop_mirror_image] popping one figment. + [`EffectsLedger.advance`][osrlib.core.effects.EffectsLedger.advance] emits this each time a regeneration + heals or a charmed creature rolls its recurring save, and + [`pop_mirror_image`][osrlib.core.spells.pop_mirror_image] emits it when an attack destroys one duplicate. + What the tick did arrives as the events beside it: healing, a saving throw, a release. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"effects.effect.ticked"}) + """The only code this event uses.""" event_type: Literal["effect_ticked"] = "effect_ticked" + """The wire name for this event type.""" + code: str = "effects.effect.ticked" + """Fixed at `effects.effect.ticked`.""" + visibility: Visibility = Visibility.REFEREE + """Referee visibility: this is the ledger's bookkeeping.""" + effect_id: str + """The id of the effect that ticked.""" + kind: str + """The effect's kind, from its definition.""" + target_ref: str + """The entity id or location string the effect is attached to.""" + round: int + """The absolute round the tick resolved on.""" class EffectExpiredEvent(Event): - """An effect's duration ran out (referee bookkeeping). + """An effect ran out of time and stopped. - Emitted by [`EffectsLedger.advance`][osrlib.core.effects.EffectsLedger.advance]. + [`EffectsLedger.advance`][osrlib.core.effects.EffectsLedger.advance] emits this when the clock reaches an + effect's expiry round. Any condition or modifier it granted comes off in the same breath, and an effect with + an expiry outcome, like a delayed poison, resolves that outcome here too. An effect you ended early + reports as [`EffectReleasedEvent`][osrlib.core.events.EffectReleasedEvent] instead. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"effects.effect.expired"}) + """The only code this event uses.""" event_type: Literal["effect_expired"] = "effect_expired" + """The wire name for this event type.""" + code: str = "effects.effect.expired" + """Fixed at `effects.effect.expired`.""" + visibility: Visibility = Visibility.REFEREE + """Referee visibility: this is the ledger's bookkeeping. A crawl session translates the expiry of a light + source into a player-facing event of its own.""" + effect_id: str + """The id of the effect that ended.""" + kind: str + """The effect's kind, from its definition.""" + target_ref: str + """The entity id or location string the effect had been attached to.""" + round: int + """The absolute round it expired on.""" class EffectReleasedEvent(Event): - """An effect explicitly released before expiry (referee bookkeeping). + """An effect was ended early, before its time was up. - Emitted by [`EffectsLedger.release`][osrlib.core.effects.EffectsLedger.release] — a - *dispel magic* or a charm's passed re-save, for example. + [`EffectsLedger.release`][osrlib.core.effects.EffectsLedger.release] emits this: a *dispel magic* stripping + an enchantment, a charmed creature making its save, a torch put out. An effect that ran out of time on its + own reports as [`EffectExpiredEvent`][osrlib.core.events.EffectExpiredEvent] instead. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"effects.effect.released"}) + """The only code this event uses.""" event_type: Literal["effect_released"] = "effect_released" + """The wire name for this event type.""" + code: str = "effects.effect.released" + """Fixed at `effects.effect.released`.""" + visibility: Visibility = Visibility.REFEREE + """Referee visibility: this is the ledger's bookkeeping.""" + effect_id: str + """The id of the effect that was ended.""" + kind: str + """The effect's kind, from its definition.""" + target_ref: str + """The entity id or location string the effect had been attached to.""" class HealingAppliedEvent(Event): - """Healing applied — or blocked (mummy rot renders magical healing ineffective). + """Hit points restored, or refused. + + [`apply_healing`][osrlib.core.combat.apply_healing] emits this, and so does a regeneration tick inside + [`EffectsLedger.advance`][osrlib.core.effects.EffectsLedger.advance]. Read `amount` rather than what was + asked for: healing is capped at the hit points the creature was missing, and a cursed creature may have had + it halved. - Emitted by [`apply_healing`][osrlib.core.combat.apply_healing] or a regeneration - tick. `code` is `combat.healing.applied` normally, or `combat.healing.blocked` - when a condition blocks it. + Healing can also be refused outright, and then the code says blocked and `amount` is 0. A creature that's + diseased takes no magical healing, and one that's weakened takes none at all. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"combat.healing.applied", "combat.healing.blocked"}) + """The two outcomes: healing landed, or a condition refused it.""" event_type: Literal["healing_applied"] = "healing_applied" + """The wire name for this event type.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: a wound closing is plain to see. The hit points behind it ride the referee-visible + [`HitPointsReportedEvent`][osrlib.core.events.HitPointsReportedEvent].""" + target_id: str + """The entity id of whoever was healed.""" + amount: int + """The hit points actually restored, after the cap and any halving. 0 when the healing was blocked.""" + source: str + """Where the healing came from: `magical` for a spell or potion, `natural` for rest, `regeneration` for a + regenerating creature's own tick.""" class DeathEvent(Event): - """A creature reduced to 0 hit points or less is killed. + """A creature has been killed. + + [`kill`][osrlib.core.effects.kill] emits this, whether death came from damage, from a failed save, or from an + effect running out. It arrives after the [`ConditionGainedEvent`][osrlib.core.events.ConditionGainedEvent] + for `dead` and before the hit point report. - Emitted by [`kill`][osrlib.core.effects.kill]. `code` is `combat.death.died` for an - ordinary death, or `combat.death.permanent` when a regenerating creature's - non-regenerable damage ledger reaches max hit points, ending any chance of - revival (the troll). + The code tells you whether the death can be undone. An ordinary death can be, by magic or by regeneration. + A permanent one cannot, which is what fire and acid do to a troll. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"combat.death.died", "combat.death.permanent"}) + """The two outcomes: an ordinary death, or one nothing can bring the creature back from.""" event_type: Literal["death"] = "death" + """The wire name for this event type.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: a creature falling is plain to see.""" + target_id: str + """The entity id of whoever died.""" class EquipmentDestroyedEvent(Event): - """A victim's equipment destroyed by a destructive death (dragon breath). + """What a victim was carrying burned with them. - Emitted by [`destroy_equipment`][osrlib.core.combat.destroy_equipment]. + [`destroy_equipment`][osrlib.core.combat.destroy_equipment] emits this when a death destroys the body and + everything on it: dragon breath, a *disintegrate*. An empty inventory produces no event. - `item_names` are the destroyed items; `saved_items` (additive) are the instance - ids of magic items that passed the `magic_item_death_save` roll — the crawl - lands them in a drop pile at the victim's cell. + Under the ruleset's magic item death save, each magic item rolls to survive, and the ones that make it are + listed separately. A crawl session drops them where the victim fell. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"combat.equipment.destroyed"}) + """The only code this event uses.""" event_type: Literal["equipment_destroyed"] = "equipment_destroyed" + """The wire name for this event type.""" + code: str = "combat.equipment.destroyed" + """Fixed at `combat.equipment.destroyed`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the loss is the players' to feel.""" + target_id: str + """The entity id of the victim.""" + item_names: tuple[str, ...] + """The display names of the items that were destroyed.""" + saved_items: tuple[str, ...] = () + """The instance ids of the magic items that passed their save and still exist. Empty when nothing survived, + which is always the case when the ruleset has the magic item death save turned off.""" class LevelDrainedEvent(Event): - """Energy drain resolved: levels lost, with the terminal case as its own code. + """An undead creature's touch has taken levels, or taken everything. - Emitted by [`drain_monster_hd`][osrlib.core.combat.drain_monster_hd] or - [`drain_levels`][osrlib.core.classes.drain_levels]. `code` is - `combat.drain.drained` for levels lost, or `combat.drain.slain` for the terminal - case — the victim is already at level 1 (or 1 Hit Die or fewer) with nowhere - left to drain, so the drain kills it outright. + [`drain_levels`][osrlib.core.classes.drain_levels] emits this for a character and + [`drain_monster_hd`][osrlib.core.combat.drain_monster_hd] for a monster, whose Hit Dice drain the same way. - `spawn_consequence` is the structured-but-manual field carrying the SRD's spawn - prose ("becomes a wight in 1d4 days, under the control of the wight that killed - them") — the kernel kills, the game narrates. + When the victim has nothing left to lose, at level 1 or one Hit Die, the drain kills instead, the code says + slain, and a [`DeathEvent`][osrlib.core.events.DeathEvent] follows. The killing level counts as lost, so a + spectre draining a level-2 fighter reports two levels gone. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"combat.drain.drained", "combat.drain.slain"}) + """The two outcomes: levels lost, or the victim drained to death.""" event_type: Literal["level_drained"] = "level_drained" + """The wire name for this event type.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: losing a level is the players' to know.""" + target_id: str + """The entity id of the victim.""" + levels_lost: int + """How many levels, or Hit Dice, the drain took.""" + new_level: int + """What the victim is now, and 0 when the drain killed it.""" + hp_lost: int + """The maximum hit points that went with the levels.""" + xp_after: int | None = None + """The character's experience points after the drain, set by the draining monster's own experience policy: + halfway between the old and new level thresholds, or the new level's minimum. `None` for a monster, whose + Hit Dice are worth no experience points, and for a victim the drain killed.""" + spawn_consequence: str | None = None + """The printed consequence of dying to this particular undead, like becoming a wight in 1d4 days under the + control of the one that killed you. osrlib kills the victim, and turning them into something is yours to + play. `None` when the source has no such consequence.""" class MonsterRevivedEvent(Event): - """A regenerating monster returned from death (the troll's 2d6-round revival). + """A monster the party thought dead has got back up. - Emitted by a regeneration tick inside - [`EffectsLedger.advance`][osrlib.core.effects.EffectsLedger.advance]. + A regeneration tick inside [`EffectsLedger.advance`][osrlib.core.effects.EffectsLedger.advance] emits this + when the countdown runs out, which for a troll is 2d6 rounds after it fell. The monster comes back on one + hit point, and the `dead` condition comes off in the same breath. It stops happening once the damage the + monster cannot regenerate, fire and acid for a troll, adds up to its full hit points. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"effects.regeneration.revived"}) + """The only code this event uses.""" event_type: Literal["monster_revived"] = "monster_revived" + """The wire name for this event type.""" + code: str = "effects.regeneration.revived" + """Fixed at `effects.regeneration.revived`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the players find out the hard way.""" + target_id: str + """The entity id of the monster that came back.""" class HitPointsReportedEvent(Event): - """A creature's hit point state — referee visibility: monster HP is hidden by design. + """Where a creature's hit points stand after something changed them. + + Anything that moves hit points emits one of these right after it: damage, healing, death, energy drain, a + regeneration tick. Follow them and you always know the true state without reading the creature objects. - Emitted alongside damage, healing, death, energy drain, and regeneration — any - change to a creature's current or maximum hit points — so the referee's view - stays in sync. + This is the only event that reports hit points, and it's referee-visible on purpose, because B/X keeps + monster hit points hidden. Show the players what happened from + [`DamageDealtEvent`][osrlib.core.events.DamageDealtEvent] and its kin instead. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"combat.state.hit_points"}) + """The only code this event uses.""" event_type: Literal["hit_points_reported"] = "hit_points_reported" + """The wire name for this event type.""" + code: str = "combat.state.hit_points" + """Fixed at `combat.state.hit_points`.""" + visibility: Visibility = Visibility.REFEREE + """Referee visibility: monster hit points are hidden by design.""" + target_id: str + """The entity id of the creature reported on.""" + current_hp: int + """Hit points now, never below 0.""" + max_hp: int + """Hit points at full, which energy drain can lower.""" class TargetsSelectedEvent(Event): - """The targeting model's resolution: which candidates an effect selected. + """Who a spell, a breath weapon, or a thrown flask ended up catching. - Emitted by [`select_targets`][osrlib.core.combat.select_targets]. + [`select_targets`][osrlib.core.combat.select_targets] emits this before the resolution that follows, so a log + records which candidates a Hit Dice budget or an area actually reached rather than only who was standing + nearby. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"combat.targeting.selected"}) + """The only code this event uses.""" event_type: Literal["targets_selected"] = "targets_selected" + """The wire name for this event type.""" + code: str = "combat.targeting.selected" + """Fixed at `combat.targeting.selected`.""" + visibility: Visibility = Visibility.REFEREE + """Referee visibility: this is bookkeeping. What the targets then suffer arrives as its own events.""" + mode: str + """How targets were chosen, as a [`TargetingMode`][osrlib.core.combat.TargetingMode] value: `self`, + `single`, `up_to_n`, `hd_budget`, `area`, or `gaze`.""" + target_ids: tuple[str, ...] + """The entity ids selected, in resolution order.""" class PreparedSpell(BaseModel): - """One prepared copy in a memorization event: the spell and its fixed form.""" + """One spell a caster has in memory, as it appears on a memorization event. + + You read these off a [`SpellsMemorizedEvent`][osrlib.core.events.SpellsMemorizedEvent]'s `prepared` tuple. + + A caster who prepares a reversible spell chooses which way round it goes at preparation time, not at casting + time, so each prepared copy records the form it's locked into. + """ model_config = ConfigDict(frozen=True) spell_id: str + """The spell's content id, like `"magic_missile"`.""" + reversed: bool = False + """True when the copy was prepared in the spell's reversed form, as *cause light wounds* is the reverse of + *cure light wounds*.""" class SpellsMemorizedEvent(Event): - """A caster's daily preparation resolved: the full prepared list. + """A caster has finished preparing spells for the day. - Emitted by [`memorize_spells`][osrlib.core.spells.memorize_spells]. One event per - preparation — memorization is a full replacement, so the list is the caster's - complete new memory. + [`memorize_spells`][osrlib.core.spells.memorize_spells] emits one of these per preparation. Preparation + replaces everything the caster had in memory, so `prepared` is the whole new memory rather than what was + added to it. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.memorize.prepared"}) + """The only code this event uses.""" event_type: Literal["spells_memorized"] = "spells_memorized" + """The wire name for this event type.""" + code: str = "magic.memorize.prepared" + """Fixed at `magic.memorize.prepared`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the caster's own memory is the player's to see.""" + caster_id: str + """The entity id of the caster.""" + prepared: tuple[PreparedSpell, ...] + """Every copy now in memory, as [`PreparedSpell`][osrlib.core.events.PreparedSpell] entries. A spell + prepared twice appears twice.""" class SpellCastEvent(Event): - """A spell cast: the memorized copy was consumed. + """A spell was cast and its memorized copy spent. - Emitted by [`cast_spell`][osrlib.core.spells.cast_spell] or - [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll]. `code` is - `magic.cast.cast` for an ordinary cast, or `magic.cast.no_effect` when every - target was ineligible or unaffected — the copy is still spent, since rejections - are free and would leak hidden state about which targets were eligible. + [`cast_spell`][osrlib.core.spells.cast_spell] and + [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] emit this. What the spell then did arrives as the + events beside it: saving throws, damage, conditions, effects, healing, deaths. - `manual=True` marks modes the kernel doesn't execute — the game narrates the - effect from the spell's prose. Resolution consequences ride the existing event - types (saves, damage, conditions, effects, healing, deaths), exactly as breath - weapons do. + A cast that found nothing to work on reports the no-effect code and still spends the copy. Refusing the cast + instead would tell the player which targets were eligible, and B/X doesn't give that away for free. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.cast.cast", "magic.cast.no_effect"}) + """The two outcomes: the spell landed, or every target was out of its reach.""" event_type: Literal["spell_cast"] = "spell_cast" + """The wire name for this event type.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: casting a spell is done out loud.""" + caster_id: str + """The entity id of the caster.""" + spell_id: str + """The spell's content id, like `"magic_missile"`.""" + mode: str + """Which of the spell's modes was used, by the mode's own name. A spell with several modes, like one that + can damage or heal, names the one that resolved.""" + reversed: bool = False + """True when the spell was cast in its reversed form.""" + target_ids: tuple[str, ...] = () + """The entity ids the spell was aimed at. Empty for a spell that targets no creature.""" + manual: bool = False + """True when osrlib didn't resolve the spell's effect, because the mode is one the rules leave to the + table. The copy is spent and the outcome is yours to narrate from the spell's printed text.""" class SpellDisruptedEvent(Event): - """A declared casting disrupted: the copy is lost as if it had been cast. + """A caster was interrupted and the spell came to nothing. - Emitted by [`disrupt_casting`][osrlib.core.spells.disrupt_casting]. + [`disrupt_casting`][osrlib.core.spells.disrupt_casting] emits this when a caster who declared a spell is hit, + or otherwise stopped, before it goes off. The memorized copy is lost exactly as if it had been cast. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.cast.disrupted"}) + """The only code this event uses.""" event_type: Literal["spell_disrupted"] = "spell_disrupted" + """The wire name for this event type.""" + code: str = "magic.cast.disrupted" + """Fixed at `magic.cast.disrupted`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the caster's spell visibly fails.""" + caster_id: str + """The entity id of the caster.""" + spell_id: str + """The spell's content id.""" + reversed: bool = False + """True when the lost copy was the spell's reversed form.""" class SpellForgottenEvent(Event): - """A memorized copy forgotten because level drain shrank the caster's slots. + """A memorized spell slipped away because the caster no longer has room for it. - Emitted by - [`forget_excess_memorized`][osrlib.core.spells.forget_excess_memorized]. + [`forget_excess_memorized`][osrlib.core.spells.forget_excess_memorized] emits one of these per lost copy, + which happens when energy drain takes levels and the caster's allowance shrinks below what is in memory. One + event names one copy, so a caster who lost two gets two events. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.memory.forgotten"}) + """The only code this event uses.""" event_type: Literal["spell_forgotten"] = "spell_forgotten" + """The wire name for this event type.""" + code: str = "magic.memory.forgotten" + """Fixed at `magic.memory.forgotten`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the caster's memory is the player's to track.""" + caster_id: str + """The entity id of the caster.""" + spell_id: str + """The spell's content id.""" + reversed: bool = False + """True when the forgotten copy was the spell's reversed form.""" class SpellBookUpdatedEvent(Event): - """A spell added to an arcane caster's spell book. + """A new spell has gone into an arcane caster's book. - Emitted by [`add_spell_to_book`][osrlib.core.spells.add_spell_to_book]. + [`add_spell_to_book`][osrlib.core.spells.add_spell_to_book] emits this. A spell in the book is one the caster + may prepare. Getting it there is a separate matter from preparing it, which reports as + [`SpellsMemorizedEvent`][osrlib.core.events.SpellsMemorizedEvent]. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.book.added"}) + """The only code this event uses.""" event_type: Literal["spell_book_updated"] = "spell_book_updated" + """The wire name for this event type.""" + code: str = "magic.book.added" + """Fixed at `magic.book.added`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the book belongs to the player.""" + caster_id: str + """The entity id of the caster whose book gained the spell.""" + spell_id: str + """The spell's content id.""" class TurningTypeOutcome(BaseModel): - """One undead type's turning verdict: its column, the cell, and the threshold.""" + """How one kind of undead fared against a turning attempt. + + Turning is read off a table once per kind of undead present, not once per monster, and these are those + readings. You find them on an [`UndeadTurnedEvent`][osrlib.core.events.UndeadTurnedEvent]'s `types`. + """ model_config = ConfigDict(frozen=True) template_id: str + """The monster template's content id, like `"skeleton"`.""" + column: str | None = None + """The turning table column this kind is read under, which follows its Hit Dice. `None` when the kind is too + strong for the table to give it a column.""" + outcome: str + """The verdict: `turn` when the roll met the threshold, `fail` when it didn't or there was no column, + `destroy` when the table destroys this kind outright at the cleric's level, and `unaffected` for a creature + that isn't undead at all.""" + threshold: int | None = None + """The 2d6 total the cleric needed for this kind. `None` when the table gives an automatic result rather than + a number.""" class UndeadTurnedEvent(Event): - """A turning attempt resolved — player visibility: the player rolls turning dice. + """A cleric held up a holy symbol, and this is what happened. - Emitted by [`turn_undead`][osrlib.core.spells.turn_undead]. - - Carries the 2d6 turn roll, the 2d6 HD pool when one was rolled (some type - succeeded), the per-type verdicts, and the affected monsters. Per-monster - consequences ride `ConditionGainedEvent`/`DeathEvent`. The code is `failed` when - no type succeeded, `destroyed` when any affected monster was destroyed, and - `turned` otherwise. + [`turn_undead`][osrlib.core.spells.turn_undead] emits one of these per attempt. Turning resolves in two + steps: the table is read once per kind of undead present, and then a second roll decides how many Hit Dice + of the kinds that succumbed are actually affected. What then happens to each monster arrives as its own + events, a condition gained or a death. """ allowed_codes: ClassVar[frozenset[str]] = frozenset( {"magic.turning.turned", "magic.turning.destroyed", "magic.turning.failed"} ) + """The three outcomes: nothing succumbed, something was turned, or something was destroyed outright.""" event_type: Literal["undead_turned"] = "undead_turned" + """The wire name for this event type.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the player rolls the turning dice.""" + caster_id: str + """The entity id of the cleric who turned.""" + roll: int + """The 2d6 read against the turning table.""" + hd_pool: int | None = None + """The second 2d6, the Hit Dice of undead the turning reaches. `None` when nothing succumbed and no second + roll was made. Undead are taken weakest first, and Hit Dice left over when the next one is too large go to + waste rather than being spent elsewhere.""" + types: tuple[TurningTypeOutcome, ...] = () + """One [`TurningTypeOutcome`][osrlib.core.events.TurningTypeOutcome] per kind of undead present, in the order + the candidates were given.""" + affected_ids: tuple[str, ...] = () + """The entity ids of the monsters the Hit Dice pool actually reached. At least one monster is always + affected when any kind succumbed, even when the pool could not pay for it.""" class MagicDispelledEvent(Event): - """A *dispel magic* resolved: which effects were released and which survived. + """A *dispel magic* went off, and these enchantments went with it. - Emitted by [`cast_spell`][osrlib.core.spells.cast_spell] when a *dispel - magic*-kind spell resolves. + [`cast_spell`][osrlib.core.spells.cast_spell] emits this when a dispelling spell resolves. Every dispellable + effect on the targets goes, except that one put there by a higher-level caster gets a roll to survive, better + the wider the gap in levels, so a single dispel can take some enchantments and leave others. What a monster + inflicted is not dispellable at all. The effects that went are also reported one by one as + [`EffectReleasedEvent`][osrlib.core.events.EffectReleasedEvent]. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.dispel.resolved"}) + """The only code this event uses.""" event_type: Literal["magic_dispelled"] = "magic_dispelled" + """The wire name for this event type.""" + code: str = "magic.dispel.resolved" + """Fixed at `magic.dispel.resolved`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the spell and what it undid are the players' to see.""" + caster_id: str + """The entity id of the caster who dispelled.""" + released_effect_ids: tuple[str, ...] = () + """The ids of the effects the dispel ended.""" + surviving_effect_ids: tuple[str, ...] = () + """The ids of the dispellable effects that survived.""" KERNEL_EVENT_CLASSES: tuple[type[Event], ...] = ( @@ -709,7 +1216,22 @@ class MagicDispelledEvent(Event): UndeadTurnedEvent, MagicDispelledEvent, ) -"""Every kernel event class, in declaration order — the discriminated union's members.""" +"""Every kernel event class as a tuple, in declaration order. A kernel event is one the core rules emit. + +Iterate it when you need the set itself rather than one event: to build a table of handlers, to generate JSON +Schema for each type, to check that your consumer covers everything. The same classes make up +[`KernelEvent`][osrlib.core.events.KernelEvent], which is what you annotate and validate with. + +This contains the core rules' events alone. For those plus the crawl's, use +[`ALL_EVENT_CLASSES`][osrlib.crawl.events.ALL_EVENT_CLASSES]. + +```python +from osrlib.core.events import KERNEL_EVENT_CLASSES + +by_type = {cls.model_fields["event_type"].default: cls for cls in KERNEL_EVENT_CLASSES} +assert by_type["damage_dealt"].__name__ == "DamageDealtEvent" +``` +""" KernelEvent = Annotated[ InitiativeRolledEvent @@ -741,7 +1263,28 @@ class MagicDispelledEvent(Event): | MagicDispelledEvent, Field(discriminator="event_type"), ] -"""Any kernel event, discriminated by `event_type`.""" +"""The union of every event the core rules emit, tagged by `event_type`. + +Annotate with this where a value is one specific event the core rules emit and you want the exact type back, +and hand it to a +pydantic `TypeAdapter` to validate serialized events. Because the union is discriminated, validation reads +`event_type` and goes straight to the right class instead of trying each in turn, and the JSON Schema it +generates is a tagged union your API consumers can read. + +Prefer [`parse_event`][osrlib.core.events.parse_event] for reading back a stored log: it does the same +validation but returns `None` for an event type this release doesn't know, rather than raising. Annotate with +[`Event`][osrlib.core.events.Event] instead where any event will do. + +```python +from pydantic import TypeAdapter + +from osrlib.core.events import DamageDealtEvent, KernelEvent + +adapter = TypeAdapter(KernelEvent) +payload = DamageDealtEvent(target_id="monster-0001", amount=5).model_dump(mode="json") +assert isinstance(adapter.validate_python(payload), DamageDealtEvent) +``` +""" @cache @@ -755,21 +1298,37 @@ def _known_event_types() -> frozenset[str]: def parse_event(data: Mapping[str, object]) -> Event | None: - """Parse one serialized kernel event, skipping unknown event types. + """Turn one stored event back into its class, or `None` if this release has never heard of its type. + + Use this to read a saved log or a stream of events from somewhere else. Skipping what it doesn't recognize + is the point: a log written by a newer release replays under an older one, minus the events the older one has + no class for. Filter the `None` results out and carry on. - The mechanical half of "consumers must ignore unknown event types": an - `event_type` this library doesn't know returns `None` instead of raising, so a - newer producer's log replays under an older consumer. + It covers the core rules' events only. To read a log that also contains the crawl's, use + [`parse_any_event`][osrlib.crawl.events.parse_any_event], which covers both. Args: - data: A mapping previously produced by an event's `model_dump`. + data: One event as a mapping, of the shape `model_dump` produces. Returns: - The event, or `None` when its `event_type` is unknown. + The event as its own class, or `None` when the mapping's `event_type` is one this release doesn't + define. Raises: - ContentValidationError: If the event type is known but the payload is - malformed. + ContentValidationError: If the event type is one this release does define and the rest of the mapping + doesn't fit it. A malformed event of a known type is a real problem, so it is raised rather than + skipped. + + Examples: + ```python + from osrlib.core.events import DamageDealtEvent, parse_event + + event = DamageDealtEvent(target_id="monster-0001", attacker_id="hild", amount=5, rolls=(5,)) + assert parse_event(event.model_dump(mode="json")) == event + + # An event type from a release this one predates is skipped, not an error. + assert parse_event({"event_type": "something_newer", "code": "x.y", "visibility": "player"}) is None + ``` """ from osrlib.errors import ContentValidationError From 9c3edcc1543abdf789800fd449fb4e1c6b27cfb9 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 22:21:52 -0700 Subject: [PATCH 2/2] Correct the effects and events docstrings review found wrong The immunity gate in grant_condition and attach reads every condition, so "nothing in the core rules reads it" was wrong for poisoned and charmed, which many monster templates declare immunity to, and for afraid, which remove fear cures behind a save. DeathEvent has a second emitter in deal_damage with its own ordering. An expiry outcome runs after the ordinary ending, not instead of it. The two morale exemptions share a code and resolve opposite ways, and hit points are reported as a standing total only by HitPointsReportedEvent. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/core/effects.py | 66 ++++++++++++++++++++++++-------------- src/osrlib/core/events.py | 37 +++++++++++++-------- 2 files changed, 66 insertions(+), 37 deletions(-) diff --git a/src/osrlib/core/effects.py b/src/osrlib/core/effects.py index ad9fadd..0523e48 100644 --- a/src/osrlib/core/effects.py +++ b/src/osrlib/core/effects.py @@ -139,10 +139,14 @@ class Condition(StrEnum): timed effect owns. Some members drive rules in [`osrlib.core.combat`][osrlib.core.combat] and - [`osrlib.core.spells`][osrlib.core.spells], and the rest are vocabulary the rest of the game acts on. The - member docstrings below say which is which, so you know whether granting one changes a roll or only tells - your interface what to show. A condition a creature's template lists in its `condition_immunities` is never - granted to it. + [`osrlib.core.spells`][osrlib.core.spells], and the rest are states the rest of the game acts on. The member + docstrings below say which is which, so you know whether granting one changes a roll or only tells your + interface what to show. + + One rule covers every member. A creature whose template lists a condition in its defenses' + `condition_immunities` never takes that condition, whether you call + [`grant_condition`][osrlib.core.effects.grant_condition] or attach an effect that brings it, so the member + that looks inert to the rest of the core rules still decides which monsters a spell can touch. The values are the lowercase strings, and they serialize into creatures and saved games. A renamed value is a `schema_version` bump, not an edit. @@ -150,7 +154,8 @@ class Condition(StrEnum): PARALYSED = "paralysed" """Frozen in place. The creature cannot attack, cast, or move, it counts toward a side's morale check for - half the side being incapacitated, and a melee attack against it hits automatically.""" + half the side being incapacitated, and a melee attack against it hits automatically. *Cure light wounds* + cures it.""" ASLEEP = "asleep" """Unconscious. Everything `paralysed` does, and one more rule of its own: a melee hit with a bladed weapon @@ -161,33 +166,40 @@ class Condition(StrEnum): `combat.attack.attacker_blind`.""" CHARMED = "charmed" - """Under a charm. Nothing in the core rules reads it: the charmed creature's obedience is yours to play out, - and the periodic save that can end the charm rides the effect's tick instead.""" + """Under a charm. The monsters that cannot be charmed, the undead and the golems among them, list it in + their `condition_immunities`, so a charm aimed at one of those takes hold of nothing. Past that, the charmed + creature's obedience is yours to play out, and the recurring save that can end the charm rides the effect's + `charm_resave` tick.""" PETRIFIED = "petrified" """Turned to stone. Everything `paralysed` does, and it suspends the creature's other effects, which neither - tick nor age until the stone is undone. Stone is not dead, so the creature is recoverable.""" + tick nor age until the stone is undone. Stone is not dead: *stone to flesh* cures it and the creature picks + up where it left off.""" DISEASED = "diseased" """Sick with a disease. Magical healing is refused outright, and natural rest heals on the slower cadence the effect names, or not at all when you pass no ledger to - [`natural_healing`][osrlib.core.combat.natural_healing].""" + [`natural_healing`][osrlib.core.combat.natural_healing]. *Cure disease* cures it.""" EXHAUSTED = "exhausted" """Spent from a forced march or a night without rest. The penalties ride the effect's modifiers rather than - the condition, so nothing in the core rules reads the condition and your interface can show it.""" + the condition, so past the immunity rule nothing in the core rules turns on it and your interface can show + it.""" LYCANTHROPY_INCUBATION = "lycanthropy_incubation" """Infected by a lycanthrope's bite and not yet transformed. Vocabulary only: nothing in the core rules - grants it or reads it, and the transformation is yours to run.""" + grants it, nothing past the immunity rule turns on it, and the transformation is yours to run.""" AVERTED_EYES = "averted_eyes" """Fighting with eyes turned away from a gaze attack. [`resolve_gaze`][osrlib.core.combat.resolve_gaze] skips the creature, and the attack penalty for fighting blind is yours to pass in the attack context.""" POISONED = "poisoned" - """Poisoned. Vocabulary only: nothing in the core rules reads it, because a poison's bite is its effect, whose - `expiry` of `death` kills when the onset runs out.""" + """Poisoned. The monsters that cannot be poisoned, the undead and the cave locust among them, list it in + their `condition_immunities`, so a poison aimed at one of those takes hold of nothing, and *neutralize + poison* cures it and can bring back a character who died of poison within the last ten rounds. The killing + is the effect's rather than the condition's: a poison that kills carries an `expiry` of `death`, which fires + when the onset runs out.""" DEAD = "dead" """Killed. Granted by [`kill`][osrlib.core.effects.kill] rather than by any effect, so its `effect_id` is @@ -203,7 +215,8 @@ class Condition(StrEnum): can still attack and cast.""" AFRAID = "afraid" - """Panicked by a fear effect. Nothing in the core rules reads it. The battle machine in + """Panicked by a fear effect. *Remove fear* cures it, and when the fear was magical the subject first saves + versus spells at +1 for each level of the curing caster, keeping the fear on a failure. The battle machine in [`osrlib.crawl.battle`][osrlib.crawl.battle] treats the creature as routed.""" FEEBLEMINDED = "feebleminded" @@ -211,16 +224,18 @@ class Condition(StrEnum): casting with `magic.cast.caster_incapacitated`.""" INVISIBLE = "invisible" - """Unseen. Nothing in the core rules reads it. The battle machine leaves the creature out of the ranks an - enemy picks targets from.""" + """Unseen. Past the immunity rule nothing in the core rules turns on it. The battle machine leaves the + creature out of the ranks an enemy picks targets from.""" TURNED = "turned" - """Driven off by a cleric's turning. Nothing in the core rules reads it. The battle and encounter machines - treat the creature as fleeing.""" + """Driven off by a cleric's turning, which is where it comes from: + [`turn_undead`][osrlib.core.spells.turn_undead] attaches the effect that grants it. Past the immunity rule + nothing in the core rules turns on it, and the battle and encounter machines treat the creature as + fleeing.""" CONFUSED = "confused" - """Acting at random. Nothing in the core rules reads it. The battle machine chooses the creature's action - instead of letting you choose.""" + """Acting at random. Past the immunity rule nothing in the core rules turns on it. The battle machine + chooses the creature's action instead of letting you choose.""" WEAKENED = "weakened" """Drained of strength. It blocks attacking, blocks casting, and blocks all healing.""" @@ -957,10 +972,13 @@ class EffectDefinition(BaseModel): nothing at all, and the attach returns no effect.""" expiry: str | None = None - """The name of an outcome the ledger resolves when the duration runs out, instead of the effect ending on - its own. There are three. `"death"` kills the bearer, which is how a delayed poison works. - `"splash_damage"` deals the second application of burning oil or holy water. `"weakness_strength_set"` - replaces the finished onset with the curse itself. Any other name raises `ValueError` at expiry.""" + """The name of an outcome the ledger resolves when the duration runs out, on top of the ordinary ending + rather than in place of it. The effect is dropped, the + [`EffectExpiredEvent`][osrlib.core.events.EffectExpiredEvent] goes out, the condition and modifiers come + off, and the outcome runs last. There are three. `"death"` kills the bearer, which is how a delayed poison + works. `"splash_damage"` deals the second application of burning oil or holy water. + `"weakness_strength_set"` replaces the finished onset with the curse itself. Any other name raises + `ValueError` at expiry.""" condition: Condition | None = None """A [`Condition`][osrlib.core.effects.Condition] granted when the effect attaches and taken back when it diff --git a/src/osrlib/core/events.py b/src/osrlib/core/events.py index bdb6ac9..98f9e15 100644 --- a/src/osrlib/core/events.py +++ b/src/osrlib/core/events.py @@ -440,14 +440,16 @@ class MoraleCheckedEvent(Event): [`check_morale`][osrlib.core.combat.check_morale] emits one of these when a fight gives a side reason to reconsider. A broken side flees or surrenders, and acting on that is the caller's job. - Some morale scores never roll at all. A score of 2 means the creature never fights, and 12 means it never - checks, and both report as exempt with no roll. + Some morale scores never roll. A score of 2 or less never fights and a score of 12 or more never checks, + and both report `combat.morale.exempt` with no roll. Read `score` to tell them apart: at 2 or less the side + is already broken, and at 12 or more it holds. """ allowed_codes: ClassVar[frozenset[str]] = frozenset( {"combat.morale.held", "combat.morale.broke", "combat.morale.exempt"} ) - """The three outcomes: the side held, the side broke, or the score exempted it from checking.""" + """The three codes: the side held, the side broke, or the score exempted it from rolling. The exempt code + covers both exemptions, so `score` is what separates them.""" event_type: Literal["morale_checked"] = "morale_checked" """The wire name for this event type.""" @@ -459,7 +461,8 @@ class MoraleCheckedEvent(Event): """Whose morale was checked, by the side key the caller passed in.""" score: int - """The side's morale score, from 2 to 12.""" + """The side's morale score. Anything from 3 to 11 rolls. A score of 2 or less exempts the side and leaves it + broken, and 12 or more exempts it and leaves it holding.""" roll: int | None = None """The 2d6 total, before modifiers. `None` when the score exempted the side from rolling.""" @@ -635,9 +638,9 @@ class EffectExpiredEvent(Event): """An effect ran out of time and stopped. [`EffectsLedger.advance`][osrlib.core.effects.EffectsLedger.advance] emits this when the clock reaches an - effect's expiry round. Any condition or modifier it granted comes off in the same breath, and an effect with - an expiry outcome, like a delayed poison, resolves that outcome here too. An effect you ended early - reports as [`EffectReleasedEvent`][osrlib.core.events.EffectReleasedEvent] instead. + effect's expiry round. This event goes out first, then the condition and modifiers come off, and an effect + with an expiry outcome, like a delayed poison, resolves that outcome last. An effect you ended early reports + as [`EffectReleasedEvent`][osrlib.core.events.EffectReleasedEvent] instead. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"effects.effect.expired"}) @@ -732,9 +735,15 @@ class HealingAppliedEvent(Event): class DeathEvent(Event): """A creature has been killed. - [`kill`][osrlib.core.effects.kill] emits this, whether death came from damage, from a failed save, or from an - effect running out. It arrives after the [`ConditionGainedEvent`][osrlib.core.events.ConditionGainedEvent] - for `dead` and before the hit point report. + [`kill`][osrlib.core.effects.kill] emits this, whether death came from damage, from a failed save, or from + an effect running out, and there it arrives after the + [`ConditionGainedEvent`][osrlib.core.events.ConditionGainedEvent] for `dead` and before the hit point + report. + + [`deal_damage`][osrlib.core.combat.deal_damage] emits a second one, with the permanent code, the first time + a monster that is already dead takes enough damage of the kind it cannot regenerate to reach its full hit + points. That one comes after the [`HitPointsReportedEvent`][osrlib.core.events.HitPointsReportedEvent] and + has no condition event with it, because the monster was already carrying `dead`. The code tells you whether the death can be undone. An ordinary death can be, by magic or by regeneration. A permanent one cannot, which is what fire and acid do to a troll. @@ -860,9 +869,11 @@ class HitPointsReportedEvent(Event): Anything that moves hit points emits one of these right after it: damage, healing, death, energy drain, a regeneration tick. Follow them and you always know the true state without reading the creature objects. - This is the only event that reports hit points, and it's referee-visible on purpose, because B/X keeps - monster hit points hidden. Show the players what happened from - [`DamageDealtEvent`][osrlib.core.events.DamageDealtEvent] and its kin instead. + This is the only event that reports a creature's standing current and maximum. The others report the + change alone: [`DamageDealtEvent`][osrlib.core.events.DamageDealtEvent] the size of a hit, + [`LevelDrainedEvent`][osrlib.core.events.LevelDrainedEvent] the maximum a drain took off. It's + referee-visible on purpose, because B/X keeps monster hit points hidden, so show the players the change + events instead. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"combat.state.hit_points"})