Skip to content

osrlib.core.events

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), 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 union on it, giving native tagged-union JSON Schema for API consumers and mechanical "ignore unknown event types" — see 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.

AttackRolledEvent

Bases: Event

An attack roll resolved: the die, the modifiers, and what it needed.

Emitted by 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.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'combat.attack.hit', 'combat.attack.missed', 'combat.attack.auto_hit'})

event_type class-attribute instance-attribute

event_type: Literal['attack_rolled'] = 'attack_rolled'

visibility class-attribute instance-attribute

attacker_id instance-attribute

attacker_id: str

defender_id instance-attribute

defender_id: str

attack_name instance-attribute

attack_name: str

roll class-attribute instance-attribute

roll: int | None = None

modifier class-attribute instance-attribute

modifier: int = 0

total class-attribute instance-attribute

total: int | None = None

required class-attribute instance-attribute

required: int | None = None

defender_ac class-attribute instance-attribute

defender_ac: int | None = None

natural class-attribute instance-attribute

natural: int | None = None

ConditionGainedEvent

Bases: Event

A creature gained a condition.

Emitted by grant_condition — directly, or through EffectsLedger.attach attaching an effect that carries one.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'effects.condition.gained'})

event_type class-attribute instance-attribute

event_type: Literal['condition_gained'] = 'condition_gained'

code class-attribute instance-attribute

code: str = 'effects.condition.gained'

visibility class-attribute instance-attribute

target_id instance-attribute

target_id: str

condition instance-attribute

condition: str

effect_id class-attribute instance-attribute

effect_id: str | None = None

ConditionRemovedEvent

Bases: Event

A creature lost a condition.

Emitted by remove_condition — directly, or through EffectsLedger.release or expiry removing the effect that granted it.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'effects.condition.removed'})

event_type class-attribute instance-attribute

event_type: Literal['condition_removed'] = 'condition_removed'

code class-attribute instance-attribute

code: str = 'effects.condition.removed'

visibility class-attribute instance-attribute

target_id instance-attribute

target_id: str

condition instance-attribute

condition: str

effect_id class-attribute instance-attribute

effect_id: str | None = None

DamageAbsorbedEvent

Bases: Event

A hit absorbed by an immunity gate: no damage was rolled.

Emitted in place of DamageDealtEvent when a harmed_only_by or energy defense excludes the source.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'combat.damage.absorbed'})

event_type class-attribute instance-attribute

event_type: Literal['damage_absorbed'] = 'damage_absorbed'

code class-attribute instance-attribute

code: str = 'combat.damage.absorbed'

visibility class-attribute instance-attribute

target_id instance-attribute

target_id: str

attacker_id class-attribute instance-attribute

attacker_id: str | None = None

keys class-attribute instance-attribute

keys: tuple[str, ...] = ()

DamageDealtEvent

Bases: Event

Damage applied to a creature.

Emitted by 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 carries it.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'combat.damage.dealt'})

event_type class-attribute instance-attribute

event_type: Literal['damage_dealt'] = 'damage_dealt'

code class-attribute instance-attribute

code: str = 'combat.damage.dealt'

visibility class-attribute instance-attribute

target_id instance-attribute

target_id: str

attacker_id class-attribute instance-attribute

attacker_id: str | None = None

amount instance-attribute

amount: int

rolls class-attribute instance-attribute

rolls: tuple[int, ...] = ()

keys class-attribute instance-attribute

keys: tuple[str, ...] = ()

non_regenerable class-attribute instance-attribute

non_regenerable: bool = False

DeathEvent

Bases: Event

A creature reduced to 0 hit points or less is killed.

Emitted by 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).

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'combat.death.died', 'combat.death.permanent'})

event_type class-attribute instance-attribute

event_type: Literal['death'] = 'death'

visibility class-attribute instance-attribute

target_id instance-attribute

target_id: str

EffectAttachedEvent

Bases: Event

An effect attached to a creature, item, or location (referee bookkeeping).

Emitted by EffectsLedger.attach.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'effects.effect.attached'})

event_type class-attribute instance-attribute

event_type: Literal['effect_attached'] = 'effect_attached'

code class-attribute instance-attribute

code: str = 'effects.effect.attached'

visibility class-attribute instance-attribute

effect_id instance-attribute

effect_id: str

kind instance-attribute

kind: str

target_ref instance-attribute

target_ref: str

expires_round class-attribute instance-attribute

expires_round: int | None = None

