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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- [`Adventure.party`][osrlib.crawl.adventure.Adventure] records the party an adventure is written for, as a [`PartySpec`][osrlib.crawl.adventure.PartySpec] with the lowest and highest character level and, when the adventure says, the fewest and most characters, the way a published module states it on its cover ("for 6 to 8 characters of levels 1 to 3"). It defaults `None`, so an existing adventure document still loads and no `schema_version` moves. The engine never reads it, so no rule, event, or draw sequence changes. A front end can use it to offer a party that fits, a converter can carry the cover's figures across, and a playtest can pick its characters by level. `PartySpec` is frozen, refuses a level or size below 1, and refuses a maximum below its minimum.
- [`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. Runtime behaviour is unchanged, no rule, event, or draw sequence moved, and what changes is what a type checker accepts: a caller that passed an object without the protocol's attributes now fails its own pyright run. [`GameSession.combatant`][osrlib.crawl.session.GameSession.combatant] returns `MonsterInstance | Character | None` rather than `object | None`, so a front end can hand its result straight to a rules function.
- [`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`.
Expand Down
6 changes: 5 additions & 1 deletion src/osrlib/crawl/adventure.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,11 @@ class PartySpec(BaseModel):

@model_validator(mode="after")
def _ranges_in_order(self) -> PartySpec:
raise NotImplementedError("chunk: party-spec")
if self.max_level < self.min_level:
raise ValueError("max_level must not be below min_level")
if self.min_size is not None and self.max_size is not None and self.max_size < self.min_size:
raise ValueError("max_size must not be below min_size")
return self


class Adventure(BaseModel):
Expand Down
7 changes: 0 additions & 7 deletions tests/test_adventure_party.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,19 @@
from osrlib.crawl.adventure import Adventure, PartySpec
from osrlib.versioning import SCHEMA_VERSION, check_document, stamp_document

CHUNK = pytest.mark.xfail(reason="chunk: party-spec", raises=NotImplementedError)


@CHUNK
def test_levels_and_size():
party = PartySpec(min_level=1, max_level=3, min_size=6, max_size=8)
assert (party.min_level, party.max_level, party.min_size, party.max_size) == (1, 3, 6, 8)


@CHUNK
def test_size_is_optional():
party = PartySpec(min_level=2, max_level=2)
assert (party.min_size, party.max_size) == (None, None)
assert PartySpec(min_level=1, max_level=1, min_size=4).max_size is None
assert PartySpec(min_level=1, max_level=1, max_size=4).min_size is None


@CHUNK
@pytest.mark.parametrize(
"fields",
[
Expand All @@ -53,7 +48,6 @@ def test_zero_is_refused(fields):
PartySpec(**fields)


@CHUNK
def test_party_is_frozen():
party = PartySpec(min_level=1, max_level=3)
with pytest.raises(ValidationError):
Expand All @@ -64,7 +58,6 @@ def test_an_adventure_without_a_party_has_none():
assert build_adventure().party is None


@CHUNK
def test_party_round_trips_through_a_stamped_document():
adventure = build_adventure().model_copy(
update={"party": PartySpec(min_level=1, max_level=3, min_size=6, max_size=8)}
Expand Down
4 changes: 1 addition & 3 deletions tests/test_docs_example_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,7 @@ def check_block(source: str, filename: str) -> list[str]:

# Blocks the gate fails on today. The lead removes an entry when its chunk merges; a block's id
# carries its line range, so editing the block retires the mark by itself.
_EXPECTED_TO_FAIL: dict[str, str] = {
"src/osrlib/crawl/adventure.py:140-146": "chunk: party-spec",
}
_EXPECTED_TO_FAIL: dict[str, str] = {}


def _cases() -> list:
Expand Down
4 changes: 1 addition & 3 deletions tests/test_docs_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@


# Blocks that fail today because their chunk isn't built. The lead removes an entry when its chunk merges.
_EXPECTED_TO_FAIL: dict[str, str] = {
"src/osrlib/crawl/adventure.py:140-146": "chunk: party-spec",
}
_EXPECTED_TO_FAIL: dict[str, str] = {}


def _cases() -> list:
Expand Down
Loading