diff --git a/CHANGELOG.md b/CHANGELOG.md index 820e052..ea760e7 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..de4e8ca 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, @@ -1704,15 +1717,20 @@ 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 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 +1791,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 +1803,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 +1825,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 +1839,7 @@ def deal_damage( def destroy_equipment( - target: Any, + target: Combatant, *, source: DamageSource | None = None, ruleset: Ruleset | None = None, @@ -1839,10 +1862,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], 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. `None` means the death category. ruleset: The ruleset in play. `None` skips the save and everything burns. @@ -1928,8 +1952,8 @@ def destroy_equipment( def resolve_attack( - attacker: Any, - defender: Any, + attacker: Combatant, + defender: Combatant, attack: Attack, *, context: AttackContext, @@ -1963,10 +1987,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 +2175,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 +2200,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`, 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. @@ -2251,7 +2275,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 +2289,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 +2633,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 +2646,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 +2671,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 +2681,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 +2707,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 +2717,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 +2747,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 +2762,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], + which a [`Character`][osrlib.core.character.Character] and a + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] both satisfy. An empty side + raises nothing. Returns: The raised trigger keys. @@ -2768,13 +2796,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 +2825,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 +2892,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 +2911,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 +2963,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 +2980,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 +3071,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 +3163,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 @@ -3143,15 +3171,19 @@ def resolve_energy_drain(attacker: Any, target: Any, *, stream: RngStream) -> li 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: 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 +3222,27 @@ 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. 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. + 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 +3254,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 +3284,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 +3312,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 +3353,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 +3379,8 @@ def select_targets( def resolve_gaze( - gazer: object, - engaged: Sequence[object], + gazer: Creature, + engaged: Sequence[Combatant], *, stream: RngStream, ledger: EffectsLedger, @@ -3362,9 +3402,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], + 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. @@ -3421,7 +3463,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 +3517,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 +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 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], + 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/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..e8d3f22 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: 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 @@ -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 [`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 [`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 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. 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