EffectExpiredEvent

Bases: Event

An effect's duration ran out (referee bookkeeping).

Emitted by EffectsLedger.advance.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'effects.effect.expired'})

event_type class-attribute instance-attribute

event_type: Literal['effect_expired'] = 'effect_expired'

code class-attribute instance-attribute

code: str = 'effects.effect.expired'

visibility class-attribute instance-attribute

effect_id instance-attribute

effect_id: str

kind instance-attribute

kind: str

target_ref instance-attribute

target_ref: str

round instance-attribute

round: int

EffectReleasedEvent

Bases: Event

An effect explicitly released before expiry (referee bookkeeping).

Emitted by EffectsLedger.release — a dispel magic or a charm's passed re-save, for example.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'effects.effect.released'})

event_type class-attribute instance-attribute

event_type: Literal['effect_released'] = 'effect_released'

code class-attribute instance-attribute

code: str = 'effects.effect.released'

visibility class-attribute instance-attribute

effect_id instance-attribute

effect_id: str

kind instance-attribute

kind: str

target_ref instance-attribute

target_ref: str

EffectTickedEvent

Bases: Event

An effect's periodic tick resolved (referee bookkeeping).

Emitted by EffectsLedger.advance (regeneration, a charm's re-save) or pop_mirror_image popping one figment.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'effects.effect.ticked'})

event_type class-attribute instance-attribute

event_type: Literal['effect_ticked'] = 'effect_ticked'

code class-attribute instance-attribute

code: str = 'effects.effect.ticked'

visibility class-attribute instance-attribute

effect_id instance-attribute

effect_id: str

kind instance-attribute

kind: str

target_ref instance-attribute

target_ref: str

round instance-attribute

round: int

EquipmentDestroyedEvent

Bases: Event

A victim's equipment destroyed by a destructive death (dragon breath).

Emitted by destroy_equipment.

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.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'combat.equipment.destroyed'})

event_type class-attribute instance-attribute

event_type: Literal['equipment_destroyed'] = 'equipment_destroyed'

code class-attribute instance-attribute

code: str = 'combat.equipment.destroyed'

visibility class-attribute instance-attribute

target_id instance-attribute

target_id: str

item_names instance-attribute

item_names: tuple[str, ...]

saved_items class-attribute instance-attribute

saved_items: tuple[str, ...] = ()

Event

Bases: BaseModel

Base class for all osrlib events.

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.

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.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset()

code instance-attribute

code: str

visibility instance-attribute

visibility: Visibility

HealingAppliedEvent

Bases: Event

Healing applied — or blocked (mummy rot renders magical healing ineffective).

Emitted by apply_healing or a regeneration tick. code is combat.healing.applied normally, or combat.healing.blocked when a condition blocks it.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'combat.healing.applied', 'combat.healing.blocked'})

event_type class-attribute instance-attribute

event_type: Literal['healing_applied'] = 'healing_applied'

visibility class-attribute instance-attribute

target_id instance-attribute

target_id: str

amount instance-attribute

amount: int

source instance-attribute

source: str

HitPointsReportedEvent

Bases: Event

A creature's hit point state — referee visibility: monster HP is hidden by design.

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.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'combat.state.hit_points'})

event_type class-attribute instance-attribute

event_type: Literal['hit_points_reported'] = 'hit_points_reported'

code class-attribute instance-attribute

code: str = 'combat.state.hit_points'

visibility class-attribute instance-attribute

target_id instance-attribute

target_id: str

current_hp instance-attribute

current_hp: int

max_hp instance-attribute

max_hp: int

InitiativeRoll

Bases: BaseModel

One participant's (or side's) initiative rolls: re-rolls included, ties re-roll.

key instance-attribute

key: str

rolls instance-attribute

rolls: tuple[int, ...]

modifier class-attribute instance-attribute

modifier: int = 0

total instance-attribute

total: int

InitiativeRolledEvent

Bases: Event

Initiative rolled for a round: every roll (re-rolls included) and the acting order.

Emitted once per round by roll_initiative. mode is "side" when one roll sets the order for an entire side, or "individual" when each combatant rolls separately.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'combat.initiative.rolled'})

event_type class-attribute instance-attribute

event_type: Literal['initiative_rolled'] = 'initiative_rolled'

code class-attribute instance-attribute

code: str = 'combat.initiative.rolled'

visibility class-attribute instance-attribute

mode instance-attribute

mode: Literal['side', 'individual']

entries instance-attribute

