From c33a4f82a1b37a8cf836c2db8681847636946865 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 17:42:26 -0700 Subject: [PATCH 1/3] Add contracts and acceptance tests for the door write and end_encounter fixes DungeonState.door now documents that a new state starts from the door's DoorSpec in the session's adventure. A door that starts open reads open, and writing one field, such as discovered, changes only that field. Today the first write stores a closed door and blocks the party. end_encounter now documents ValueError when no encounter is open, raised before it changes anything or takes a draw. Today it raises AttributeError. The new tests are marked xfail for chunk door-and-end. The unmarked tests pass today and guard closing the door, a door that starts closed, and a DungeonState with no session. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- src/osrlib/crawl/dungeon.py | 36 ++-- src/osrlib/crawl/encounter.py | 7 +- tests/test_door_and_end_encounter.py | 296 +++++++++++++++++++++++++++ 3 files changed, 320 insertions(+), 19 deletions(-) create mode 100644 tests/test_door_and_end_encounter.py diff --git a/src/osrlib/crawl/dungeon.py b/src/osrlib/crawl/dungeon.py index 8f9b14d..844eef5 100644 --- a/src/osrlib/crawl/dungeon.py +++ b/src/osrlib/crawl/dungeon.py @@ -1973,30 +1973,34 @@ def door(self, ref: str) -> DoorState: changes `doors`. - When the door has an entry, the method returns that entry. Writing to it writes to `doors`. - - When the door has no entry, the method returns a new all-`False` state and doesn't store it. - The first time you set one of its fields, the state becomes the door's entry in `doors`. If - the door has gained an entry in the meantime, the write goes to that entry instead. - - The method doesn't read the door's [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec], so it - ignores `starts_open`: - - - A door whose `starts_open` is set stands open in play. Until the door has an entry, the - state this method returns still reads `open` as `False`. - - The first time you set a field through this method, like `door(ref).discovered = True`, - the method stores the entry with `open` still `False`. - - When the session's commands change a door, they store an entry with `starts_open` - applied. + - When the door has no entry, the method returns a new state and doesn't store it. The first + time you set one of its fields, the state becomes the door's entry in `doors`. If the door + has gained an entry in the meantime, the write goes to that entry instead. + + A new state starts from the door as you wrote it in its + [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec], the same way as the entry the session's own + commands store. For a door whose `starts_open` is set, `open` reads `True`, and every other + field reads `False`. So setting one field changes only that field. For example, + `door(ref).discovered = True` on a door that starts open stores an entry that is open and + discovered, and the party can still walk through. + + The method finds the `DoorSpec` in the adventure of the session that holds this state as its + [`GameSession.dungeon_state`][osrlib.crawl.session.GameSession.dungeon_state], whether you made + the session with [`GameSession.new`][osrlib.crawl.session.GameSession.new] or + [`load_game`][osrlib.persistence.load_game]. A `DungeonState` you make yourself has no adventure, + and neither has a reference that names no door in the adventure. For those, a new state reads + `False` in every field. To show doors the way the party sees them, read the player view from - [`GameSession.view`][osrlib.crawl.session.GameSession.view] instead. + [`GameSession.view`][osrlib.crawl.session.GameSession.view]. Args: ref: The door's [`edge_ref`][osrlib.crawl.dungeon.edge_ref]. You get the same reference from either side of a door, so either side reaches the same state. Returns: - The door's entry in `doors`, or a new all-`False` state that becomes the entry the first - time you set one of its fields. + The door's entry in `doors`, or a new state that starts from the door's `DoorSpec` and + becomes the entry the first time you set one of its fields. Examples: ```python diff --git a/src/osrlib/crawl/encounter.py b/src/osrlib/crawl/encounter.py index 9394ecd..f729212 100644 --- a/src/osrlib/crawl/encounter.py +++ b/src/osrlib/crawl/encounter.py @@ -1152,8 +1152,8 @@ def end_encounter(session, outcome: str) -> list[Event]: [`replay_game`][osrlib.persistence.replay_game] reproduces the close. The session doesn't record a direct call to this function, so a replay of the game stays in the encounter. - An encounter must be open when you call it. If `session.encounter` is None, it raises - `AttributeError`, because the function doesn't check for an open encounter first. When it + An encounter must be open when you call it. If `session.encounter` is None, the function raises + `ValueError` before it changes anything or draws from a random number stream. When it returns, `session.encounter` is None. If the session was in `encounter` or `battle` mode, it's now in `exploring` mode. @@ -1202,7 +1202,8 @@ def end_encounter(session, outcome: str) -> list[Event]: clock advance after it. Raises: - AttributeError: If no encounter is open on the session. + ValueError: If no encounter is open on the session. The session is left as it was, with no + draws taken and no time passed. Examples: ```python diff --git a/tests/test_door_and_end_encounter.py b/tests/test_door_and_end_encounter.py new file mode 100644 index 0000000..f4912f2 --- /dev/null +++ b/tests/test_door_and_end_encounter.py @@ -0,0 +1,296 @@ +"""Acceptance tests for a door write that keeps the door's authored state, and `end_encounter` with nothing open. + +A door's stored state starts from the door as the adventure wrote it. Before the fix, +the first write through [`DungeonState.door`][osrlib.crawl.dungeon.DungeonState.door] +stored a state with every field `False`. Play and the player view read the stored +`open` before the authored `starts_open`, so writing an unrelated field, such as +`discovered`, closed a door that starts open and stopped the party from walking through. +Issue #159 fixed the same problem for reads. + +The contract these tests check: + +- A new state from `DungeonState.door` starts from the door's `DoorSpec`. A door that + starts open reads open, before and after a write to any other field, and the write + changes only the field it sets. +- The party walks through such a door, and the player view shows it open. +- A referee's `SetDoorState` still closes it, and so does writing `open = False`. +- A save and a load keep it open, and a loaded session's `DungeonState.door` starts new + states from the same authored defaults. +- A door that starts closed stays closed after a write to another field. +- `end_encounter` with no open encounter raises `ValueError` before it changes anything + or takes a draw, as the kernel-argument rule in `AGENTS.md` requires. + +Tests marked `xfail` fail until the chunk lands. Unmarked tests pass today and guard +behavior the fix must keep. +""" + +import pytest + +from crawl_fixtures import build_open_door_adventure, build_party +from osrlib.core.events import Visibility +from osrlib.crawl.adventure import Adventure, TownSpec +from osrlib.crawl.commands import EnterDungeon, MoveParty, SessionMode, SetDoorState +from osrlib.crawl.dungeon import ( + Direction, + DoorSpec, + DoorState, + DungeonSpec, + DungeonState, + Edge, + EdgeKind, + LevelSpec, + WanderingSpec, + edge_key, +) +from osrlib.crawl.encounter import end_encounter +from osrlib.crawl.session import GameSession +from osrlib.persistence import load_game, save_game + +CHUNK = pytest.mark.xfail(reason="chunk: door-and-end", strict=True) + +DOOR_REF = "vestibule:1:1,0:west" +"""The only door in both fixtures, east of the entrance cell (0, 0).""" + +SET_DOOR = {"dungeon_id": "vestibule", "level_number": 1, "x": 1, "y": 0, "direction": Direction.WEST} +"""The `SetDoorState` address of `DOOR_REF`.""" + +OTHER_FIELDS = ["discovered", "unlocked", "wedged"] +"""Fields a caller can write without meaning to open or close the door.""" + + +def build_closed_door_adventure() -> Adventure: + """Build the same two cells as `build_open_door_adventure`, with a door that starts closed.""" + edges = {edge_key((0, 0), Direction.EAST): Edge(kind=EdgeKind.DOOR, door=DoorSpec())} + level = LevelSpec( + number=1, width=2, height=1, edges=edges, entrance=(0, 0), wandering=WanderingSpec(chance_in_six=0) + ) + return Adventure( + name="The Shut Door", + description="Two cells and a door that starts closed.", + town=TownSpec(name="Threshold", travel_turns={"vestibule": 1}), + dungeons=(DungeonSpec(id="vestibule", name="Vestibule", levels=(level,)),), + ) + + +def session_at_door(adventure: Adventure) -> GameSession: + """The party at (0, 0), facing the untouched door to the east.""" + session = GameSession.new(build_party(), adventure, seed=5) + assert session.execute(EnterDungeon(dungeon_id="vestibule")).accepted + assert session.dungeon_state.location.position == (0, 0) + assert DOOR_REF not in session.dungeon_state.doors, "the door must be untouched for the case to mean anything" + return session + + +def open_door_session() -> GameSession: + """The party beside a door whose `DoorSpec` sets `starts_open`.""" + session = session_at_door(build_open_door_adventure()) + edge = session.adventure.dungeon("vestibule").level(1).edge((0, 0), Direction.EAST) + assert edge.door is not None and edge.door.starts_open + return session + + +def closed_door_session() -> GameSession: + """The party beside a door that starts closed.""" + session = session_at_door(build_closed_door_adventure()) + edge = session.adventure.dungeon("vestibule").level(1).edge((0, 0), Direction.EAST) + assert edge.door is not None and not edge.door.starts_open + return session + + +def door_view_open(session: GameSession) -> bool | None: + """The player view's `door_open` for the vestibule door.""" + view = session.view(Visibility.PLAYER) + (level,) = [level for level in view.explored if level.dungeon_id == "vestibule"] + doors = [edge for edge in level.edges.values() if edge.kind == "door"] + assert len(doors) == 1 + return doors[0].door_open + + +def expected_state(**fields: bool) -> dict[str, bool]: + """A door state's fields as `model_dump` gives them: `False` except those named.""" + return {**DoorState().model_dump(), **fields} + + +def walk_east(session: GameSession) -> list[str]: + """Move the party east, and return the rejection codes, which are empty when it moved.""" + result = session.execute(MoveParty(direction=Direction.EAST)) + return [rejection.code for rejection in result.rejections] + + +# ---------------------------------------------------------------------- a door that starts open + + +class TestReadingADoorThatStartsOpen: + """A new state from `DungeonState.door` reads the door as the adventure wrote it.""" + + @CHUNK + def test_a_read_reads_it_open(self): + session = open_door_session() + assert session.dungeon_state.door(DOOR_REF).model_dump() == expected_state(open=True) + + def test_a_read_stores_nothing(self): + session = open_door_session() + _ = session.dungeon_state.door(DOOR_REF).open + assert DOOR_REF not in session.dungeon_state.doors + + +class TestWritingAnotherFieldKeepsItOpen: + """Writing one field of a door that starts open stores an entry that is still open.""" + + @CHUNK + @pytest.mark.parametrize("field", OTHER_FIELDS) + def test_the_write_changes_only_that_field(self, field): + session = open_door_session() + setattr(session.dungeon_state.door(DOOR_REF), field, True) + assert DOOR_REF in session.dungeon_state.doors + assert session.dungeon_state.doors[DOOR_REF].model_dump() == expected_state(open=True, **{field: True}) + + @CHUNK + @pytest.mark.parametrize("field", OTHER_FIELDS) + def test_the_party_walks_through(self, field): + session = open_door_session() + setattr(session.dungeon_state.door(DOOR_REF), field, True) + assert walk_east(session) == [] + assert session.dungeon_state.location.position == (1, 0) + + @CHUNK + @pytest.mark.parametrize("field", OTHER_FIELDS) + def test_the_player_view_shows_it_open(self, field): + session = open_door_session() + assert door_view_open(session) is True + setattr(session.dungeon_state.door(DOOR_REF), field, True) + assert door_view_open(session) is True + + @CHUNK + def test_a_write_from_another_read_goes_to_the_same_entry(self): + session = open_door_session() + first = session.dungeon_state.door(DOOR_REF) + second = session.dungeon_state.door(DOOR_REF) + first.discovered = True + second.unlocked = True + assert session.dungeon_state.doors[DOOR_REF].model_dump() == expected_state( + open=True, discovered=True, unlocked=True + ) + + +class TestClosingADoorThatStartsOpen: + """The fix keeps every way to close the door.""" + + def test_set_door_state_closes_an_untouched_door(self): + session = open_door_session() + result = session.execute(SetDoorState(**SET_DOOR, open=False)) + assert result.accepted + assert [event.code for event in result.events] == ["exploration.door.closed"] + assert session.dungeon_state.door(DOOR_REF).open is False + assert door_view_open(session) is False + assert walk_east(session) == ["exploration.move.blocked"] + + @CHUNK + def test_set_door_state_closes_it_after_a_write_of_another_field(self): + session = open_door_session() + session.dungeon_state.door(DOOR_REF).discovered = True + result = session.execute(SetDoorState(**SET_DOOR, open=False)) + assert result.accepted + assert [event.code for event in result.events] == ["exploration.door.closed"] + assert session.dungeon_state.doors[DOOR_REF].model_dump() == expected_state(discovered=True) + assert door_view_open(session) is False + assert walk_east(session) == ["exploration.move.blocked"] + + def test_writing_open_false_closes_it(self): + session = open_door_session() + session.dungeon_state.door(DOOR_REF).open = False + assert session.dungeon_state.doors[DOOR_REF].open is False + assert door_view_open(session) is False + assert walk_east(session) == ["exploration.move.blocked"] + + +class TestSavingADoorThatStartsOpen: + """A save and a load keep the door open, and a loaded session seeds new states the same way.""" + + @CHUNK + def test_a_written_door_is_open_after_a_load(self): + session = open_door_session() + session.dungeon_state.door(DOOR_REF).discovered = True + restored = load_game(save_game(session)) + assert restored.dungeon_state.doors[DOOR_REF].model_dump() == expected_state(open=True, discovered=True) + assert door_view_open(restored) is True + assert walk_east(restored) == [] + assert restored.dungeon_state.location.position == (1, 0) + + @CHUNK + def test_a_loaded_session_seeds_a_new_state_from_the_door(self): + session = open_door_session() + restored = load_game(save_game(session)) + assert DOOR_REF not in restored.dungeon_state.doors + assert restored.dungeon_state.door(DOOR_REF).open is True + restored.dungeon_state.door(DOOR_REF).discovered = True + assert restored.dungeon_state.doors[DOOR_REF].model_dump() == expected_state(open=True, discovered=True) + assert walk_east(restored) == [] + + +# ---------------------------------------------------------------------- a door that starts closed + + +class TestADoorThatStartsClosed: + """A door that starts closed stays closed after a write to another field.""" + + def test_a_read_reads_it_closed(self): + session = closed_door_session() + assert session.dungeon_state.door(DOOR_REF).model_dump() == expected_state() + assert DOOR_REF not in session.dungeon_state.doors + + @pytest.mark.parametrize("field", OTHER_FIELDS) + def test_a_write_of_another_field_keeps_it_closed(self, field): + session = closed_door_session() + setattr(session.dungeon_state.door(DOOR_REF), field, True) + assert session.dungeon_state.doors[DOOR_REF].model_dump() == expected_state(**{field: True}) + assert door_view_open(session) is False + assert walk_east(session) == ["exploration.move.blocked"] + + def test_it_stays_closed_after_a_load(self): + session = closed_door_session() + session.dungeon_state.door(DOOR_REF).discovered = True + restored = load_game(save_game(session)) + assert restored.dungeon_state.door(DOOR_REF).open is False + assert walk_east(restored) == ["exploration.move.blocked"] + + +class TestADungeonStateWithNoSession: + """A `DungeonState` made on its own has no adventure, so a new state reads `False` in every field.""" + + def test_a_new_state_is_all_false(self): + state = DungeonState() + assert state.door(DOOR_REF).model_dump() == expected_state() + state.door(DOOR_REF).discovered = True + assert state.doors[DOOR_REF].model_dump() == expected_state(discovered=True) + + +# ---------------------------------------------------------------------- end_encounter with nothing open + + +class TestEndEncounterWithNothingOpen: + """`end_encounter` refuses a session with no open encounter before it changes or draws anything.""" + + @CHUNK + def test_it_raises_value_error_and_changes_nothing(self): + session = open_door_session() + assert session.encounter is None + assert session.mode is SessionMode.EXPLORING + before = save_game(session) + streams_before = session.streams.export_states() + with pytest.raises(ValueError, match="encounter"): + end_encounter(session, "departed") + assert session.streams.export_states() == streams_before + assert save_game(session) == before + assert session.encounter is None + assert session.mode is SessionMode.EXPLORING + + @CHUNK + def test_it_raises_value_error_in_town(self): + session = GameSession.new(build_party(), build_open_door_adventure(), seed=5) + assert session.encounter is None + assert session.dungeon_state.location.kind == "town" + before = save_game(session) + with pytest.raises(ValueError, match="encounter"): + end_encounter(session, "victory") + assert save_game(session) == before From 7311c0c5bc095cffc4b3b54129a948652082ed0b Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 17:49:10 -0700 Subject: [PATCH 2/3] Start a new door state from its DoorSpec, and refuse end_encounter with nothing open DungeonState.door now starts a door with no entry from the door's DoorSpec, through the same seeding rule that the session's door commands use. A door that starts open reads open, and writing another field, such as discovered, stores an entry that is still open. The party can walk through it. GameSession.dungeon_state is now a property. Setting it links the state to the session's adventure, so GameSession.new and load_game both get the behavior. The link is a slot on DungeonState, outside its fields and private attributes. Equality, copies, pickles, and saves leave it out, so the save format and schema_version don't change. end_encounter now raises ValueError when no encounter is open, before it changes anything or takes a draw. Before, it raised AttributeError. No golden file changed. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 4 +++- src/osrlib/crawl/dungeon.py | 12 +++++++++++- src/osrlib/crawl/encounter.py | 2 ++ src/osrlib/crawl/exploration.py | 23 +++++++++++++++++++++++ src/osrlib/crawl/session.py | 23 ++++++++++++++++++++--- 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe1baf..634655a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - A temple service that would do nothing. - A battle round in which a member behind the front rank strikes with a staff of striking. - A battle round that declares a `close` move (an order to move toward a group) or an area spell at a group that has fled, that is, run out of the battle. + - A command after the whole party was turned to stone, unless `game_over` mode allows it. The session now ends the game when the last member turns to stone, so it refuses most later commands. - The replay runs without an error but plays out differently when the log contains any of these: - A lair the party left and came back to. - A rest in a dungeon, or a rest that a wandering monster interrupted. @@ -202,7 +203,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - The text is in the `narrative` of the `PartyMovedEvent` for the landing cell, the cell where the party arrives, and `format_message` adds it to that event's line. - For stairs within one level, the session emits that event only when the gate has success text. - `PartyMovedEvent.narrative` is a new field. It's `None` when the gate has no success text and when no gate was crossed. It defaults to `None`, so [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] stays 4. -- Reading a door's state with [`DungeonState.door`][osrlib.crawl.dungeon.DungeonState.door] no longer changes the door (#159). Before, the first call for a door stored an all-`False` entry. So a front end that read an untouched door whose [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec] sets `starts_open` left it shut for the rest of play, and a save recorded it as shut. Now the method stores nothing for a read. For an untouched door, it returns a new all-`False` state, so `open` reads `False` even when `starts_open` is set. To show doors as the party sees them, read the player view from [`GameSession.view`][osrlib.crawl.session.GameSession.view]. Setting a field on the state the method returns still stores the entry, as before. +- Reading a door's state with [`DungeonState.door`][osrlib.crawl.dungeon.DungeonState.door] no longer changes the door (#159). Before, the first call for a door stored an all-`False` entry. So a front end that read an untouched door whose [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec] sets `starts_open` left it shut for the rest of play, and a save recorded it as shut. Now the method stores nothing for a read. For an untouched door, it returns a new state that starts from the door's `DoorSpec`, so `open` reads `True` when `starts_open` is set and every other field reads `False`. Setting a field on that state stores the entry and changes only that field, so `door(ref).discovered = True` on a door that starts open stores a door that is open and discovered, and the party can still walk through. A `DungeonState` that no session holds has no adventure to read, so for it a new state still reads `False` in every field. To show doors as the party sees them, read the player view from [`GameSession.view`][osrlib.crawl.session.GameSession.view]. +- [`end_encounter`][osrlib.crawl.encounter.end_encounter] now raises `ValueError` when no encounter is open. Before, it raised `AttributeError`. It raises before it changes the session or draws from a random number stream, so a refused call leaves the game as it was. - Worn gauntlets of ogre power now raise the wearer's carrying capacity under detailed encumbrance, as their entry in the OSE SRD says (#166). A game uses detailed encumbrance when its [`Ruleset`][osrlib.core.ruleset.Ruleset] sets `encumbrance` to `EncumbranceMode.DETAILED`. That mode sets each character's movement rate from bands of carried weight. Each movement band and the maximum load rise by the template's `max_load_bonus_coins`, 1,000 coins. So the wearer moves at 120' up to 1,400 coins, not 400, and can still move at 2,600 coins, the raised maximum. When the party takes treasure with [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure], the engine loads the wearer up to that raised maximum. Basic encumbrance is unchanged, because the entry gives the extra weight under detailed encumbrance only. - [`damage_roll`][osrlib.core.combat.damage_roll] now adds an enchanted weapon's `damage_bonus` (#166). Before, it added the `attack_bonus`. Every built-in weapon has equal bonuses and deals the same damage as before. A weapon made from a custom template whose two bonuses differ now adds its damage bonus. A matching versus clause's `bonus` still replaces the damage bonus. - [`effective_hd`][osrlib.core.combat.effective_hd] now counts the Hit Dice a drained monster has left (#166). Before, it counted the template's, so a drained ogre still counted as 4 Hit Dice. *Sleep*'s 2d8 mode uses this count for what each creature costs from its budget and for its 4 Hit Dice limit. The entry "*Sleep* now judges a drained monster by the Hit Dice it has left", earlier in this section, covers the spell's test for a 4+1 creature. diff --git a/src/osrlib/crawl/dungeon.py b/src/osrlib/crawl/dungeon.py index 844eef5..f046708 100644 --- a/src/osrlib/crawl/dungeon.py +++ b/src/osrlib/crawl/dungeon.py @@ -1814,6 +1814,11 @@ class DungeonState(BaseModel): lock_failures: Which thief failed each lock, and at what level. """ + # `GameSession` stores here the function that `door` calls to start a new state from the door's + # `DoorSpec`. A slot sits outside the fields and the private attributes, so equality, copies, + # pickles, and saves all leave it out. A copy or a loaded save has none until a session takes it. + __slots__ = ("_new_door",) + model_config = ConfigDict(validate_assignment=True) location: PartyLocation = PartyLocation(kind="town") @@ -2020,10 +2025,15 @@ def door(self, ref: str) -> DoorState: state = self.doors.get(ref) if state is not None: return state - state = DoorState() + new_door: Callable[[str], DoorState] | None = getattr(self, "_new_door", None) + state = DoorState() if new_door is None else new_door(ref) state._write_through = lambda written, name, value: self._write_door(ref, written, name, value) return state + def _start_doors_from(self, new_door: Callable[[str], DoorState]) -> None: + """Make `door` start each new state with `new_door(ref)` instead of an all-`False` state.""" + object.__setattr__(self, "_new_door", new_door) + def _write_door(self, ref: str, state: DoorState, name: str, value: Any) -> None: """Store a state from `door` on its first field write, or copy the write to an entry stored since.""" stored = self.doors.get(ref) diff --git a/src/osrlib/crawl/encounter.py b/src/osrlib/crawl/encounter.py index f729212..c8db8a7 100644 --- a/src/osrlib/crawl/encounter.py +++ b/src/osrlib/crawl/encounter.py @@ -1254,6 +1254,8 @@ def end_encounter(session, outcome: str) -> list[Event]: from osrlib.crawl.session import DefeatedMonsterRecord state = session.encounter + if state is None: + raise ValueError("no encounter is open on the session") events: list[Event] = [] all_defeated = True for group in state.groups: diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index ae52b1b..94223ed 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -373,6 +373,29 @@ def _seed(edge, state: DoorState) -> DoorState: return state +def _new_door_state(adventure, ref: str) -> DoorState: + """A new state for the door at `ref`, seeded from its authored spec and never stored. + + [`DungeonState.door`][osrlib.crawl.dungeon.DungeonState.door] calls this for a door with no + entry, so the state it returns reads the same as the entry `_store_door_state` would store. A + reference that names no door in the adventure gets an all-`False` state. + + Args: + adventure (osrlib.crawl.adventure.Adventure): The session's adventure. + ref: The door's [`edge_ref`][osrlib.crawl.dungeon.edge_ref]. + + Returns: + A new state, with `open` set when the door starts open. + """ + try: + dungeon_id, level_number, cell, side = ref.rsplit(":", 3) + level = adventure.dungeon(dungeon_id).level(int(level_number)) + except ValueError: + return DoorState() + edge = level.edges.get(f"{cell}:{side}") + return DoorState() if edge is None else _seed(edge, DoorState()) + + def _read_door_state(session, edge, ref: str) -> DoorState: """The overlay entry for one door, for reading, never stored. diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index d1a0ffd..a2b9fd8 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -667,9 +667,6 @@ def __init__( """Which [`SessionMode`][osrlib.crawl.commands.SessionMode] the session is in, and so which commands it will accept. A new session starts in `town`.""" self.dungeon_state = DungeonState() - """Everything play has changed on top of the adventure's map: where the party is, which - cells it has walked and seen, door state, found and sprung traps, drop piles, and generated - caches. The dungeons in the adventure never change.""" self.monsters: dict[str, MonsterInstance] = {} """The live monsters, keyed by session id. Spawning adds to it, and a defeated monster is never removed, so a later event can still refer to it.""" @@ -866,6 +863,26 @@ def new(cls, party: Party, adventure: Adventure, *, seed: int, ruleset: Ruleset member.id = session.allocator.allocate("character") return session + @property + def dungeon_state(self) -> DungeonState: + """Everything play has changed on top of the adventure's map. + + It holds where the party is, which cells it has walked and seen, door state, found and sprung + traps, drop piles, and generated caches. The dungeons in the adventure never change. + + When you set it, the session links the new state to this session's adventure. Then + [`DungeonState.door`][osrlib.crawl.dungeon.DungeonState.door] starts a door with no entry from + the door's [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec]. + """ + return self._dungeon_state + + @dungeon_state.setter + def dungeon_state(self, state: DungeonState) -> None: + from osrlib.crawl import exploration + + state._start_doors_from(lambda ref: exploration._new_door_state(self.adventure, ref)) + self._dungeon_state = state + @property def metadata(self) -> dict[str, object]: """Return the versions a client checks to confirm it's talking to a compatible engine. From e7ca0df4b263d50ca79474239e7f2af6152a75e9 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 17:51:54 -0700 Subject: [PATCH 3/3] Remove the door-and-end markers Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- tests/test_door_and_end_encounter.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/test_door_and_end_encounter.py b/tests/test_door_and_end_encounter.py index f4912f2..eb0e027 100644 --- a/tests/test_door_and_end_encounter.py +++ b/tests/test_door_and_end_encounter.py @@ -20,8 +20,6 @@ - `end_encounter` with no open encounter raises `ValueError` before it changes anything or takes a draw, as the kernel-argument rule in `AGENTS.md` requires. -Tests marked `xfail` fail until the chunk lands. Unmarked tests pass today and guard -behavior the fix must keep. """ import pytest @@ -46,8 +44,6 @@ from osrlib.crawl.session import GameSession from osrlib.persistence import load_game, save_game -CHUNK = pytest.mark.xfail(reason="chunk: door-and-end", strict=True) - DOOR_REF = "vestibule:1:1,0:west" """The only door in both fixtures, east of the entrance cell (0, 0).""" @@ -123,7 +119,6 @@ def walk_east(session: GameSession) -> list[str]: class TestReadingADoorThatStartsOpen: """A new state from `DungeonState.door` reads the door as the adventure wrote it.""" - @CHUNK def test_a_read_reads_it_open(self): session = open_door_session() assert session.dungeon_state.door(DOOR_REF).model_dump() == expected_state(open=True) @@ -137,7 +132,6 @@ def test_a_read_stores_nothing(self): class TestWritingAnotherFieldKeepsItOpen: """Writing one field of a door that starts open stores an entry that is still open.""" - @CHUNK @pytest.mark.parametrize("field", OTHER_FIELDS) def test_the_write_changes_only_that_field(self, field): session = open_door_session() @@ -145,7 +139,6 @@ def test_the_write_changes_only_that_field(self, field): assert DOOR_REF in session.dungeon_state.doors assert session.dungeon_state.doors[DOOR_REF].model_dump() == expected_state(open=True, **{field: True}) - @CHUNK @pytest.mark.parametrize("field", OTHER_FIELDS) def test_the_party_walks_through(self, field): session = open_door_session() @@ -153,7 +146,6 @@ def test_the_party_walks_through(self, field): assert walk_east(session) == [] assert session.dungeon_state.location.position == (1, 0) - @CHUNK @pytest.mark.parametrize("field", OTHER_FIELDS) def test_the_player_view_shows_it_open(self, field): session = open_door_session() @@ -161,7 +153,6 @@ def test_the_player_view_shows_it_open(self, field): setattr(session.dungeon_state.door(DOOR_REF), field, True) assert door_view_open(session) is True - @CHUNK def test_a_write_from_another_read_goes_to_the_same_entry(self): session = open_door_session() first = session.dungeon_state.door(DOOR_REF) @@ -185,7 +176,6 @@ def test_set_door_state_closes_an_untouched_door(self): assert door_view_open(session) is False assert walk_east(session) == ["exploration.move.blocked"] - @CHUNK def test_set_door_state_closes_it_after_a_write_of_another_field(self): session = open_door_session() session.dungeon_state.door(DOOR_REF).discovered = True @@ -207,7 +197,6 @@ def test_writing_open_false_closes_it(self): class TestSavingADoorThatStartsOpen: """A save and a load keep the door open, and a loaded session seeds new states the same way.""" - @CHUNK def test_a_written_door_is_open_after_a_load(self): session = open_door_session() session.dungeon_state.door(DOOR_REF).discovered = True @@ -217,7 +206,6 @@ def test_a_written_door_is_open_after_a_load(self): assert walk_east(restored) == [] assert restored.dungeon_state.location.position == (1, 0) - @CHUNK def test_a_loaded_session_seeds_a_new_state_from_the_door(self): session = open_door_session() restored = load_game(save_game(session)) @@ -271,7 +259,6 @@ def test_a_new_state_is_all_false(self): class TestEndEncounterWithNothingOpen: """`end_encounter` refuses a session with no open encounter before it changes or draws anything.""" - @CHUNK def test_it_raises_value_error_and_changes_nothing(self): session = open_door_session() assert session.encounter is None @@ -285,7 +272,6 @@ def test_it_raises_value_error_and_changes_nothing(self): assert session.encounter is None assert session.mode is SessionMode.EXPLORING - @CHUNK def test_it_raises_value_error_in_town(self): session = GameSession.new(build_party(), build_open_door_adventure(), seed=5) assert session.encounter is None