diff --git a/src/osrlib/core/effects.py b/src/osrlib/core/effects.py index 6073ae7..0523e48 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,188 @@ 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 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. """ 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. *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 + 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. 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: *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]. *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 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, 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. 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 + `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. *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" + """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. 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, 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. 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.""" 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 +322,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 +379,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 +428,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 +445,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 +522,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 +628,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 +655,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 +738,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 +806,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 +856,149 @@ 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, 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 + 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 +1009,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 +1147,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 +1223,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 +1273,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 +1377,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 +1484,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 +1523,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 +1559,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..98f9e15 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. + + 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. - 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. + Events never change once made, so keep a list of them as a log and read it whenever you like. - `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. + 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,992 @@ 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. + + [`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. - 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. + 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. - 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. + [`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. + + 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. 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 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.""" + 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. 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.""" + 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. 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"}) + """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. - 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). + [`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. """ 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 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"}) + """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 +1227,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 +1274,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 +1309,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