Skip to content

Annotate the kernel's creature parameters with the protocols (#104) - #142

Merged
mmacy merged 5 commits into
mainfrom
chunk/kernel-protocols
Sep 15, 2026
Merged

mmacy merged 5 commits into
mainfrom
chunk/kernel-protocols

Conversation

@mmacy

@mmacy mmacy commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Every public creature parameter in core/combat.py, core/spells.py, core/effects.py, and core/items.py now names what the function reads through it, in place of Any and object.

  • A parameter whose reads are the id, name, alignment, hit points, conditions, and stat modifiers takes Creature: has_condition, apply_healing, incapacitated, alignments_differ, check_immunity, validate_attack, select_targets, and their kin.
  • One that reads THAC0, armour class, or a saving throw takes Combatant: attack_roll, resolve_attack, saving_throw, damage_roll, deal_damage (its destructive-kill path rolls the victim's saves), destroy_equipment, participant_modifier, melee_modifier_for, and resolve_splash_attack.
  • One that reads level, spell book, or memorized spells takes Caster: memorize_spells, validate_cast, cast_spell, cast_from_scroll, validate_turn_undead, turn_undead, and the rest of the casting surface.
  • One whose unguarded reads need what a single concrete type has takes that type: MonsterInstance for validate_breath, resolve_breath, drain_monster_hd, resolve_energy_drain's attacker, and turn_undead's candidates; Character for sword_control_check, imported under TYPE_CHECKING.
  • A sequence parameter takes a Sequence of the same choice, and a spell's targets takes Creature values or the location strings the module already documented for place-targeted spells.

The three protocols are unchanged. No function reads, unguarded, an attribute both concrete types have that the protocols lack, so nothing was added to osrlib.core.creature.

Runtime behaviour is identical. No branch, guard, or draw was added or moved. The only executable changes are imports, six typing.cast calls (three in deal_damage, three in resolve_energy_drain) where a runtime gate the type checker cannot see has already settled which concrete type is in hand, and _ScrollReader keeping the scroll's caster level in a slot rather than behind a read-only property, so the proxy satisfies Caster where it is passed as one and declares the members it forwards. The rule behind each cast is stated in the function's own docstring. Every Args entry that promised a Character or a MonsterInstance in prose now links the protocol and says which concrete types satisfy it, and the module prose that described the parameters as duck-typed names the protocols instead.

Three crawl-layer declarations are typed in the second commit, because each launders a creature through object on its way into a kernel function and pyright had nothing to check until the kernel said what it reads: battle.py's slow_attacks element type, and exploration.py's two spell target lists. _use_device's healing target is typed Any rather than Creature | None, since its value is resolved in one effect_spec.kind == "healing" block and used in a second block testing the same condition, which pyright cannot correlate; narrowing it properly means restructuring those blocks, which belongs to a crawl-scoped change rather than this one. Nothing else in the crawl layer changed.

Gate, run in this worktree at 3d40cc6 after merging origin/main: uv run pyright reports 0 errors, 0 warnings, 0 informations; uv run ruff format --check reports 160 files already formatted; uv run ruff check passes; uv run pytest reports 2935 passed, 146 skipped, 2 xpassed with no golden file touched; uv run mkdocs build --strict builds clean. uv run pytest --runxfail tests/test_creature_protocols.py passes all 7 tests. The two xpasses are this chunk's own markers, left in place for the lead to remove.

Closes #104

https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs

Every public creature parameter in core/combat.py, core/spells.py,
core/effects.py, and core/items.py now names what the function reads through
it, in place of Any and object. A parameter whose reads are the id, name,
alignment, hit points, conditions, and stat modifiers takes Creature; one that
reads THAC0, armour class, or a saving throw takes Combatant; one that reads
level, spell book, or memorized spells takes Caster; and one whose reads need
what a single concrete type has takes that type, so resolve_breath and
drain_monster_hd take a MonsterInstance and sword_control_check a Character.
A sequence parameter takes a Sequence of the same choice, and a spell's targets
take Creature values or the location strings the module already documented.

The point is that a front end's call is now checked from the signature and the
reference links the type, instead of prose promising what the annotation did
not say. The three protocols are unchanged: no function read, unguarded, an
attribute both concrete types have that the protocols lack, so nothing was
added to them.

Runtime behaviour is identical. No branch, guard, or draw was added or moved:
the only executable changes are imports, five typing.cast calls where a runtime
gate the checker cannot see has already settled which concrete type is in hand,
and _ScrollReader keeping the scroll's caster level in a slot rather than
behind a read-only property, so the proxy satisfies Caster where it is passed
as one and declares the members it forwards. The full suite passes with no
golden file touched.

pyright reports six errors, all in the crawl layer, which this chunk was told
not to touch: battle.py declares slow_attacks with an object element type, two
handlers in exploration.py declare their spell target list as list[object], and
one healing path leaves its target Unknown | None across two separate tests of
the same condition. Widening the kernel's Any to a protocol is what exposed
them; the kernel itself is clean.

Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs
Annotating the kernel's creature parameters turned three crawl-layer
declarations into pyright errors, because each one launders a creature through
object before handing it to a kernel function. battle.py's slow_attacks takes
its element type from the party member it holds beside the declaration, and
exploration.py's two spell target lists take theirs from what a cast accepts,
a creature or the location string a place-targeted spell takes.

_use_device's healing target is typed Any rather than Creature | None. Its
value is resolved in one `effect_spec.kind == "healing"` block and used in a
second block testing the same condition, which pyright cannot correlate, so
the declared union stays None-tainted at the call into apply_healing and only
Any clears it. Narrowing that properly means restructuring the two blocks,
which is a change to the crawl layer this chunk has no business making.

Nothing else in the crawl layer changed, no behaviour changed, and no golden
file moved. pyright now reports zero errors.

Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs
- The `osrlib.core.creature` module docstring claimed every rules function
  takes one of the three protocols. Six functions take a concrete creature
  type, which the paragraph below it already permits, so the opening sentence
  now allows for it and points at the function's own entry.
- `validate_turn_undead` took its cleric as Creature while `turn_undead` took
  the same argument as Caster, and the validator's docstring calls it a
  character with a cleric's class. It takes Caster now.
- Five Args entries in combat.py said "a Combatant that a Character or a
  MonsterInstance satisfies", which reads the wrong way round against the form
  every other entry uses. They now say "which a Character and a MonsterInstance
  both satisfy".
- The rule each cast encodes is stated where a caller reads it. `deal_damage`
  says that only a monster instance has a regeneration ability, so a target
  that reaches the non-regenerable ledger is one and `nonregen_damage` is the
  field written, and that a monster instance records the round it was last
  damaged when a clock is passed. `resolve_energy_drain` says the class
  definition is what tells the two victims apart: a character loses experience
  levels, a monster loses Hit Dice. The comment beside `cast("Character",
  target)` now says why the type name is quoted, as items.py does.

No signature changed except the validator's, no behaviour changed, and no
golden file moved.

Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs
@mmacy
mmacy merged commit 8fe2df8 into main Sep 15, 2026
4 checks passed
@mmacy
mmacy deleted the chunk/kernel-protocols branch September 15, 2026 05:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace duck-typed combatant and caster parameters with a protocol

1 participant