entries: tuple[InitiativeRoll, ...]

order instance-attribute

order: tuple[str, ...]

LevelDrainedEvent

Bases: Event

Energy drain resolved: levels lost, with the terminal case as its own code.

Emitted by drain_monster_hd or 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.

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.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'combat.drain.drained', 'combat.drain.slain'})

event_type class-attribute instance-attribute

event_type: Literal['level_drained'] = 'level_drained'

visibility class-attribute instance-attribute

target_id instance-attribute

target_id: str

levels_lost instance-attribute

levels_lost: int

new_level instance-attribute

new_level: int

hp_lost instance-attribute

hp_lost: int

xp_after class-attribute instance-attribute

xp_after: int | None = None

spawn_consequence class-attribute instance-attribute

spawn_consequence: str | None = None

MagicDispelledEvent

Bases: Event

A dispel magic resolved: which effects were released and which survived.

Emitted by cast_spell when a dispel magic-kind spell resolves.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'magic.dispel.resolved'})

event_type class-attribute instance-attribute

event_type: Literal['magic_dispelled'] = 'magic_dispelled'

code class-attribute instance-attribute

code: str = 'magic.dispel.resolved'

visibility class-attribute instance-attribute

caster_id instance-attribute

caster_id: str

released_effect_ids class-attribute instance-attribute

released_effect_ids: tuple[str, ...] = ()

surviving_effect_ids class-attribute instance-attribute

surviving_effect_ids: tuple[str, ...] = ()

MonsterRevivedEvent

Bases: Event

A regenerating monster returned from death (the troll's 2d6-round revival).

Emitted by a regeneration tick inside EffectsLedger.advance.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'effects.regeneration.revived'})

event_type class-attribute instance-attribute

event_type: Literal['monster_revived'] = 'monster_revived'

code class-attribute instance-attribute

code: str = 'effects.regeneration.revived'

visibility class-attribute instance-attribute

target_id instance-attribute

target_id: str

MoraleCheckedEvent

Bases: Event

A morale check: referee visibility — players learn the outcome from behavior.

Emitted by 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.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'combat.morale.held', 'combat.morale.broke', 'combat.morale.exempt'})

event_type class-attribute instance-attribute

event_type: Literal['morale_checked'] = 'morale_checked'

visibility class-attribute instance-attribute

subject instance-attribute

subject: str

score instance-attribute

score: int

roll class-attribute instance-attribute

roll: int | None = None

modifier class-attribute instance-attribute

modifier: int = 0

PreparedSpell

Bases: BaseModel

One prepared copy in a memorization event: the spell and its fixed form.

spell_id instance-attribute

spell_id: str

reversed class-attribute instance-attribute

reversed: bool = False

ReactionRolledEvent

Bases: Event

A monster reaction roll: referee visibility — players learn reactions from behavior.

Emitted by roll_reaction.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'encounter.reaction.rolled'})

event_type class-attribute instance-attribute

event_type: Literal['reaction_rolled'] = 'reaction_rolled'

code class-attribute instance-attribute

code: str = 'encounter.reaction.rolled'

visibility class-attribute instance-attribute

roll instance-attribute

roll: int

modifier class-attribute instance-attribute

modifier: int = 0

total instance-attribute

total: int

result instance-attribute

result: str

SavingThrowRolledEvent

Bases: Event

A saving throw resolved; roll and required are None for auto-save defenses.

Emitted by 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.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'combat.save.passed', 'combat.save.failed', 'combat.save.auto'})

event_type class-attribute instance-attribute

event_type: Literal['saving_throw_rolled'] = 'saving_throw_rolled'

visibility class-attribute instance-attribute

target_id instance-attribute

target_id: str

category instance-attribute

category: str

roll class-attribute instance-attribute

roll: int | None = None

modifier class-attribute instance-attribute

modifier: int = 0

required class-attribute instance-attribute

required: int | None = None

SpellBookUpdatedEvent

Bases: Event

A spell added to an arcane caster's spell book.

Emitted by add_spell_to_book.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'magic.book.added'})

event_type class-attribute instance-attribute

event_type: Literal['spell_book_updated'] = 'spell_book_updated'

code class-attribute instance-attribute

code: str = 'magic.book.added'

visibility class-attribute instance-attribute

caster_id instance-attribute

caster_id: str

spell_id instance-attribute

spell_id: str

SpellCastEvent

Bases: Event

A spell cast: the memorized copy was consumed.

