Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
310 changes: 176 additions & 134 deletions src/osrlib/core/combat.py

Large diffs are not rendered by default.

13 changes: 7 additions & 6 deletions src/osrlib/core/creature.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
61 changes: 40 additions & 21 deletions src/osrlib/core/effects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -647,7 +659,7 @@ class ActiveModifier(ModifierSpec):


def modifier_values(
target: Any,
target: Creature,
kind: str,
*,
element: str | None = None,
Expand All @@ -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.
Expand Down Expand Up @@ -730,7 +743,7 @@ def _matching_modifiers(


def modifier_total(
target: Any,
target: Creature,
kind: str,
*,
element: str | None = None,
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
19 changes: 14 additions & 5 deletions src/osrlib/core/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading