diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ce450c..d2ac76c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/src/osrlib/crawl/adventure.py b/src/osrlib/crawl/adventure.py index 1a2688b..bdfbadd 100644 --- a/src/osrlib/crawl/adventure.py +++ b/src/osrlib/crawl/adventure.py @@ -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): diff --git a/tests/test_adventure_party.py b/tests/test_adventure_party.py index d6c5af4..ff5ccfe 100644 --- a/tests/test_adventure_party.py +++ b/tests/test_adventure_party.py @@ -9,16 +9,12 @@ 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) @@ -26,7 +22,6 @@ def test_size_is_optional(): assert PartySpec(min_level=1, max_level=1, max_size=4).min_size is None -@CHUNK @pytest.mark.parametrize( "fields", [ @@ -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): @@ -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)} diff --git a/tests/test_docs_example_output.py b/tests/test_docs_example_output.py index d3ad870..48e821a 100644 --- a/tests/test_docs_example_output.py +++ b/tests/test_docs_example_output.py @@ -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: diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index 79fa020..b908c70 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -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: