From 21ccaf97272e17539d20d37334f406434206dc6b Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 21:52:44 -0700 Subject: [PATCH 1/4] Annotate the kernel's creature parameters with the protocols (#104) Every public creature parameter in core/combat.py, core/spells.py, core/effects.py, and core/items.py now names what the function reads through it, in place of Any and object. A parameter whose reads are the id, name, alignment, hit points, conditions, and stat modifiers takes Creature; one that reads THAC0, armour class, or a saving throw takes Combatant; one that reads level, spell book, or memorized spells takes Caster; and one whose reads need what a single concrete type has takes that type, so resolve_breath and drain_monster_hd take a MonsterInstance and sword_control_check a Character. A sequence parameter takes a Sequence of the same choice, and a spell's targets take Creature values or the location strings the module already documented. The point is that a front end's call is now checked from the signature and the reference links the type, instead of prose promising what the annotation did not say. The three protocols are unchanged: no function read, unguarded, an attribute both concrete types have that the protocols lack, so nothing was added to them. Runtime behaviour is identical. No branch, guard, or draw was added or moved: the only executable changes are imports, five typing.cast calls where a runtime gate the checker cannot see has already settled which concrete type is in hand, and _ScrollReader keeping the scroll's caster level in a slot rather than behind a read-only property, so the proxy satisfies Caster where it is passed as one and declares the members it forwards. The full suite passes with no golden file touched. pyright reports six errors, all in the crawl layer, which this chunk was told not to touch: battle.py declares slow_attacks with an object element type, two handlers in exploration.py declare their spell target list as list[object], and one healing path leaves its target Unknown | None across two separate tests of the same condition. Widening the kernel's Any to a protocol is what exposed them; the kernel itself is clean. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- CHANGELOG.md | 2 +- src/osrlib/core/combat.py | 291 +++++++++++++++++++++---------------- src/osrlib/core/effects.py | 61 +++++--- src/osrlib/core/items.py | 19 ++- src/osrlib/core/spells.py | 178 +++++++++++++---------- 5 files changed, 315 insertions(+), 236 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec8003..1d2122c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- [`osrlib.core.creature`][osrlib.core.creature] declares the three protocols a rules function reads its creature argument through: [`Creature`][osrlib.core.creature.Creature] (id, name, alignment, hit points, conditions, stat modifiers), [`Combatant`][osrlib.core.creature.Combatant] (the attack, initiative, and saving-throw numbers), and [`Caster`][osrlib.core.creature.Caster] (level, spell book, memorized spells) (#104). A [`Character`][osrlib.core.character.Character] satisfies all three and a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] the first two, structurally, so a caller passes either without a cast and annotates its own functions with the protocol that names what they read. +- [`osrlib.core.creature`][osrlib.core.creature] declares the three protocols a rules function reads its creature argument through: [`Creature`][osrlib.core.creature.Creature] (id, name, alignment, hit points, conditions, stat modifiers), [`Combatant`][osrlib.core.creature.Combatant] (the attack, initiative, and saving-throw numbers), and [`Caster`][osrlib.core.creature.Caster] (level, spell book, memorized spells) (#104). A [`Character`][osrlib.core.character.Character] satisfies all three and a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] the first two, structurally, so a caller passes either without a cast and annotates its own functions with the protocol that names what they read. Every public creature parameter in [`osrlib.core.combat`][osrlib.core.combat], [`osrlib.core.spells`][osrlib.core.spells], [`osrlib.core.effects`][osrlib.core.effects], and [`osrlib.core.items`][osrlib.core.items] now states the protocol the function reads through it, in place of `Any` and `object`: [`attack_roll`][osrlib.core.combat.attack_roll] takes a `Combatant` attacker and defender, [`has_condition`][osrlib.core.effects.has_condition] a `Creature`, [`cast_spell`][osrlib.core.spells.cast_spell] a `Caster` and a sequence of `Creature` values or the location strings a place-targeted spell takes, and a function that reads what one concrete type alone has takes that type, so [`resolve_breath`][osrlib.core.combat.resolve_breath] takes a `MonsterInstance` and [`sword_control_check`][osrlib.core.items.sword_control_check] a `Character`. The signature is what pyright checks a front end's call against and what the reference links. Widening `Any` to a protocol is not a breaking change, and no rule, event, or draw sequence changed. - [`StreamName`][osrlib.core.rng.StreamName] is the one home for every RNG stream key the library draws from, and each public `*_STREAM` constant takes its value from the matching member, so `COMBAT_STREAM` and `StreamName.COMBAT` are one object (#106). Name a stream through the enum rather than writing its key out: a misspelled key raises nothing, because it forks a stream of its own and draws plausible numbers from it, which is the one defect the determinism contract cannot catch. `StreamName` is a `StrEnum`, so a member is its own string and the values are unchanged: [`RngStreams`][osrlib.core.rng.RngStreams] keys by string, a save file records the same keys, and seed material and draw order are untouched. - [`MoraleCheckedEvent`][osrlib.core.events.MoraleCheckedEvent]`.held` states whether the side keeps fighting, and [`check_morale`][osrlib.core.combat.check_morale] fills it on every code. `combat.morale.exempt` covers both exemptions, so a front end that wants to tell a side that never fights from one that never breaks reads `held` instead of pairing the code with the score. The default formatter's `combat.morale.exempt` line reads it, and falls back to the score for a log written before schema 4, where the field defaults `None`. - The API reference has a front page for `osrlib.core` and for `osrlib.crawl`, at the top of each layer's section, rendering that package's docstring, so the crawl package's end-to-end program is on the published site (#101). diff --git a/src/osrlib/core/combat.py b/src/osrlib/core/combat.py index 7576949..ea2364d 100644 --- a/src/osrlib/core/combat.py +++ b/src/osrlib/core/combat.py @@ -18,11 +18,15 @@ and the shared targeting model ([`select_targets`][osrlib.core.combat.select_targets]). Combatant-typed parameters (`attacker`, `defender`, `target`, and kin) follow one -convention across the whole library: they accept a -[`Character`][osrlib.core.character.Character] or a -[`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. Both expose THAC0, attack -bonus, armour class, saving throws, hit points, and conditions, and these functions -read only those shared fields. NPC adventurers are `Character` instances, so there's no +convention across the whole library: each takes a protocol from +[`osrlib.core.creature`][osrlib.core.creature], and a +[`Character`][osrlib.core.character.Character] and a +[`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy it. A function +that reads THAC0, armour class, or saving throws takes +[`Combatant`][osrlib.core.creature.Combatant]; one that reads only hit points, +conditions, and stat modifiers takes [`Creature`][osrlib.core.creature.Creature]; and +one that needs what only a monster carries, such as the daily breath count, takes +`MonsterInstance` itself. NPC adventurers are `Character` instances, so there's no third combatant type. Resolutions also take an [`AttackContext`][osrlib.core.combat.AttackContext] that @@ -94,12 +98,13 @@ from collections.abc import Mapping, Sequence from enum import StrEnum -from typing import Any +from typing import TYPE_CHECKING, Any, cast from pydantic import BaseModel, ConfigDict from osrlib.core.classes import SavingThrows from osrlib.core.clock import GameClock, TimeUnit +from osrlib.core.creature import Combatant, Creature from osrlib.core.dice import RollResult, roll from osrlib.core.effects import ( Condition, @@ -138,12 +143,15 @@ equipped_item_modifiers, magic_item_template, ) -from osrlib.core.monsters import Element, MonsterAttack +from osrlib.core.monsters import Element, MonsterAbility, MonsterAttack, MonsterInstance from osrlib.core.rng import RngStream, StreamName from osrlib.core.ruleset import Ruleset from osrlib.core.tables import ReactionResult, reaction_result, to_hit_ac from osrlib.core.validation import Rejection +if TYPE_CHECKING: + from osrlib.core.character import Character + __all__ = [ "Attack", "AttackContext", @@ -742,7 +750,7 @@ def _entity_id(combatant: Any) -> str: return identifier if identifier is not None else getattr(combatant, "name", "unknown") -def alignments_differ(source: Any, target: Any) -> bool: +def alignments_differ(source: Creature, target: Creature) -> bool: """Return whether two combatants' operative alignments differ, for warding gates. The wards that turn aside creatures "of another alignment", *protection from evil* and @@ -756,9 +764,10 @@ def alignments_differ(source: Any, target: Any) -> bool: Args: source: The creature the ward is checked against, usually the attacker. A - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. - target: The warded creature, a `Character` or a `MonsterInstance`. + [`Creature`][osrlib.core.creature.Creature], which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. + target: The warded `Creature`, a `Character` or a `MonsterInstance`. Returns: True when the alignments differ or either is unresolved. @@ -950,7 +959,7 @@ def _strength_set_value(combatant: Any) -> int | None: return None -def melee_modifier_for(combatant: Any) -> int: +def melee_modifier_for(combatant: Combatant) -> int: """Return a combatant's melee attack-and-damage modifier, `strength_set` aware. [`attack_roll`][osrlib.core.combat.attack_roll] and @@ -963,9 +972,9 @@ def melee_modifier_for(combatant: Any) -> int: melee modifier from. Monsters have no STR score and keep their intrinsic 0. Args: - combatant: The attacking combatant, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. + combatant: The attacking [`Combatant`][osrlib.core.creature.Combatant], which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. Returns: The signed melee modifier, applied to both the attack roll and the damage. @@ -1036,7 +1045,7 @@ def _range_band_modifier(attack: Attack, context: AttackContext) -> int | None: return None -def damage_source_for(attacker: Any, attack: Attack, context: AttackContext) -> DamageSource: +def damage_source_for(attacker: Creature, attack: Attack, context: AttackContext) -> DamageSource: """Build the damage source an attack presents to the defender's defenses. [`resolve_attack`][osrlib.core.combat.resolve_attack] builds one for you on every hit. @@ -1056,9 +1065,9 @@ def damage_source_for(attacker: Any, attack: Attack, context: AttackContext) -> Whether the source counts as a small missile is recorded here for the immunity gate. Args: - attacker: The attacking combatant, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. + attacker: The attacking [`Creature`][osrlib.core.creature.Creature], which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. attack: The weapon, facet, gear item, or monster attack (`None` for unarmed). context: The attack context. Set `lit` when the oil flask is alight, and `distance_feet` when a melee-and-missile weapon is thrown. @@ -1140,7 +1149,7 @@ def _is_bladed(attack: Attack) -> bool: def validate_attack( - attacker: Any, defender: Any, attack: Attack, context: AttackContext, *, ruleset: Ruleset + attacker: Creature, defender: Creature, attack: Attack, context: AttackContext, *, ruleset: Ruleset ) -> list[Rejection]: """Validate an attack: the pure pre-phase, with no RNG draws and no mutation. @@ -1157,10 +1166,12 @@ def validate_attack( reason. Args: - attacker: The attacking combatant, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. - defender: The defending combatant, a `Character` or a `MonsterInstance`. + attacker: The attacking [`Creature`][osrlib.core.creature.Creature], which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. Only its + conditions are read here. + defender: The defending `Creature`, a `Character` or a `MonsterInstance`. Nothing is + read from it. attack: The weapon, facet, gear item, or monster attack (`None` for unarmed). context: The situation you assert. This reads `distance_feet` and `fired_last_round`. @@ -1257,8 +1268,8 @@ def _defender_descending_ac(defender: Any, context: AttackContext, *, missile: b def attack_roll( - attacker: Any, - defender: Any, + attacker: Combatant, + defender: Combatant, attack: Attack, *, context: AttackContext, @@ -1289,10 +1300,10 @@ def attack_roll( ruleset flag. Args: - attacker: The attacking combatant, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. - defender: The defending combatant, a `Character` or a `MonsterInstance`. + attacker: The attacking [`Combatant`][osrlib.core.creature.Combatant], which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. + defender: The defending `Combatant`, a `Character` or a `MonsterInstance`. attack: The weapon, facet, gear item, or monster attack (`None` for unarmed). context: The situation you assert. ruleset: The ruleset in play. @@ -1422,7 +1433,9 @@ def attack_roll( ) -def check_immunity(defender: Any, source: DamageSource, *, ruleset: Ruleset, attacker: Any | None = None) -> bool: +def check_immunity( + defender: Creature, source: DamageSource, *, ruleset: Ruleset, attacker: Creature | None = None +) -> bool: """Return True when the defender's defenses absorb the source: no damage is rolled. [`resolve_attack`][osrlib.core.combat.resolve_attack] and @@ -1445,12 +1458,12 @@ def check_immunity(defender: Any, source: DamageSource, *, ruleset: Ruleset, att arrow isn't. Args: - defender: The defending combatant, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. + defender: The defending [`Creature`][osrlib.core.creature.Creature], which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. source: The damage source presented. ruleset: The ruleset in play. - attacker: The attacking combatant, a `Character` or a `MonsterInstance`. Only + attacker: The attacking `Creature`, a `Character` or a `MonsterInstance`. Only `hd5_counts_as_magical` reads it, and the flag cannot apply without it. Returns: @@ -1516,13 +1529,13 @@ def check_immunity(defender: Any, source: DamageSource, *, ruleset: Ruleset, att def damage_roll( - attacker: Any, + attacker: Combatant, attack: Attack, *, context: AttackContext, ruleset: Ruleset, stream: RngStream, - defender: Any | None = None, + defender: Creature | None = None, ) -> RollResult: """Roll an attack's damage: dice, STR for melee, doublings, minimum 1. @@ -1547,17 +1560,17 @@ def damage_roll( default, and its printed 2d8 with the flag off. Args: - attacker: The attacking combatant, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. + attacker: The attacking [`Combatant`][osrlib.core.creature.Combatant], which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. attack: The weapon, facet, gear item, or monster attack (`None` for unarmed). context: The situation you assert. Its `braced`, `charging`, `behind_target`, and `target_unaware` fields drive the doublings. ruleset: The ruleset in play. stream: The stream to draw the damage dice from, conventionally [`COMBAT_STREAM`][osrlib.core.combat.COMBAT_STREAM]. - defender: The defender, a `Character` or a `MonsterInstance`. Only an enchanted - arm's versus clause reads it, so an ordinary weapon needs no defender. + defender: The defending `Creature`, a `Character` or a `MonsterInstance`. Only an + enchanted arm's versus clause reads it, so an ordinary weapon needs no defender. Returns: The damage roll. `rolls` contains the individual dice and `total` the final amount, @@ -1678,7 +1691,7 @@ def _item_effect_params(combatant: Any, effect_kind: str) -> dict[str, Any] | No def deal_damage( - target: Any, + target: Combatant, amount: int, *, source: DamageSource, @@ -1710,9 +1723,10 @@ def deal_damage( it carried. Args: - target: The creature taking damage, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. Mutated in place. + target: The [`Combatant`][osrlib.core.creature.Combatant] taking the damage, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. Mutated in + place, and its saving throws are rolled when a destructive source kills it. amount: The rolled amount, before reductions. source: The damage source, which selects the reductions, the ledger, and whether a kill destroys equipment. @@ -1773,7 +1787,11 @@ def deal_damage( already_dead = has_condition(target, Condition.DEAD) target.current_hp = max(0, target.current_hp - amount) if clock is not None and hasattr(target, "last_damaged_round"): - target.last_damaged_round = clock.rounds + # The round stamp and the non-regenerable ledger below are a monster instance's + # alone. What proves the target is one, this `hasattr` and the regeneration + # ability, is invisible to the type checker, so each use names the type the + # branch already established. + cast(MonsterInstance, target).last_damaged_round = clock.rounds regeneration = _monster_ability_params(target, "regeneration") blocked = False newly_permanent = False @@ -1781,9 +1799,10 @@ def deal_damage( blocked_by = tuple(str(element) for element in regeneration.get("blocked_by", ())) blocked = source.element in blocked_by if blocked: - before = target.nonregen_damage - target.nonregen_damage = min(target.max_hp, target.nonregen_damage + amount) - newly_permanent = before < target.max_hp <= target.nonregen_damage + regenerator = cast(MonsterInstance, target) + before = regenerator.nonregen_damage + regenerator.nonregen_damage = min(regenerator.max_hp, regenerator.nonregen_damage + amount) + newly_permanent = before < regenerator.max_hp <= regenerator.nonregen_damage keys = source.keys if source.element is None or source.element in source.keys else (*source.keys, source.element) events.append( DamageDealtEvent( @@ -1802,7 +1821,7 @@ def deal_damage( permanent = ( regeneration is not None and regeneration.get("revive") is not None - and target.nonregen_damage >= target.max_hp + and cast(MonsterInstance, target).nonregen_damage >= target.max_hp ) events.extend(kill(target, permanent=permanent)) if source.destructive: @@ -1816,7 +1835,7 @@ def deal_damage( def destroy_equipment( - target: Any, + target: Combatant, *, source: DamageSource | None = None, ruleset: Ruleset | None = None, @@ -1839,10 +1858,11 @@ def destroy_equipment( survival. The rolls themselves are silent, and the event reports the outcome. Args: - target: The victim, a [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. Its inventory is - emptied except for saved magic items, and what it wielded, wore, and had on - its fingers is cleared. + target: The victim, a [`Combatant`][osrlib.core.creature.Combatant] that a + [`Character`][osrlib.core.character.Character] or a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] satisfies. Its inventory is + emptied except for saved magic items, and what it wielded, wore, and had on its + fingers is cleared. source: The destructive damage source, which selects the saving throw category. `None` means the death category. ruleset: The ruleset in play. `None` skips the save and everything burns. @@ -1928,8 +1948,8 @@ def destroy_equipment( def resolve_attack( - attacker: Any, - defender: Any, + attacker: Combatant, + defender: Combatant, attack: Attack, *, context: AttackContext, @@ -1963,10 +1983,10 @@ def resolve_attack( [`burning_oil_pool_definition`][osrlib.core.combat.burning_oil_pool_definition]. Args: - attacker: The attacking combatant, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. - defender: The defending combatant, a `Character` or a `MonsterInstance`. Mutated + attacker: The attacking [`Combatant`][osrlib.core.creature.Combatant], which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. + defender: The defending `Combatant`, a `Character` or a `MonsterInstance`. Mutated in place when damage lands. attack: The weapon, facet, gear item, or monster attack (`None` for unarmed). context: The situation you assert. `AttackContext()` is the plain melee case. @@ -2151,8 +2171,8 @@ def burning_oil_pool_definition() -> EffectDefinition: def resolve_splash_attack( - attacker: Any, - defender: Any, + attacker: Combatant, + defender: Combatant, attack: GearTemplate, *, context: AttackContext, @@ -2176,11 +2196,11 @@ def resolve_splash_attack( and a free one would give away what B/X keeps hidden until it matters. Args: - attacker: The throwing combatant, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. - defender: The target, a `Character` or a `MonsterInstance`. Mutated in place when - damage lands. + attacker: The throwing [`Combatant`][osrlib.core.creature.Combatant], which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. + defender: The target, a `Combatant` that a `Character` or a `MonsterInstance` + satisfies. Mutated in place when damage lands. attack: The splash gear item, which is holy water or a flask of oil. context: The situation you assert. Oil does nothing unless `lit` is true. ruleset: The ruleset in play. @@ -2251,7 +2271,7 @@ def resolve_splash_attack( return result -def participant_modifier(combatant: Any, *, monster_modifier: int = 0) -> int: +def participant_modifier(combatant: Combatant, *, monster_modifier: int = 0) -> int: """Return a combatant's individual-initiative modifier. Use it to fill the `modifier` field of a @@ -2265,8 +2285,9 @@ def participant_modifier(combatant: Any, *, monster_modifier: int = 0) -> int: modifiers to the referee. Args: - combatant: The combatant, a [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. + combatant: The [`Combatant`][osrlib.core.creature.Combatant] rolling, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. monster_modifier: The modifier to use for a monster. Ignored for characters. Returns: @@ -2608,7 +2629,7 @@ def check(self, subject: str, score: int, *, modifier: int = 0, stream: RngStrea return result -def incapacitated(combatant: Any) -> bool: +def incapacitated(combatant: Creature) -> bool: """Return whether a combatant counts as incapacitated for morale triggers. [`morale_triggers`][osrlib.core.combat.morale_triggers] counts a side's incapacitated @@ -2621,8 +2642,9 @@ def incapacitated(combatant: Any) -> bool: petrified, or asleep. Args: - combatant: The combatant, a [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. + combatant: The [`Creature`][osrlib.core.creature.Creature] to ask about, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. Returns: True when incapacitated. @@ -2645,7 +2667,7 @@ def incapacitated(combatant: Any) -> bool: return any(has_condition(combatant, condition) for condition in _CANNOT_ACT) -def cannot_move(combatant: Any) -> bool: +def cannot_move(combatant: Creature) -> bool: """Return whether a combatant cannot move. Ask this before letting a combatant move, flee, or close to melee. It's @@ -2655,8 +2677,9 @@ def cannot_move(combatant: Any) -> bool: calls it for you. Args: - combatant: The combatant, a [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. + combatant: The [`Creature`][osrlib.core.creature.Creature] to ask about, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. Returns: True when movement is impossible. @@ -2680,7 +2703,7 @@ def cannot_move(combatant: Any) -> bool: return incapacitated(combatant) or has_condition(combatant, Condition.ENTANGLED) -def morale_modifier(combatant: Any) -> int: +def morale_modifier(combatant: Creature) -> int: """Return a combatant's spell morale modifier, from *bless*, *blight*, and their kin. [`check_morale`][osrlib.core.combat.check_morale] takes a side key and a score, never a @@ -2690,9 +2713,9 @@ def morale_modifier(combatant: Any) -> int: there's one adjustment rule rather than a second channel for spells. Args: - combatant: The creature whose morale is being checked, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. + combatant: The [`Creature`][osrlib.core.creature.Creature] whose morale is being checked, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. Returns: The signed modifier, already totalled across every active effect and 0 when none @@ -2720,7 +2743,7 @@ def morale_modifier(combatant: Any) -> int: return modifier_total(combatant, "morale_bonus") -def morale_triggers(members: Sequence[object]) -> list[str]: +def morale_triggers(members: Sequence[Creature]) -> list[str]: """Return the morale triggers a side's current state raises. Call this after each round to learn whether a side should check morale, then call @@ -2735,9 +2758,10 @@ def morale_triggers(members: Sequence[object]) -> list[str]: ones you've already acted on. Args: - members: The side's combatants, [`Character`][osrlib.core.character.Character] or - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects. An empty - side raises nothing. + members: The side's creatures, each a [`Creature`][osrlib.core.creature.Creature] that + a [`Character`][osrlib.core.character.Character] or a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] satisfies. An empty side + raises nothing. Returns: The raised trigger keys. @@ -2768,13 +2792,13 @@ def morale_triggers(members: Sequence[object]) -> list[str]: def saving_throw( - target: Any, + target: Combatant, category: SaveCategory, *, modifier: int = 0, magical: bool = False, element: str | None = None, - source: Any | None = None, + source: Creature | None = None, stream: RngStream, ) -> SaveResult: """Roll a saving throw: 1d20 at or above the target's value for the category. @@ -2797,16 +2821,16 @@ def saving_throw( against magical forms of its own element, passes without a roll and without a draw. Args: - target: The saving combatant, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. + target: The saving [`Combatant`][osrlib.core.creature.Combatant], which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. category: The saving throw category. modifier: Your adjustment, added to everything the target supplies. magical: Whether the effect is magical. It turns on the WIS modifier and is what an auto-saving energy defense keys off. element: The effect's element, read by auto-save defenses and by element-scoped save bonuses. - source: The creature whose attack or ability forced the save, a `Character` or a + source: The `Creature` whose attack or ability forced the save, a `Character` or a `MonsterInstance`. Only alignment-scoped save bonuses read it. stream: The stream the d20 comes from, conventionally [`COMBAT_STREAM`][osrlib.core.combat.COMBAT_STREAM]. One draw, or none on an @@ -2864,7 +2888,7 @@ def saving_throw( return SaveResult(passed=passed, roll=rolled, modifier=modifier, required=required, events=(event,)) -def apply_healing(target: Any, amount: int, *, source: str = "magical") -> list[Event]: +def apply_healing(target: Creature, amount: int, *, source: str = "magical") -> list[Event]: """Apply instantaneous healing, capped at max HP. This mutates the target and draws nothing: roll the amount first if the healing is @@ -2883,9 +2907,9 @@ def apply_healing(target: Any, amount: int, *, source: str = "magical") -> list[ than blocking it. Args: - target: The creature to heal, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. Mutated in place. + target: The [`Creature`][osrlib.core.creature.Creature] to heal, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. Mutated in place. amount: The healing amount, which must not be negative. Healing past the maximum is capped, not an error. source: The healing kind: `magical`, which is the default, `natural`, or @@ -2935,7 +2959,7 @@ def apply_healing(target: Any, amount: int, *, source: str = "magical") -> list[ ] -def natural_healing(target: Any, stream: RngStream, *, ledger: EffectsLedger | None = None) -> list[Event]: +def natural_healing(target: Creature, stream: RngStream, *, ledger: EffectsLedger | None = None) -> list[Event]: """Apply one full day of complete rest: 1d3 hit points. Call this once per day of uninterrupted rest. Whether the rest was uninterrupted is @@ -2952,9 +2976,9 @@ def natural_healing(target: Any, stream: RngStream, *, ledger: EffectsLedger | N the slowest one wins. A diseased target with no ledger to count on doesn't heal. Args: - target: The resting creature, a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. Mutated in place. + target: The resting [`Creature`][osrlib.core.creature.Creature], which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. Mutated in place. stream: The stream the 1d3 comes from. Natural healing is effect-internal randomness, so it draws from [`EFFECTS_STREAM`][osrlib.core.effects.EFFECTS_STREAM], not the combat @@ -3043,7 +3067,7 @@ def falling_damage(feet: int, stream: RngStream) -> RollResult | None: return roll(f"{dice}d6", stream) -def drain_monster_hd(monster: Any, *, levels: int = 1, stream: RngStream) -> list[Event]: +def drain_monster_hd(monster: MonsterInstance, *, levels: int = 1, stream: RngStream) -> list[Event]: """Drain a monster's Hit Dice, which is what "experience level (or Hit Die)" means. Call this when something drains a monster rather than a character. For a character, @@ -3135,7 +3159,7 @@ def drain_monster_hd(monster: Any, *, levels: int = 1, stream: RngStream) -> lis return events -def resolve_energy_drain(attacker: Any, target: Any, *, stream: RngStream) -> list[Event]: +def resolve_energy_drain(attacker: MonsterInstance, target: Creature, *, stream: RngStream) -> list[Event]: """Drain a victim's levels or Hit Dice from a drain-tagged monster's touch. Call this after a wight, wraith, spectre, or vampire lands a hit, since @@ -3149,9 +3173,9 @@ def resolve_energy_drain(attacker: Any, target: Any, *, stream: RngStream) -> li Args: attacker: The draining [`MonsterInstance`][osrlib.core.monsters.MonsterInstance], which must have an `energy_drain` tag. - target: The drained combatant, a - [`Character`][osrlib.core.character.Character] or a `MonsterInstance`. - Mutated in place. + target: The drained [`Creature`][osrlib.core.creature.Creature], a + [`Character`][osrlib.core.character.Character] or a `MonsterInstance`. Which one it is + decides whether levels or Hit Dice come off. Mutated in place. stream: The stream the lost-hit-point dice come from. Drain reverses advancement, so it draws from [`ADVANCEMENT_STREAM`][osrlib.core.character.ADVANCEMENT_STREAM] rather than @@ -3190,20 +3214,26 @@ def resolve_energy_drain(attacker: Any, target: Any, *, stream: RngStream) -> li raise ValueError(f"{_entity_id(attacker)} has no energy_drain ability") levels = int(params.get("levels", 1)) if getattr(target, "definition", None) is not None: - ability = attacker.template.ability("energy_drain") + # A class definition is a character's, and its absence is what marks a monster. + # The branch has settled which the target is; the casts say so, since a `getattr` + # test proves nothing to the type checker. + character = cast("Character", target) + # The params gate above proves the ability is there. The second lookup is for its + # prose, which the params don't carry. + ability = cast(MonsterAbility, attacker.template.ability("energy_drain")) result = drain_levels( - target, - target.definition, + character, + character.definition, levels=levels, xp_policy=str(params.get("xp_policy", "level_minimum")), stream=stream, spawn_consequence=ability.prose, ) return list(result.events) - return drain_monster_hd(target, levels=levels, stream=stream) + return drain_monster_hd(cast(MonsterInstance, target), levels=levels, stream=stream) -def effective_hd(combatant: Any) -> int: +def effective_hd(combatant: Creature) -> int: """Return a combatant's effective Hit Dice for the HD-budget targeting mode. [`select_targets`][osrlib.core.combat.select_targets] spends its budget in these units, @@ -3215,8 +3245,9 @@ def effective_hd(combatant: Any) -> int: its level. Args: - combatant: The combatant, a [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. + combatant: The [`Creature`][osrlib.core.creature.Creature] to measure, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. Returns: The effective Hit Dice, never below 1. @@ -3244,13 +3275,13 @@ def effective_hd(combatant: Any) -> int: def select_targets( mode: TargetingMode, - candidates: Sequence[object], + candidates: Sequence[Creature], *, stream: RngStream, count: int | None = None, count_dice: str | None = None, hd_budget: int | None = None, -) -> tuple[list[object], list[Event]]: +) -> tuple[list[Creature], list[Event]]: """Resolve the shared targeting model against an explicit candidate list. Spells, breath weapons, and thrown weapons all choose their victims through this one @@ -3272,9 +3303,9 @@ def select_targets( Args: mode: The targeting mode. - candidates: The candidates, in the order you want ties and precedence broken. - Each a [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. + candidates: The candidates, in the order you want ties and precedence broken. Each a + [`Creature`][osrlib.core.creature.Creature], which a [`Character`][osrlib.core.character.Character] + and a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. stream: The stream a rolled count draws from, conventionally [`COMBAT_STREAM`][osrlib.core.combat.COMBAT_STREAM]. Only `count_dice` draws. count: The fixed N for `up_to_n`. @@ -3313,7 +3344,7 @@ def select_targets( assert [target.id for target in selected] == ["goblin-1", "goblin-2"] ``` """ - selected: list[object] + selected: list[Creature] if mode in (TargetingMode.SELF, TargetingMode.SINGLE): selected = list(candidates[:1]) elif mode is TargetingMode.UP_TO_N: @@ -3339,8 +3370,8 @@ def select_targets( def resolve_gaze( - gazer: object, - engaged: Sequence[object], + gazer: Creature, + engaged: Sequence[Combatant], *, stream: RngStream, ledger: EffectsLedger, @@ -3362,9 +3393,11 @@ def resolve_gaze( counterplay with a mirror stays a matter for the referee. Args: - gazer: The gazing [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. - engaged: The combatants in melee with it, each a - [`Character`][osrlib.core.character.Character] or a `MonsterInstance`. + gazer: The gazing [`Creature`][osrlib.core.creature.Creature], which in the SRD's monsters is always a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. Nothing is read from it. + engaged: The creatures in melee with it, each a [`Combatant`][osrlib.core.creature.Combatant] + that a [`Character`][osrlib.core.character.Character] or a `MonsterInstance` + satisfies. stream: The stream the saves draw from, conventionally [`COMBAT_STREAM`][osrlib.core.combat.COMBAT_STREAM]. One draw per combatant that has to save. @@ -3421,7 +3454,7 @@ def resolve_gaze( return events -def validate_breath(monster: Any) -> list[Rejection]: +def validate_breath(monster: MonsterInstance) -> list[Rejection]: """Validate a breath weapon use against the per-monster daily limit. Call this before [`resolve_breath`][osrlib.core.combat.resolve_breath], which raises @@ -3475,8 +3508,8 @@ def validate_breath(monster: Any) -> list[Rejection]: def resolve_breath( - monster: Any, - targets: Sequence[object], + monster: MonsterInstance, + targets: Sequence[Combatant], *, ruleset: Ruleset, stream: RngStream, @@ -3508,9 +3541,9 @@ def resolve_breath( Args: monster: The breathing [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. Its `breath_uses_today` goes up when the breath has a daily limit. - targets: The combatants caught in the breath, each a - [`Character`][osrlib.core.character.Character] or a `MonsterInstance`. - Mutated in place. + targets: The creatures caught in the breath, each a [`Combatant`][osrlib.core.creature.Combatant] + that a [`Character`][osrlib.core.character.Character] or a `MonsterInstance` + satisfies. Mutated in place. ruleset: The ruleset in play. stream: The stream every draw comes from, conventionally [`COMBAT_STREAM`][osrlib.core.combat.COMBAT_STREAM]: one save per target, the diff --git a/src/osrlib/core/effects.py b/src/osrlib/core/effects.py index 10d25f7..9ff9454 100644 --- a/src/osrlib/core/effects.py +++ b/src/osrlib/core/effects.py @@ -30,9 +30,10 @@ 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]. +The `target` parameters below take [`Creature`][osrlib.core.creature.Creature], the protocol that names the +hit points, conditions, and stat modifiers these calls read. A [`Character`][osrlib.core.character.Character] +and a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy it, and a `registry` maps entity +ids to those same creatures. Typical usage: @@ -75,6 +76,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from osrlib.core.clock import ROUNDS_PER_DAY, ROUNDS_PER_TURN, GameClock, TimeUnit +from osrlib.core.creature import Creature from osrlib.core.dice import parse, roll from osrlib.core.events import ( ConditionGainedEvent, @@ -279,7 +281,7 @@ def _int_param(params: Mapping[str, Any], key: str, default: int = 0) -> int: return int(params.get(key, default)) -def has_condition(target: Any, condition: Condition) -> bool: +def has_condition(target: Creature, condition: Condition) -> bool: """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 @@ -290,8 +292,9 @@ def has_condition(target: Any, condition: Condition) -> bool: tuple of [`ActiveCondition`][osrlib.core.effects.ActiveCondition] records directly. Args: - target: The creature to check. Any object with a `conditions` tuple works, and an object without one - reads as having no conditions. + target: The [`Creature`][osrlib.core.creature.Creature] to check, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. condition: The condition to look for. Returns: @@ -321,7 +324,7 @@ def _entity_id(target: Any) -> str: return identifier if identifier is not None else getattr(target, "name", "unknown") -def grant_condition(target: Any, condition: Condition, effect_id: str | None) -> list[Event]: +def grant_condition(target: Creature, condition: Condition, effect_id: str | None) -> list[Event]: """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 @@ -340,7 +343,10 @@ def grant_condition(target: Any, condition: Condition, effect_id: str | None) -> record. Args: - target: The creature to affect. Its `conditions` tuple is replaced in place. + target: The [`Creature`][osrlib.core.creature.Creature] to affect, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. Its `conditions` tuple is + replaced in place. condition: The condition to grant. effect_id: The id of the effect that owns the condition and will take it back, or `None` for a state no effect owns. @@ -378,7 +384,7 @@ def grant_condition(target: Any, condition: Condition, effect_id: str | None) -> return [ConditionGainedEvent(target_id=_entity_id(target), condition=condition.value, effect_id=effect_id)] -def remove_condition(target: Any, condition: Condition, effect_id: str | None) -> list[Event]: +def remove_condition(target: Creature, condition: Condition, effect_id: str | None) -> list[Event]: """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 @@ -391,7 +397,10 @@ def remove_condition(target: Any, condition: Condition, effect_id: str | None) - [`EffectsLedger.release`][osrlib.core.effects.EffectsLedger.release]. Args: - target: The creature to affect. Its `conditions` tuple is replaced in place. + target: The [`Creature`][osrlib.core.creature.Creature] to affect, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. 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. @@ -444,7 +453,7 @@ def _remove_modifiers(target: Any, effect_id: str) -> None: target.stat_modifiers = remaining -def kill(target: Any, *, permanent: bool = False) -> list[Event]: +def kill(target: Creature, *, permanent: bool = False) -> list[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 @@ -456,7 +465,10 @@ def kill(target: Any, *, permanent: bool = False) -> list[Event]: twice is safe: a creature that's already dead returns no events and isn't killed again. Args: - target: The creature to kill. Its `current_hp` and `conditions` are written in place. + target: The [`Creature`][osrlib.core.creature.Creature] to kill, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. 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. @@ -647,7 +659,7 @@ class ActiveModifier(ModifierSpec): def modifier_values( - target: Any, + target: Creature, kind: str, *, element: str | None = None, @@ -668,8 +680,9 @@ def modifier_values( and a melee-only modifier only when you pass `melee=True`. Args: - target: The creature to read modifiers from. An object with no `stat_modifiers` tuple reads as having - none. + target: The [`Creature`][osrlib.core.creature.Creature] to read modifiers from, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. 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. @@ -730,7 +743,7 @@ def _matching_modifiers( def modifier_total( - target: Any, + target: Creature, kind: str, *, element: str | None = None, @@ -751,7 +764,9 @@ def modifier_total( [`modifier_values`][osrlib.core.effects.modifier_values]. Args: - target: The creature to total modifiers for. + target: The [`Creature`][osrlib.core.creature.Creature] to total modifiers for, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. 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. @@ -805,7 +820,7 @@ def modifier_total( return bonus + penalty + sum(item_values) -def modifier_dice(target: Any, kind: str) -> str | None: +def modifier_dice(target: Creature, kind: str) -> str | None: """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. @@ -816,7 +831,9 @@ def modifier_dice(target: Any, kind: str) -> str | None: two *strikings* rolls one extra die, not two. Args: - target: The creature to read modifiers from. + target: The [`Creature`][osrlib.core.creature.Creature] to read modifiers from, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. kind: The statistic to look for, one of [`MODIFIER_KINDS`][osrlib.core.effects.MODIFIER_KINDS]. Returns: @@ -855,7 +872,7 @@ def modifier_dice(target: Any, kind: str) -> str | None: return None -def has_modifier(target: Any, kind: str) -> bool: +def has_modifier(target: Creature, kind: str) -> bool: """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 @@ -867,7 +884,9 @@ def has_modifier(target: Any, kind: str) -> bool: matters, go through [`modifier_values`][osrlib.core.effects.modifier_values]. Args: - target: The creature to read modifiers from. + target: The [`Creature`][osrlib.core.creature.Creature] to read modifiers from, which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. kind: The statistic to look for, one of [`MODIFIER_KINDS`][osrlib.core.effects.MODIFIER_KINDS]. Returns: diff --git a/src/osrlib/core/items.py b/src/osrlib/core/items.py index 15988da..9cfb1f3 100644 --- a/src/osrlib/core/items.py +++ b/src/osrlib/core/items.py @@ -70,7 +70,7 @@ from collections.abc import Mapping from enum import StrEnum -from typing import Annotated, Any, Literal +from typing import TYPE_CHECKING, Annotated, Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -82,6 +82,9 @@ from osrlib.core.treasure import MagicItemType, TreasureEntry from osrlib.core.validation import Rejection +if TYPE_CHECKING: + from osrlib.core.character import Character + __all__ = [ "AmmunitionTemplate", "AnyInstance", @@ -2141,7 +2144,14 @@ class SwordControlResult(BaseModel): """True when the sword's total is higher and it takes charge. A tie goes to the wielder.""" -def sword_control_check(character: Any, sword: MagicItemInstance, *, stream: RngStream) -> SwordControlResult: +# The annotation stays quoted because `Character` is imported for type checking alone, +# and this signature is read at runtime, where a bare forward reference cannot resolve. +def sword_control_check( + character: "Character", # noqa: UP037 + sword: MagicItemInstance, + *, + stream: RngStream, +) -> SwordControlResult: """Resolve one contest of wills between a sentient sword and the character holding it. A sentient sword can try to take charge of its wielder. This runs that contest and @@ -2155,9 +2165,8 @@ def sword_control_check(character: Any, sword: MagicItemInstance, *, stream: Rng their hit points. The sword takes charge when its total is strictly higher. Args: - character: The wielder. Any object with ability scores, hit points, and an - alignment satisfies it. In practice a - [`Character`][osrlib.core.character.Character]. Nothing is mutated. + character: The wielder, a [`Character`][osrlib.core.character.Character]: the contest reads the + ability scores that no monster has. Nothing is mutated. sword: The sword, which must have a [`SwordSentience`][osrlib.core.items.SwordSentience]. stream: The RNG stream the situational dice come from. Pass a session stream so diff --git a/src/osrlib/core/spells.py b/src/osrlib/core/spells.py index 2ed8390..4cd14b7 100644 --- a/src/osrlib/core/spells.py +++ b/src/osrlib/core/spells.py @@ -41,8 +41,9 @@ [`validate_turn_undead`][osrlib.core.spells.validate_turn_undead], then [`turn_undead`][osrlib.core.spells.turn_undead]. -Casters are [`Character`][osrlib.core.character.Character] objects. Targets arrive duck-typed per -the combatant convention (see [`osrlib.core.combat`][osrlib.core.combat]) as characters, +Casters are [`Caster`][osrlib.core.creature.Caster] values, which a +[`Character`][osrlib.core.character.Character] satisfies and a monster does not. Targets are +[`Creature`][osrlib.core.creature.Creature] values, characters or [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, or location strings for effects a game attaches to places rather than to creatures. @@ -112,8 +113,8 @@ # Import direction: the data loaders import these models and character.py imports # the loaders, so this module must never import character.py. Casting, memorization, -# and turning therefore take caster objects duck-typed, per the combatant -# convention, and character.py imports MemorizedSpell from here, never the reverse. +# and turning therefore take their caster as the `Caster` protocol, which a character +# satisfies, and character.py imports MemorizedSpell from here, never the reverse. from collections.abc import Mapping, Sequence from typing import Any, Literal @@ -121,6 +122,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from osrlib.core.abilities import AbilityScore +from osrlib.core.alignment import Alignment from osrlib.core.classes import ClassDefinition from osrlib.core.clock import ROUNDS_PER_DAY, GameClock, TimeUnit from osrlib.core.combat import ( @@ -137,8 +139,11 @@ saving_throw, select_targets, ) +from osrlib.core.creature import Caster, Creature from osrlib.core.dice import parse, roll from osrlib.core.effects import ( + ActiveCondition, + ActiveModifier, Condition, EffectDefinition, EffectsLedger, @@ -162,7 +167,7 @@ TurningTypeOutcome, UndeadTurnedEvent, ) -from osrlib.core.monsters import MonsterTemplate +from osrlib.core.monsters import MonsterInstance, MonsterTemplate from osrlib.core.rng import RngStream, StreamName from osrlib.core.ruleset import Ruleset from osrlib.core.tables import turning_column @@ -1166,7 +1171,7 @@ def accepted(self) -> bool: def memorize_spells( - caster: Any, definition: ClassDefinition, catalog: SpellCatalog, selections: Sequence[MemorizedSpell] + caster: Caster, definition: ClassDefinition, catalog: SpellCatalog, selections: Sequence[MemorizedSpell] ) -> MemorizationResult: """Fill a caster's spell slots for the day, replacing whatever was memorized before. @@ -1189,8 +1194,9 @@ def memorize_spells( them, so if you drive the rules yourself you decide when preparation is allowed. Args: - caster: The caster preparing spells, a [`Character`][osrlib.core.character.Character] whose - `memorized_spells` this replaces. Nothing is written when the call is rejected. + caster: The [`Caster`][osrlib.core.creature.Caster] preparing spells, which a + [`Character`][osrlib.core.character.Character] satisfies. Its `memorized_spells` is what this + replaces. Nothing is written when the call is rejected. definition: The caster's class, as a [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from [`load_classes`][osrlib.data.load_classes]. Its progression row at the caster's level @@ -1328,7 +1334,7 @@ def accepted(self) -> bool: return not self.rejections -def open_book_capacity(caster: Any, definition: ClassDefinition, catalog: SpellCatalog) -> tuple[int, ...]: +def open_book_capacity(caster: Caster, definition: ClassDefinition, catalog: SpellCatalog) -> tuple[int, ...]: """Return how many more spells fit in an arcane caster's book, at each spell level. Ask this before you offer a player a spell to learn, so the menu only shows levels with room in @@ -1346,8 +1352,9 @@ def open_book_capacity(caster: Any, definition: ClassDefinition, catalog: SpellC until their levels come back. Args: - caster: The caster, a [`Character`][osrlib.core.character.Character]. Its `spell_book` and - `level` are read and nothing is written. + caster: The [`Caster`][osrlib.core.creature.Caster], which a + [`Character`][osrlib.core.character.Character] satisfies. Its `spell_book` and `level` are read + and nothing is written. definition: The caster's class, as a [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from [`load_classes`][osrlib.data.load_classes]. @@ -1400,7 +1407,7 @@ def open_book_capacity(caster: Any, definition: ClassDefinition, catalog: SpellC def add_spell_to_book( - caster: Any, definition: ClassDefinition, catalog: SpellCatalog, spell_id: str + caster: Caster, definition: ClassDefinition, catalog: SpellCatalog, spell_id: str ) -> SpellBookResult: """Write a spell into an arcane caster's spell book. @@ -1421,9 +1428,9 @@ def add_spell_to_book( also passes no time. Args: - caster: The caster learning the spell, a [`Character`][osrlib.core.character.Character] with - an arcane class. Its `spell_book` grows by one id. Nothing is written when the call is - rejected. + caster: The [`Caster`][osrlib.core.creature.Caster] learning the spell, a + [`Character`][osrlib.core.character.Character] with an arcane class. Its `spell_book` grows by one + id. Nothing is written when the call is rejected. definition: The caster's class, as a [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from [`load_classes`][osrlib.data.load_classes]. @@ -1498,7 +1505,7 @@ def add_spell_to_book( return SpellBookResult(events=(SpellBookUpdatedEvent(caster_id=_entity_id(caster), spell_id=spell_id),)) -def forget_excess_memorized(caster: Any, definition: ClassDefinition, catalog: SpellCatalog) -> list[Event]: +def forget_excess_memorized(caster: Caster, definition: ClassDefinition, catalog: SpellCatalog) -> list[Event]: """Drop memorized copies the caster no longer has the slots for. Call this after anything that lowers a caster's level, which in B/X means energy drain. Their @@ -1515,8 +1522,9 @@ def forget_excess_memorized(caster: Any, definition: ClassDefinition, catalog: S that is decidable from the list itself and gives the same answer on every replay. Args: - caster: The caster who lost levels, a [`Character`][osrlib.core.character.Character]. Its - `memorized_spells` shrinks. A caster with nothing memorized is left alone. + caster: The [`Caster`][osrlib.core.creature.Caster] who lost levels, which a + [`Character`][osrlib.core.character.Character] satisfies. Its `memorized_spells` shrinks. A caster + with nothing memorized is left alone. definition: The caster's class, as a [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from [`load_classes`][osrlib.data.load_classes]. Its row at the caster's new level supplies @@ -1714,13 +1722,13 @@ def _memorized_index(caster: Any, spell: SpellTemplate, reversed: bool, profile: def validate_cast( - caster: Any, + caster: Caster, spell: SpellTemplate, mode: str, *, profile: CasterProfile | None, reversed: bool = False, - targets: Sequence[object] = (), + targets: Sequence[Creature | str] = (), context: CastContext | None = None, ledger: EffectsLedger | None = None, ) -> list[Rejection]: @@ -1750,7 +1758,8 @@ def validate_cast( inventory itself. Args: - caster: The caster, a [`Character`][osrlib.core.character.Character]. Read, never written. + caster: The [`Caster`][osrlib.core.creature.Caster], which a + [`Character`][osrlib.core.character.Character] satisfies. Read, never written. spell: The [`SpellTemplate`][osrlib.core.spells.SpellTemplate] to cast, from [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. mode: Which usage of the spell, by its @@ -1762,12 +1771,10 @@ def validate_cast( the form it was prepared in. Pass `None` to skip the memorized-copy check entirely, for a scroll read, where the scroll is the copy. reversed: True to cast the spell's reversed form. - targets: The candidate targets, per the combatant convention (see - [`osrlib.core.combat`][osrlib.core.combat]): - [`Character`][osrlib.core.character.Character] or - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, or location strings - for spells your game attaches to a place rather than a creature. Only the count is - examined here. + targets: The candidate targets: [`Creature`][osrlib.core.creature.Creature] values, which a + `Character` and a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy, or + location strings for spells your game attaches to a place rather than a creature. Only the count + is examined here. context: The [`CastContext`][osrlib.core.spells.CastContext] with what you assert about the situation. `None` asserts nothing. ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger], consulted for effects on @@ -1964,13 +1971,13 @@ def affect(self, target: Any) -> None: def cast_spell( - caster: Any, + caster: Caster, spell: SpellTemplate, mode: str, *, profile: CasterProfile, reversed: bool = False, - targets: Sequence[object] = (), + targets: Sequence[Creature | str] = (), context: CastContext | None = None, ledger: EffectsLedger, clock: GameClock, @@ -2010,20 +2017,18 @@ def cast_spell( from `effects_stream`. Args: - caster: The caster, a [`Character`][osrlib.core.character.Character] with a matching - memorized copy. Its `memorized_spells` loses that copy. + caster: The [`Caster`][osrlib.core.creature.Caster] with a matching memorized copy, which a + [`Character`][osrlib.core.character.Character] satisfies. Its `memorized_spells` loses that copy. spell: The [`SpellTemplate`][osrlib.core.spells.SpellTemplate] to cast, from [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. mode: Which usage of the spell, by its [`SpellMode.key`][osrlib.core.spells.SpellMode]. profile: The caster's [`CasterProfile`][osrlib.core.spells.CasterProfile], from [`caster_profile`][osrlib.core.spells.caster_profile]. reversed: True to cast the spell's reversed form. - targets: The candidate targets in your own order, per the combatant convention (see - [`osrlib.core.combat`][osrlib.core.combat]): - [`Character`][osrlib.core.character.Character] or - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, or location strings - for spells your game attaches to a place. Casting drops the ineligible ones and then - applies the mode's targeting to the rest, so passing more candidates than the spell can + targets: The candidate targets in your own order: [`Creature`][osrlib.core.creature.Creature] values, + which a `Character` and a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy, + or location strings for spells your game attaches to a place. Casting drops the ineligible ones + and then applies the mode's targeting to the rest, so passing more candidates than the spell can take is normal for an area or group mode. context: The [`CastContext`][osrlib.core.spells.CastContext] with what you assert about the situation. `None` asserts nothing. @@ -2138,11 +2143,11 @@ def cast_spell( def _perform_cast( - caster: Any, + caster: Caster, spell: SpellTemplate, spell_mode: SpellMode, reversed: bool, - targets: Sequence[object], + targets: Sequence[Creature | str], *, context: CastContext, ledger: EffectsLedger, @@ -2218,22 +2223,34 @@ def _perform_cast( class _ScrollReader: - """A duck-typed caster proxy: the reader's body at the scroll's caster level. + """A caster proxy: the reader's body at the scroll's caster level. Attribute reads and writes pass through to the reader, so conditions and modifiers land on the - real character. Only `level` is overridden, because a scroll spell resolves at the minimum class - level able to cast it. - """ - - __slots__ = ("_level", "_reader") - - def __init__(self, reader: Any, level: int) -> None: + real character. Only `level` is answered here, because a scroll spell resolves at the minimum + class level able to cast it. The proxy is passed where a + [`Caster`][osrlib.core.creature.Caster] is expected, so it declares that surface rather than + leaving every member to `__getattr__`. + """ + + __slots__ = ("_reader", "level") + + # Forwarded to the reader at runtime, and declared here so the type checker sees the + # caster surface the proxy stands in for. + id: str | None + name: str + alignment: Alignment | None + current_hp: int + max_hp: int + conditions: tuple[ActiveCondition, ...] + stat_modifiers: tuple[ActiveModifier, ...] + spell_book: tuple[str, ...] + memorized_spells: tuple[MemorizedSpell, ...] + level: int + """The scroll's caster level, which is the one thing the proxy answers for itself.""" + + def __init__(self, reader: Caster, level: int) -> None: object.__setattr__(self, "_reader", reader) - object.__setattr__(self, "_level", level) - - @property - def level(self) -> int: - return object.__getattribute__(self, "_level") + object.__setattr__(self, "level", level) def __getattr__(self, name: str): return getattr(object.__getattribute__(self, "_reader"), name) @@ -2298,12 +2315,12 @@ def minimum_caster_level(spell: SpellTemplate) -> int: def validate_scroll_cast( - reader: Any, + reader: Caster, spell: SpellTemplate, mode: str, *, reversed: bool = False, - targets: Sequence[object] = (), + targets: Sequence[Creature | str] = (), context: CastContext | None = None, ledger: EffectsLedger | None = None, ) -> list[Rejection]: @@ -2330,16 +2347,16 @@ def validate_scroll_cast( those. Args: - reader: The character reading the scroll, a - [`Character`][osrlib.core.character.Character]. Read, never written. + reader: The [`Caster`][osrlib.core.creature.Caster] reading the scroll, which a + [`Character`][osrlib.core.character.Character] satisfies. Read, never written. spell: The inscribed [`SpellTemplate`][osrlib.core.spells.SpellTemplate], from [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. mode: Which usage of the spell, by its [`SpellMode.key`][osrlib.core.spells.SpellMode]. A key the chosen form does not have is a rejection, not an exception. reversed: True to ask about the spell's reversed form. - targets: The candidate targets, per the combatant convention (see - [`osrlib.core.combat`][osrlib.core.combat]). Only the count is examined. `None` means no - targets, the same as an empty sequence. + targets: The candidate targets, [`Creature`][osrlib.core.creature.Creature] values or location + strings, as [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] takes them. Only the count + is examined. Leave it out for a spell with no targets. context: The [`CastContext`][osrlib.core.spells.CastContext] with what you assert about the situation. `None` asserts nothing. ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger], consulted for effects on the @@ -2395,12 +2412,12 @@ def validate_scroll_cast( def cast_from_scroll( - reader: Any, + reader: Caster, spell: SpellTemplate, mode: str, *, reversed: bool = False, - targets: Sequence[object] = (), + targets: Sequence[Creature | str] = (), context: CastContext | None = None, ledger: EffectsLedger, clock: GameClock, @@ -2440,18 +2457,16 @@ def cast_from_scroll( as it would from a spell they had memorized. Args: - reader: The character reading the scroll, a - [`Character`][osrlib.core.character.Character]. Nothing is taken from their memorized + reader: The [`Caster`][osrlib.core.creature.Caster] reading the scroll, which a + [`Character`][osrlib.core.character.Character] satisfies. Nothing is taken from their memorized spells. spell: The inscribed [`SpellTemplate`][osrlib.core.spells.SpellTemplate], from [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. mode: Which usage of the spell, by its [`SpellMode.key`][osrlib.core.spells.SpellMode]. reversed: True to cast the spell's reversed form. - targets: The candidate targets in your own order, per the combatant convention (see - [`osrlib.core.combat`][osrlib.core.combat]): - [`Character`][osrlib.core.character.Character] or - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, or location strings - for spells your game attaches to a place. + targets: The candidate targets in your own order: [`Creature`][osrlib.core.creature.Creature] values, + which a `Character` and a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy, + or location strings for spells your game attaches to a place. context: The [`CastContext`][osrlib.core.spells.CastContext] with what you assert about the situation. `None` asserts nothing. ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger] that ongoing effects attach @@ -2554,7 +2569,7 @@ def cast_from_scroll( ) -def disrupt_casting(caster: Any, spell_id: str, *, reversed: bool = False) -> list[Event]: +def disrupt_casting(caster: Caster, spell_id: str, *, reversed: bool = False) -> list[Event]: """Take away a spell a caster declared but never got to cast. A caster who announces a spell and is then hit, or fails a save, before their turn comes round @@ -2567,8 +2582,8 @@ def disrupt_casting(caster: Any, spell_id: str, *, reversed: bool = False) -> li which is what lets a divine caster's declared reversal cost them a normally prepared copy. Args: - caster: The caster who was interrupted, a [`Character`][osrlib.core.character.Character]. - Its `memorized_spells` loses one copy. + caster: The [`Caster`][osrlib.core.creature.Caster] who was interrupted, which a + [`Character`][osrlib.core.character.Character] satisfies. Its `memorized_spells` loses one copy. spell_id: The id of the spell they had declared. For the ids the shipped catalog uses, see [the spell id index][spells-index]. reversed: True when the declared cast was of the reversed form. @@ -2679,8 +2694,8 @@ def _eligible(target: Any, mode: SpellMode) -> bool: def _select_cast_targets( - caster: Any, mode: SpellMode, targets: Sequence[object], stream: RngStream -) -> tuple[list[object], list[Event]]: + caster: Caster, mode: SpellMode, targets: Sequence[Creature | str], stream: RngStream +) -> tuple[list[Any], list[Event]]: """Filter eligibility, then resolve the targeting mode over the survivors. Eligibility filtering happens inside resolution, never as a rejection, so ineligible candidates @@ -2692,7 +2707,7 @@ def _select_cast_targets( return list(targets), [] if targeting.mode is TargetingMode.SELF: return [caster], [] - eligible = [target for target in targets if _eligible(target, mode)] + eligible: list[Any] = [target for target in targets if _eligible(target, mode)] if targeting.mode is TargetingMode.SINGLE or (mode.effect is not None and "missiles_base" in mode.effect.params): return eligible, [] if targeting.mode is TargetingMode.HD_BUDGET: @@ -3475,7 +3490,7 @@ class TurnUndeadResult(BaseModel): """ -def validate_turn_undead(cleric: Any, definition: ClassDefinition) -> list[Rejection]: +def validate_turn_undead(cleric: Creature, definition: ClassDefinition) -> list[Rejection]: """Ask whether a character may attempt to turn undead, without rolling. Call this to decide whether to offer turning as an action at all. Then call @@ -3493,8 +3508,9 @@ def validate_turn_undead(cleric: Any, definition: ClassDefinition) -> list[Rejec that wants the stricter reading checks inventory itself. Args: - cleric: The character attempting the turning, a - [`Character`][osrlib.core.character.Character]. Read, never written. + cleric: The [`Creature`][osrlib.core.creature.Creature] attempting the turning, a + [`Character`][osrlib.core.character.Character] with a cleric's class definition. Read, never + written. definition: Their class, as a [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from [`load_classes`][osrlib.data.load_classes]. @@ -3542,9 +3558,9 @@ def validate_turn_undead(cleric: Any, definition: ClassDefinition) -> list[Rejec def turn_undead( - cleric: Any, + cleric: Caster, definition: ClassDefinition, - candidates: Sequence[Any], + candidates: Sequence[MonsterInstance], *, ledger: EffectsLedger, clock: GameClock, @@ -3581,7 +3597,9 @@ def turn_undead( find out what is undead. Args: - cleric: The character turning, a [`Character`][osrlib.core.character.Character]. + cleric: The [`Caster`][osrlib.core.creature.Caster] turning, which a + [`Character`][osrlib.core.character.Character] satisfies. Its `level` picks the row of the turning + table. definition: Their class, as a [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from [`load_classes`][osrlib.data.load_classes]. It must have the `turn_undead` tag. candidates: The monsters present, as From d823fbac743682a25d060f1bfdbdd81c4813f2f5 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 21:58:07 -0700 Subject: [PATCH 2/4] Type the crawl-layer call sites the kernel protocols now check (#104) Annotating the kernel's creature parameters turned three crawl-layer declarations into pyright errors, because each one launders a creature through object before handing it to a kernel function. battle.py's slow_attacks takes its element type from the party member it holds beside the declaration, and exploration.py's two spell target lists take theirs from what a cast accepts, a creature or the location string a place-targeted spell takes. _use_device's healing target is typed Any rather than Creature | None. Its value is resolved in one `effect_spec.kind == "healing"` block and used in a second block testing the same condition, which pyright cannot correlate, so the declared union stays None-tainted at the call into apply_healing and only Any clears it. Narrowing that properly means restructuring the two blocks, which is a change to the crawl layer this chunk has no business making. Nothing else in the crawl layer changed, no behaviour changed, and no golden file moved. pyright now reports zero errors. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- src/osrlib/crawl/battle.py | 3 ++- src/osrlib/crawl/exploration.py | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 2ce3801..dbe47d6 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -160,6 +160,7 @@ validate_attack, validate_breath, ) +from osrlib.core.creature import Creature from osrlib.core.dice import roll from osrlib.core.effects import EFFECTS_STREAM, Condition, has_condition from osrlib.core.events import AttackRolledEvent, Event, SavingThrowRolledEvent, SpellDisruptedEvent @@ -1654,7 +1655,7 @@ def _handle_resolve_battle_round(session, command: ResolveBattleRound) -> tuple[ fired_this_round: list[str] = [] fire_damaged_groups: set[str] = set() party_retreating = False - slow_attacks: list[tuple[object, BattleDeclaration]] = [] + slow_attacks: list[tuple[Creature, BattleDeclaration]] = [] def party_block() -> list[Event]: nonlocal party_retreating diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index f87d2b2..1f59c75 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -97,6 +97,7 @@ class up in its own private handler table and runs the handler it finds, which i natural_healing, saving_throw, ) +from osrlib.core.creature import Creature from osrlib.core.dice import roll from osrlib.core.effects import EFFECTS_STREAM, Condition, EffectDefinition, ModifierSpec from osrlib.core.events import Event, SavingThrowRolledEvent @@ -3220,7 +3221,7 @@ def _handle_cast_spell(session, command: CastSpell) -> tuple[list[Rejection], li if _location(session).kind == "dungeon" and session.ledger.active_on(_cell_ref(session), "silence"): return [Rejection(code="magic.cast.silenced_area", params={"caster": member.id})], [] registry = session.registry() - targets: list[object] = [] + targets: list[Creature | str] = [] for target_ref in command.targets: if target_ref.startswith("cell:"): targets.append(target_ref) @@ -3848,7 +3849,7 @@ def _use_scroll(session, member, instance: MagicItemInstance, template, command) return [Rejection(code="items.scroll.wrong_caster", params={"item": instance.instance_id})], [] mode = command.mode or spell.modes[0].key registry = session.registry() - targets: list[object] = [] + targets: list[Creature | str] = [] target_refs = command.targets or ((command.target_id,) if command.target_id else ()) for target_ref in target_refs: if target_ref.startswith("cell:"): @@ -4013,7 +4014,7 @@ def _use_device(session, member, instance: MagicItemInstance, template, command) return [Rejection(code="items.use.unknown_target", params={"target": command.target_id or ""})], [] if effect_spec is not None and effect_spec.kind == "striking": return [Rejection(code="items.use.battle_only", params={"item": instance.instance_id})], [] - target = None + target: Any = None if effect_spec is not None and effect_spec.kind == "healing": # Resolve the touch target before anything mutates: a rejected command # mutates nothing, and identification below is a mutation. From 3d40cc60e01a73f1fcd3307baad70add3751aab1 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 22:09:19 -0700 Subject: [PATCH 3/4] Corrections from the review of the kernel protocol annotations (#104) - The `osrlib.core.creature` module docstring claimed every rules function takes one of the three protocols. Six functions take a concrete creature type, which the paragraph below it already permits, so the opening sentence now allows for it and points at the function's own entry. - `validate_turn_undead` took its cleric as Creature while `turn_undead` took the same argument as Caster, and the validator's docstring calls it a character with a cleric's class. It takes Caster now. - Five Args entries in combat.py said "a Combatant that a Character or a MonsterInstance satisfies", which reads the wrong way round against the form every other entry uses. They now say "which a Character and a MonsterInstance both satisfy". - The rule each cast encodes is stated where a caller reads it. `deal_damage` says that only a monster instance has a regeneration ability, so a target that reaches the non-regenerable ledger is one and `nonregen_damage` is the field written, and that a monster instance records the round it was last damaged when a clock is passed. `resolve_energy_drain` says the class definition is what tells the two victims apart: a character loses experience levels, a monster loses Hit Dice. The comment beside `cast("Character", target)` now says why the type name is quoted, as items.py does. No signature changed except the validator's, no behaviour changed, and no golden file moved. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- src/osrlib/core/combat.py | 49 ++++++++++++++++++++++--------------- src/osrlib/core/creature.py | 13 +++++----- src/osrlib/core/spells.py | 4 +-- 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/src/osrlib/core/combat.py b/src/osrlib/core/combat.py index ea2364d..de4e8ca 100644 --- a/src/osrlib/core/combat.py +++ b/src/osrlib/core/combat.py @@ -1717,10 +1717,14 @@ def deal_damage( and never take a die below 1, so a source that rolled no dice has nothing to reduce. Hit points then fall, floored at 0. Fire and acid against a regenerating monster whose regeneration they block also accrue in its non-regenerable ledger, capped at its - maximum. Such a monster dies permanently only when its regeneration names a `revive` - entry, meaning it's the kind that gets back up, and the ledger alone reaches the - maximum. At 0 hit points the target dies, and a destructive source then destroys what - it carried. + maximum. Only a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] has a + regeneration ability, so a target that reaches that step is one, and the field written + there is the instance's `nonregen_damage`. A monster instance also records the round it + was last damaged whenever you pass a `clock`, which is what a revival countdown is + measured from. Such a monster dies permanently only when its regeneration names a + `revive` entry, meaning it's the kind that gets back up, and the ledger alone reaches + the maximum. At 0 hit points the target dies, and a destructive source then destroys + what it carried. Args: target: The [`Combatant`][osrlib.core.creature.Combatant] taking the damage, which a @@ -1858,9 +1862,9 @@ def destroy_equipment( survival. The rolls themselves are silent, and the event reports the outcome. Args: - target: The victim, a [`Combatant`][osrlib.core.creature.Combatant] that a - [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] satisfies. Its inventory is + target: The victim, a [`Combatant`][osrlib.core.creature.Combatant], which a + [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. Its inventory is emptied except for saved magic items, and what it wielded, wore, and had on its fingers is cleared. source: The destructive damage source, which selects the saving throw category. @@ -2199,8 +2203,8 @@ def resolve_splash_attack( attacker: The throwing [`Combatant`][osrlib.core.creature.Combatant], which a [`Character`][osrlib.core.character.Character] and a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. - defender: The target, a `Combatant` that a `Character` or a `MonsterInstance` - satisfies. Mutated in place when damage lands. + defender: The target, a `Combatant`, which a `Character` and a `MonsterInstance` + both satisfy. Mutated in place when damage lands. attack: The splash gear item, which is holy water or a flask of oil. context: The situation you assert. Oil does nothing unless `lit` is true. ruleset: The ruleset in play. @@ -2758,9 +2762,9 @@ def morale_triggers(members: Sequence[Creature]) -> list[str]: ones you've already acted on. Args: - members: The side's creatures, each a [`Creature`][osrlib.core.creature.Creature] that - a [`Character`][osrlib.core.character.Character] or a - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] satisfies. An empty side + members: The side's creatures, each a [`Creature`][osrlib.core.creature.Creature], + which a [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. An empty side raises nothing. Returns: @@ -3167,7 +3171,11 @@ def resolve_energy_drain(attacker: MonsterInstance, target: Creature, *, stream: leaves the drain to you. It reads the attacker's `energy_drain` tag for how many levels to take and which XP policy to use, then applies character drain or [`drain_monster_hd`][osrlib.core.combat.drain_monster_hd] according to what the target - is. The tag's own text describes what the victim becomes, and that text appears in the + is. What it is shows in its class definition: a + [`Character`][osrlib.core.character.Character] has one and loses experience levels + through [`drain_levels`][osrlib.core.classes.drain_levels], and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] has none and loses Hit Dice. + The tag's own text describes what the victim becomes, and that text appears in the drain event. Args: @@ -3216,7 +3224,8 @@ def resolve_energy_drain(attacker: MonsterInstance, target: Creature, *, stream: if getattr(target, "definition", None) is not None: # A class definition is a character's, and its absence is what marks a monster. # The branch has settled which the target is; the casts say so, since a `getattr` - # test proves nothing to the type checker. + # test proves nothing to the type checker. The type name stays quoted because + # `Character` is imported for type checking alone and `cast` never evaluates it. character = cast("Character", target) # The params gate above proves the ability is there. The second lookup is for its # prose, which the params don't carry. @@ -3395,9 +3404,9 @@ def resolve_gaze( Args: gazer: The gazing [`Creature`][osrlib.core.creature.Creature], which in the SRD's monsters is always a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. Nothing is read from it. - engaged: The creatures in melee with it, each a [`Combatant`][osrlib.core.creature.Combatant] - that a [`Character`][osrlib.core.character.Character] or a `MonsterInstance` - satisfies. + engaged: The creatures in melee with it, each a [`Combatant`][osrlib.core.creature.Combatant], + which a [`Character`][osrlib.core.character.Character] and a `MonsterInstance` both + satisfy. stream: The stream the saves draw from, conventionally [`COMBAT_STREAM`][osrlib.core.combat.COMBAT_STREAM]. One draw per combatant that has to save. @@ -3541,9 +3550,9 @@ def resolve_breath( Args: monster: The breathing [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. Its `breath_uses_today` goes up when the breath has a daily limit. - targets: The creatures caught in the breath, each a [`Combatant`][osrlib.core.creature.Combatant] - that a [`Character`][osrlib.core.character.Character] or a `MonsterInstance` - satisfies. Mutated in place. + targets: The creatures caught in the breath, each a [`Combatant`][osrlib.core.creature.Combatant], + which a [`Character`][osrlib.core.character.Character] and a `MonsterInstance` both + satisfy. Mutated in place. ruleset: The ruleset in play. stream: The stream every draw comes from, conventionally [`COMBAT_STREAM`][osrlib.core.combat.COMBAT_STREAM]: one save per target, the diff --git a/src/osrlib/core/creature.py b/src/osrlib/core/creature.py index b70bbd8..fc06630 100644 --- a/src/osrlib/core/creature.py +++ b/src/osrlib/core/creature.py @@ -1,12 +1,13 @@ """The attribute surface a character or a monster instance offers the rules, as protocols. -Every rules function in [`osrlib.core.combat`][osrlib.core.combat], [`osrlib.core.spells`][osrlib.core.spells], +A rules function in [`osrlib.core.combat`][osrlib.core.combat], [`osrlib.core.spells`][osrlib.core.spells], and [`osrlib.core.effects`][osrlib.core.effects] takes the creature it acts on as one of the three protocols -here. A [`Character`][osrlib.core.character.Character] and a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] -satisfy them structurally, so you pass either without a cast, and pyright checks that whatever else you pass -has the attributes the function reads. Nothing here is instantiated. Read a protocol to learn what a function -needs from its argument, and annotate your own code with it when you write a function that takes either kind -of creature. +here, unless what it reads needs a single concrete type, in which case it takes that type and says so in its +own entry. A [`Character`][osrlib.core.character.Character] and a +[`MonsterInstance`][osrlib.core.monsters.MonsterInstance] satisfy the protocols structurally, so you pass +either without a cast, and pyright checks that whatever else you pass has the attributes the function +reads. Nothing here is instantiated. Read a protocol to learn what a function needs from its argument, and +annotate your own code with it when you write a function that takes either kind of creature. [`Creature`][osrlib.core.creature.Creature] is the base: an id, a name, hit points, conditions, and stat modifiers. [`Combatant`][osrlib.core.creature.Combatant] adds the combat numbers an attack or a saving throw reads. diff --git a/src/osrlib/core/spells.py b/src/osrlib/core/spells.py index 4cd14b7..e8d3f22 100644 --- a/src/osrlib/core/spells.py +++ b/src/osrlib/core/spells.py @@ -3490,7 +3490,7 @@ class TurnUndeadResult(BaseModel): """ -def validate_turn_undead(cleric: Creature, definition: ClassDefinition) -> list[Rejection]: +def validate_turn_undead(cleric: Caster, definition: ClassDefinition) -> list[Rejection]: """Ask whether a character may attempt to turn undead, without rolling. Call this to decide whether to offer turning as an action at all. Then call @@ -3508,7 +3508,7 @@ def validate_turn_undead(cleric: Creature, definition: ClassDefinition) -> list[ that wants the stricter reading checks inventory itself. Args: - cleric: The [`Creature`][osrlib.core.creature.Creature] attempting the turning, a + cleric: The [`Caster`][osrlib.core.creature.Caster] attempting the turning, a [`Character`][osrlib.core.character.Character] with a cleric's class definition. Read, never written. definition: Their class, as a [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from From 7d5c9dbf38daad37d1a95fd5163035186f1e11ab Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 22:11:02 -0700 Subject: [PATCH 4/4] Unmark the kernel-protocols acceptance tests Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- tests/test_creature_protocols.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_creature_protocols.py b/tests/test_creature_protocols.py index 15b6174..8e97f79 100644 --- a/tests/test_creature_protocols.py +++ b/tests/test_creature_protocols.py @@ -9,8 +9,6 @@ import inspect import typing -import pytest - from osrlib.core import combat, effects, items, spells from osrlib.core.alignment import Alignment from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character @@ -123,7 +121,6 @@ def _mentions_a_duck(annotation) -> bool: class TestTheKernelSignaturesNameTheProtocols: - @pytest.mark.xfail(reason="chunk: kernel-protocols") def test_no_public_creature_parameter_is_any_or_object(self): ducks = [ f"{module}.{function}({parameter.name}: {parameter.annotation})" @@ -132,7 +129,6 @@ def test_no_public_creature_parameter_is_any_or_object(self): ] assert ducks == [], "\n".join(ducks) - @pytest.mark.xfail(reason="chunk: kernel-protocols") def test_the_flagship_functions_take_a_combatant_or_a_caster(self): assert inspect.signature(combat.attack_roll).parameters["attacker"].annotation is Combatant assert inspect.signature(combat.attack_roll).parameters["defender"].annotation is Combatant