Emitted by cast_spell or 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.

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.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'magic.cast.cast', 'magic.cast.no_effect'})

event_type class-attribute instance-attribute

event_type: Literal['spell_cast'] = 'spell_cast'

visibility class-attribute instance-attribute

caster_id instance-attribute

caster_id: str

spell_id instance-attribute

spell_id: str

mode instance-attribute

mode: str

reversed class-attribute instance-attribute

reversed: bool = False

target_ids class-attribute instance-attribute

target_ids: tuple[str, ...] = ()

manual class-attribute instance-attribute

manual: bool = False

SpellDisruptedEvent

Bases: Event

A declared casting disrupted: the copy is lost as if it had been cast.

Emitted by disrupt_casting.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'magic.cast.disrupted'})

event_type class-attribute instance-attribute

event_type: Literal['spell_disrupted'] = 'spell_disrupted'

code class-attribute instance-attribute

code: str = 'magic.cast.disrupted'

visibility class-attribute instance-attribute

caster_id instance-attribute

caster_id: str

spell_id instance-attribute

spell_id: str

reversed class-attribute instance-attribute

reversed: bool = False

SpellForgottenEvent

Bases: Event

A memorized copy forgotten because level drain shrank the caster's slots.

Emitted by forget_excess_memorized.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'magic.memory.forgotten'})

event_type class-attribute instance-attribute

event_type: Literal['spell_forgotten'] = 'spell_forgotten'

code class-attribute instance-attribute

code: str = 'magic.memory.forgotten'

visibility class-attribute instance-attribute

caster_id instance-attribute

caster_id: str

spell_id instance-attribute

spell_id: str

reversed class-attribute instance-attribute

reversed: bool = False

SpellsMemorizedEvent

Bases: Event

A caster's daily preparation resolved: the full prepared list.

Emitted by memorize_spells. One event per preparation — memorization is a full replacement, so the list is the caster's complete new memory.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'magic.memorize.prepared'})

event_type class-attribute instance-attribute

event_type: Literal['spells_memorized'] = 'spells_memorized'

code class-attribute instance-attribute

code: str = 'magic.memorize.prepared'

visibility class-attribute instance-attribute

caster_id instance-attribute

caster_id: str

prepared instance-attribute

prepared: tuple[PreparedSpell, ...]

TargetsSelectedEvent

Bases: Event

The targeting model's resolution: which candidates an effect selected.

Emitted by select_targets.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'combat.targeting.selected'})

event_type class-attribute instance-attribute

event_type: Literal['targets_selected'] = 'targets_selected'

code class-attribute instance-attribute

code: str = 'combat.targeting.selected'

visibility class-attribute instance-attribute

mode instance-attribute

mode: str

target_ids instance-attribute

target_ids: tuple[str, ...]

TurningTypeOutcome

Bases: BaseModel

One undead type's turning verdict: its column, the cell, and the threshold.

template_id instance-attribute

template_id: str

column class-attribute instance-attribute

column: str | None = None

outcome instance-attribute

outcome: str

threshold class-attribute instance-attribute

threshold: int | None = None

UndeadTurnedEvent

Bases: Event

A turning attempt resolved — player visibility: the player rolls turning dice.

Emitted by 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.

allowed_codes class-attribute

allowed_codes: frozenset[str] = frozenset({'magic.turning.turned', 'magic.turning.destroyed', 'magic.turning.failed'})

event_type class-attribute instance-attribute

event_type: Literal['undead_turned'] = 'undead_turned'

visibility class-attribute instance-attribute

caster_id instance-attribute

caster_id: str

roll instance-attribute

roll: int

hd_pool class-attribute instance-attribute

hd_pool: int | None = None

types class-attribute instance-attribute

types: tuple[TurningTypeOutcome, ...] = ()

affected_ids class-attribute instance-attribute

affected_ids: tuple[str, ...] = ()

Visibility

Bases: 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.

PLAYER class-attribute instance-attribute

PLAYER = 'player'

REFEREE class-attribute instance-attribute

REFEREE = 'referee'

parse_event

parse_event(data: Mapping[str, object]) -> Event | None

Parse one serialized kernel event, skipping unknown event types.

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.

Parameters:

Name Type Description Default
data Mapping[str, object]

A mapping previously produced by an event's model_dump.

required

Returns:

Type Description
Event | None

The event, or None when its event_type is unknown.

Raises:

Type Description
ContentValidationError

If the event type is known but the payload is malformed.