From de349b79afc6df4addf9dac3e4f9ff9beeda05a5 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 15 Oct 2026 18:50:39 -0700 Subject: [PATCH 1/2] Implement passages, the transition the party walks through A MoveParty across the side of a cell that a passage stands on lands the party on the passage's destination. The move makes the usual can't-move checks, evaluates the passage's gate as UseStairs does, and costs what a step onto the landing cell costs. It emits what UseStairs emits for the same destination, with via="passage", then a PartyMovedEvent for the landing cell. Within one level, doors the party opened swing shut as they do after any step. LevelSpec.passage_at finds a passage by cell and side. transition_at and UseStairs ignore passages, and the player view shows a passage's side as open. validate_adventure refuses a passage whose side or far cell breaks the crossing, one that lands on its own cell or on a far cell, two passages on one cell and side, and a trap whose transition is a passage, with the exact lines the acceptance tests pin. SCHEMA_VERSION stays 4. No draw sequence or golden file changed. --- CHANGELOG.md | 1 + docs/getting-started/building-an-adventure.md | 4 +- docs/guides/gates-triggers-quests.md | 2 +- src/osrlib/crawl/adventure.py | 119 ++++++++++++++++-- src/osrlib/crawl/commands.py | 30 ++++- src/osrlib/crawl/dungeon.py | 27 +++- src/osrlib/crawl/events.py | 16 ++- src/osrlib/crawl/exploration.py | 68 ++++++++-- src/osrlib/crawl/views.py | 14 ++- src/osrlib/messages.py | 6 +- 10 files changed, 248 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e92cfe..ee54a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - 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). - Four event fields say where something came from, so a front end reads the fact off the event instead of rebuilding it from where the party is standing (#67, #68, #70, #71). [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent]`.via` gives, on a level or dungeon entry, the transition's `kind` for [`UseStairs`][osrlib.crawl.commands.UseStairs], `"trap"` for a trap that dropped the party through the floor, `"entrance"` for [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon], and `"placed"` for a referee's [`PlaceParty`][osrlib.crawl.commands.PlaceParty], and `.transition_ref` names the cell the transition stands on, as [`cell_ref`][osrlib.crawl.dungeon.cell_ref] writes it, so a stairs crossing maps back to its [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] and the gate on it, which is what a narrator needs to read the gate's `guidance` beside the success beat. [`ItemAcquiredEvent`][osrlib.crawl.events.ItemAcquiredEvent]`.origin` says where goods came from, `"treasure"` for a share of a haul, `"purchase"` for gear bought in town, and `"grant"` for a referee's [`GrantItem`][osrlib.crawl.commands.GrantItem] or [`GrantCoins`][osrlib.crawl.commands.GrantCoins], so a reward counted out per member never reads as a pot split. A room trap found through a door names that door on both surfaces the find has: [`TrapEvent`][osrlib.crawl.events.TrapEvent]`.direction` carries the bearing, and the search token takes a third segment, `room_trap::`, while a trap found inside its own area keeps the bare token and no bearing. The engine always fills the four fields, and each defaults `None` so a log written before schema 4 still parses. The default formatter reads them, so a stairs crossing reads as the climb or the descent it was and a door-edge trap find names the door. - An unidentified enchanted arm's public combat facts reach the player view, matching an identified one. [`MemberView`][osrlib.crawl.views.MemberView]'s masked inventory gives an unidentified magic item `qualities` and `missile_ranges` when its masked display string was built from that base weapon ("a dagger with a faint aura"). An item whose display comes from its category instead, such as a `staff_of_striking` reading "a staff", shows neither field, and neither does a shield or a ring. On the base-weapon path the two fields reveal nothing the display string does not already: they are rulebook facts about the mundane weapon underneath, not about the enchantment, and the bonus, any curse, the template id, and the true name all stay hidden. Without them a front end cannot tell a melee declaration from a missile one for an item it cannot yet name. +- A passage is a transition the party walks through instead of taking with [`UseStairs`][osrlib.crawl.commands.UseStairs]. Use one where a map shows two cells side by side that the grid can't put side by side, like the floors of two levels that meet, or two halves of one map that you lay out apart. A [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] whose `kind` is `"passage"` names the `side` of its cell that it stands on, and that side is a wall in the level's edges. A [`MoveParty`][osrlib.crawl.commands.MoveParty] from the cell across that side lands the party on the passage's destination. The move makes the usual can't-move checks, then evaluates the passage's gate as `UseStairs` does, and it costs what a step onto the landing cell costs. It reports what `UseStairs` reports for the same destination, with `via="passage"`, and then a [`PartyMovedEvent`][osrlib.crawl.events.PartyMovedEvent] for the landing cell. [`LevelSpec.passage_at`][osrlib.crawl.dungeon.LevelSpec.passage_at] finds a passage by cell and side, and [`LevelSpec.transition_at`][osrlib.crawl.dungeon.LevelSpec.transition_at] and `UseStairs` ignore passages. The player view reports a passage's side as `"open"`, and sight, light, and fighting space treat it as the wall it is. [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] refuses a passage whose side isn't a wall. It also refuses a passage whose far cell, the cell one step across its side, is off the grid, in an area, or not walled on its other three sides, and a passage that lands on its own cell or on any passage's far cell. It refuses two passages on the same cell and side, and a trap whose transition is a passage. A new kind value and an optional field are additive within a schema version, so [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] stays 4. No draw sequence or golden file changed. ### Changed diff --git a/docs/getting-started/building-an-adventure.md b/docs/getting-started/building-an-adventure.md index ff60686..7efaf45 100644 --- a/docs/getting-started/building-an-adventure.md +++ b/docs/getting-started/building-an-adventure.md @@ -13,7 +13,7 @@ The shape of the tree: A level is a `width × height` grid. Cells are addressed `(x, y)` with `x` increasing east and `y` increasing south from `(0, 0)` at the northwest corner. -Walls are the default. You declare the exceptions (passages and doors) in the `edges` map. Everything absent from the map is solid wall, including the level boundary. The key `"1,0:west"` is the west side of cell `(1, 0)`; the [`edge_key`][osrlib.crawl.dungeon.edge_key] helper computes the canonical key for any cell and direction: +Walls are the default. You declare the exceptions (open edges and doors) in the `edges` map. Everything absent from the map is solid wall, including the level boundary. The key `"1,0:west"` is the west side of cell `(1, 0)`. The [`edge_key`][osrlib.crawl.dungeon.edge_key] helper computes the canonical key for any cell and direction: ```{.python .no-run} # The level: a 4x1 corridor, entered at the west end, with a door at the far end. @@ -55,7 +55,7 @@ Beyond encounters, an area (or the level itself) can contain: - [`FeatureSpec`][osrlib.crawl.dungeon.FeatureSpec] - a treasure cache, a construction trick, or custom content for your front end. A cache contains hand-placed items, magic items (any id from [the magic item id index][magic-items-index], like a `sword_plus_1` in a chest), coins, and named valuables ([`ValuableSpec`][osrlib.crawl.dungeon.ValuableSpec]). - [`TrapSpec`][osrlib.crawl.dungeon.TrapSpec] - room traps on areas, treasure traps on caches. A room trap springs when the party steps in or, with `trigger="open"`, when the party opens one of the area's doors. - [`AreaTreasureSpec`][osrlib.crawl.dungeon.AreaTreasureSpec] - generated treasure: explicit treasure type letters (see [the treasure type index][treasure-types-index]) or the level's unguarded-treasure band. -- [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] - stairs, trapdoors, and chutes between levels. Transitions live on the level, not the area. +- [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] - stairs, trapdoors, and chutes, which the party takes with [`UseStairs`][osrlib.crawl.commands.UseStairs], and passages, which the party walks through with [`MoveParty`][osrlib.crawl.commands.MoveParty]. A passage joins two cells that a map shows side by side but the grid can't, like the floors of two levels that meet. Transitions live on the level, not the area. - [`WanderingSpec`][osrlib.crawl.dungeon.WanderingSpec] - the level's wandering-monster check: 1-in-6 every two turns by default, with an optional custom table. ## The dungeon, the town, and the root diff --git a/docs/guides/gates-triggers-quests.md b/docs/guides/gates-triggers-quests.md index 6cd8cf1..674ef1f 100644 --- a/docs/guides/gates-triggers-quests.md +++ b/docs/guides/gates-triggers-quests.md @@ -28,7 +28,7 @@ Locks and gates are separate layers: on a door with both, the party must open th `consumes=True` turns a `has_item` condition into a toll: each time the gated command succeeds, one instance leaves the first holder in marching order, reported by [`ItemConsumedEvent`][osrlib.crawl.events.ItemConsumedEvent] just before the door or arrival event. Every success charges again, so a consumed key-door that swings shut takes another key. Coins are not items and can't be tolled. To charge one, mint a token as a bundled item and gate on that. -A [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] contains the authored text for the mechanical object it hangs on. Gates read two of its beats: `refusal`, returned in the rejection, and `success`, which goes on the successful command's event, the [`DoorEvent`][osrlib.crawl.events.DoorEvent] for a door and the [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent] for a transition that crosses into a new level or dungeon. [`format_message`][osrlib.messages.format_message] appends the beat verbatim, so it shows up in a bare transcript. A transition whose destination is its own level crosses no boundary and emits no arrival event, so a success beat there has nowhere to display. A narrator that wants the gate's `guidance` alongside its success text maps the event back to the object the gate hangs on: a door event names the cell and the side, and a transition's arrival names the cell the transition stands on in `transition_ref`, so neither mapping has to guess from where the party is standing now. The block's other fields are `journal`, `guidance` for an LLM narrator, and `speaker`, each read by the surface that consumes it. A gate's `journal` beat has no consumer at all: journaling a door is [a trigger's](#wiring-the-dungeon-with-triggers) job. No part of a gate's narrative block reaches the player view, which contains no gate setup at all. +A [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] contains the authored text for the mechanical object it hangs on. Gates read two of its beats: `refusal`, returned in the rejection, and `success`, which goes on the successful command's event, the [`DoorEvent`][osrlib.crawl.events.DoorEvent] for a door and the [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent] for a transition that crosses into a new level or dungeon. [`format_message`][osrlib.messages.format_message] appends the beat verbatim, so it shows up in a bare transcript. A transition whose destination is its own level crosses no boundary and emits no arrival event, so a success beat there has nowhere to display. A narrator that wants the gate's `guidance` alongside its success text maps the event back to the object the gate hangs on: a door event names the cell and the side, and a transition's arrival names the cell the transition stands on in `transition_ref`, so neither mapping has to guess from where the party is standing now. A passage stands on one side of its cell, so for a passage crossing the narrator also needs the direction of the [`MoveParty`][osrlib.crawl.commands.MoveParty], and [`LevelSpec.passage_at`][osrlib.crawl.dungeon.LevelSpec.passage_at] finds the passage from the cell and that direction. The block's other fields are `journal`, `guidance` for an LLM narrator, and `speaker`, each read by the surface that consumes it. A gate's `journal` beat has no consumer at all: journaling a door is [a trigger's](#wiring-the-dungeon-with-triggers) job. No part of a gate's narrative block reaches the player view, which contains no gate setup at all. ## Wiring the dungeon with triggers diff --git a/src/osrlib/crawl/adventure.py b/src/osrlib/crawl/adventure.py index bdfbadd..7d3ac9d 100644 --- a/src/osrlib/crawl/adventure.py +++ b/src/osrlib/crawl/adventure.py @@ -50,7 +50,18 @@ SetDoorState, SpawnMonsters, ) -from osrlib.crawl.dungeon import AreaSpec, Direction, DungeonSpec, EdgeKind, FeatureSpec, LevelSpec +from osrlib.crawl.dungeon import ( + AreaSpec, + Direction, + DungeonSpec, + EdgeKind, + FeatureSpec, + LevelSpec, + Position, + TransitionSpec, + TrapSpec, + step, +) from osrlib.crawl.gates import ConditionSpec, GateSpec, HasItemCondition from osrlib.crawl.quests import QuestSpec from osrlib.crawl.triggers import ( @@ -403,6 +414,74 @@ def _area_has_boundary_door(area: AreaSpec, level: LevelSpec) -> bool: return any(level.edge(cell, direction).kind is EdgeKind.DOOR for cell in area.cells for direction in Direction) +def _trap_crosses_a_passage(trap: TrapSpec | None) -> bool: + """Return whether a trap's effect moves its victim through a passage. + + A trap's transition is a forced relocation, and a passage is something the party walks through + on its own, so validation refuses the two together. + """ + return trap is not None and trap.effect.transition is not None and trap.effect.transition.kind == "passage" + + +def _passage_far_cells(adventure: Adventure) -> set[tuple[str, int, Position]]: + """Return the far cell of every passage in the adventure, as `(dungeon id, level number, cell)`. + + A passage's far cell is the cell one step across its side. Only passages that stand on the grid + and whose far cell is on the grid count, because the other refusals already cover the rest. + """ + far_cells: set[tuple[str, int, Position]] = set() + for dungeon in adventure.dungeons: + for level in dungeon.levels: + for transition in level.transitions: + if transition.kind != "passage" or transition.side is None: + continue + far = step(transition.position, transition.side) + if level.in_bounds(transition.position) and level.in_bounds(far): + far_cells.add((dungeon.id, level.number, far)) + return far_cells + + +def _validate_passage( + passage: TransitionSpec, + side: Direction, + dungeon_id: str, + level: LevelSpec, + owner: str, + far_cells: set[tuple[str, int, Position]], + errors: list[str], +) -> None: + """Check the rules a passage's crossing depends on. + + The passage's side has to be a wall. Its far cell has to be on the grid, outside every area, and + walled on every other side, so the party can never reach it and sight never passes through it. + The geometry checks run only for a passage that stands on the grid, since one that doesn't has + already been refused for that. The destination can't be the passage's own cell, and it can't be + any passage's far cell, which is walled in on every side. + """ + site = f"{owner}: passage at {passage.position} {side.value}" + if level.in_bounds(passage.position): + if level.edge(passage.position, side).kind is not EdgeKind.WALL: + errors.append(f"{site} stands on an edge that is not a wall") + far = step(passage.position, side) + if not level.in_bounds(far): + errors.append(f"{site} has its far cell {far} off the grid") + else: + area = level.area_at(far) + if area is not None: + errors.append(f"{site} has its far cell {far} in area {area.id!r}") + for direction in Direction: + if direction is not side.opposite and level.edge(far, direction).kind is not EdgeKind.WALL: + errors.append(f"{site} has a {direction.value} edge on its far cell {far} that is not a wall") + destination = (passage.to_dungeon_id, passage.to_level_number, passage.to_position) + if destination == (dungeon_id, level.number, passage.position): + errors.append(f"{site} lands on its own cell") + elif destination in far_cells: + errors.append( + f"{site} lands on {passage.to_dungeon_id} level {passage.to_level_number} {passage.to_position}, " + "the far cell of a passage" + ) + + def _validate_feature( feature: FeatureSpec, level: LevelSpec, @@ -413,6 +492,8 @@ def _validate_feature( ) -> None: if feature.cell is not None and not level.in_bounds(feature.cell): errors.append(f"{owner}: feature {feature.id!r} cell {feature.cell} is out of bounds") + if _trap_crosses_a_passage(feature.trap): + errors.append(f"{owner}: feature {feature.id!r} has a trap whose transition is a passage") for item_id in feature.item_ids: try: equipment.get(item_id) @@ -648,12 +729,17 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment area trap and a feature trap are referenced the same way, so a collision would leave the found, sprung, and removed records unable to tell the two apart), area cells on the grid, an `open`-trigger room trap having a door on its area's boundary (an `enter`-trigger trap needs - none, and a door that starts open still counts), + none, and a door that starts open still counts), no area trap whose transition is a passage, keyed encounter template ids resolving and any fixed alignment being one the template allows, - feature cells on the grid with their cache item ids and magic item ids resolving, every - level-scope feature having a cell, inline wandering-table monster ids resolving, the item ids - named by `has_item` gates on doors and transitions resolving, and transitions standing on the grid - and landing on real cells of real levels. + feature cells on the grid with their cache item ids and magic item ids resolving, no feature trap + whose transition is a passage, every level-scope feature having a cell, inline wandering-table + monster ids resolving, the item ids named by `has_item` gates on doors and transitions resolving, + and transitions standing on the grid and landing on real cells of real levels. + + A passage gets more checks after those. Its side has to be a wall. Its far cell, the cell one + step across that side, has to be on the grid, outside every area, and walled on its other three + sides. The passage can't land on its own cell or on the far cell of any passage. Two passages + can't stand on the same cell and side. Then, for each trigger: its id unique, the area, level, dungeon, item, and monster its pattern names resolving, and for each consequence the item it grants, the monster it spawns, a door @@ -723,6 +809,7 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment for dungeon_id in adventure.town.travel_turns: if not any(dungeon.id == dungeon_id for dungeon in adventure.dungeons): errors.append(f"town travel names unknown dungeon {dungeon_id!r}") + far_cells = _passage_far_cells(adventure) for dungeon in adventure.dungeons: if not any(level.entrance is not None for level in dungeon.levels): errors.append(f"dungeon {dungeon.id!r} has no entrance on any level") @@ -752,6 +839,8 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment errors.append(f"{owner}: area {area.id!r} cell {cell} is out of bounds") if area.trap is not None and area.trap.trigger == "open" and not _area_has_boundary_door(area, level): errors.append(f"{owner}: area {area.id!r} has an open-trigger trap and no door on its boundary") + if _trap_crosses_a_passage(area.trap): + errors.append(f"{owner}: area {area.id!r} has a trap whose transition is a passage") if area.encounter is not None: for keyed in area.encounter.monsters: try: @@ -785,22 +874,30 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment for edge_key_, edge in level.edges.items(): if edge.door is not None: _validate_gate(edge.door.requires, owner, f"door {edge_key_}", effective_items, magic, errors) + passage_sides: set[tuple[Position, Direction]] = set() + doubled_sides: set[tuple[Position, Direction]] = set() for transition in level.transitions: _validate_gate( transition.requires, owner, f"transition at {transition.position}", effective_items, magic, errors ) if not level.in_bounds(transition.position): errors.append(f"{owner}: transition at {transition.position} is out of bounds") - try: - target = adventure.dungeon(transition.to_dungeon_id).level(transition.to_level_number) - except ValueError: + target = _resolve_level(adventure, transition.to_dungeon_id, transition.to_level_number) + if target is None: errors.append( f"{owner}: transition targets unknown " f"{transition.to_dungeon_id!r} level {transition.to_level_number}" ) - continue - if not target.in_bounds(transition.to_position): + elif not target.in_bounds(transition.to_position): errors.append(f"{owner}: transition target cell {transition.to_position} is out of bounds") + if transition.kind != "passage" or transition.side is None: + continue + _validate_passage(transition, transition.side, dungeon.id, level, owner, far_cells, errors) + cell_side = (transition.position, transition.side) + if cell_side in passage_sides and cell_side not in doubled_sides: + doubled_sides.add(cell_side) + errors.append(f"{owner}: two passages stand at {transition.position} {transition.side.value}") + passage_sides.add(cell_side) seen_triggers: set[str] = set() for trigger in adventure.triggers: if trigger.id in seen_triggers: diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index c05adae..212df39 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -318,6 +318,12 @@ class MoveParty(Command): [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] is what places the party at the entrance and switches the session to `exploring`. + A move across the side of the party's cell that a passage stands on crosses the + passage instead of stopping at the wall there. The party lands on the passage's + destination, facing its `to_facing`, and the move costs what a step onto that + cell costs. A passage is a [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] + whose `kind` is `"passage"`. + Modes: `exploring` @@ -326,7 +332,11 @@ class MoveParty(Command): - `exploration.move.cannot_move` - the party cannot move: it is overloaded, or a living member is unable to walk. - `exploration.move.blocked` - a wall, a closed or secret door, or the map - edge blocks that direction. + edge blocks that direction, and no passage stands on that side. + - `exploration.transition.gate_refused` - the move would cross a passage + whose authored condition ([`GateSpec`][osrlib.crawl.gates.GateSpec]) the + party doesn't satisfy. The refusal includes the author's own text, and + costs no movement, no time, and no toll. Events: [`PartyMovedEvent`][osrlib.crawl.events.PartyMovedEvent] with the new position @@ -355,6 +365,15 @@ class MoveParty(Command): attacks stance opens battle at once ([`BattleStartedEvent`][osrlib.crawl.events.BattleStartedEvent]). + A move through a passage reports the crossing before the step, as + [`UseStairs`][osrlib.crawl.commands.UseStairs] does. An + [`ItemConsumedEvent`][osrlib.crawl.events.ItemConsumedEvent] comes first when + the passage's gate takes a toll. A `LocationEnteredEvent` follows when the + level or dungeon changes, with `via` set to `"passage"` and `transition_ref` + naming the passage's cell, then the `DoorEvent`s for the doors that swing shut + on the level the party left. The `PartyMovedEvent` for the landing cell comes + next, then the landing cell's own events. + The step accrues toward the turn clock, and crossing a turn boundary brings its bookkeeping: [`EffectExpiredEvent`][osrlib.core.events.EffectExpiredEvent] with a @@ -373,7 +392,7 @@ class MoveParty(Command): To change facing without spending the step, use [`TurnParty`][osrlib.crawl.commands.TurnParty]. A direction with a wall, a closed door, an undiscovered secret door, or the map edge behind it is refused with - `exploration.move.blocked`, and + `exploration.move.blocked`, unless a passage stands on that side of the party's cell, and [`ExploredLevelView.edges`][osrlib.crawl.views.ExploredLevelView.edges] is where a front end reads which sides of the cell the party can leave by.""" @@ -1486,14 +1505,17 @@ class UseStairs(Command): The party must be exploring a dungeon (see [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon]) and standing on a cell with an authored transition. The move costs one unexplored-cell step of - movement. + movement. A passage doesn't count, because the party walks through a passage + with [`MoveParty`][osrlib.crawl.commands.MoveParty]. On a cell with both a + passage and stairs, this command takes the stairs. Modes: `exploring` Rejections: - `session.command.wrong_mode` - the session is not exploring a dungeon. - - `exploration.stairs.none` - no transition on the party's cell. + - `exploration.stairs.none` - no transition other than a passage on the + party's cell. - `exploration.transition.gate_refused` - the transition has an authored condition ([`GateSpec`][osrlib.crawl.gates.GateSpec]) the party does not satisfy. The refusal includes the author's own text, and costs no diff --git a/src/osrlib/crawl/dungeon.py b/src/osrlib/crawl/dungeon.py index be2c4d8..5456284 100644 --- a/src/osrlib/crawl/dungeon.py +++ b/src/osrlib/crawl/dungeon.py @@ -441,6 +441,10 @@ class TransitionSpec(BaseModel): to_position: The cell the party arrives at. to_facing: The direction the party faces on arrival. requires: An authored condition the party must satisfy to take it. + + Raises: + ValueError: If `kind` is `"passage"` and `side` is `None`, or `side` is set on any other + kind. """ model_config = ConfigDict(frozen=True) @@ -480,6 +484,12 @@ class TransitionSpec(BaseModel): at all: that is a forced relocation rather than an attempt, and the trap effect rejects one that does.""" + @model_validator(mode="after") + def _side_exactly_on_passages(self) -> TransitionSpec: + if (self.kind == "passage") != (self.side is not None): + raise ValueError("a transition names a side exactly when its kind is 'passage'") + return self + class TrapEffect(BaseModel): """What a sprung trap does to its victim. @@ -1276,20 +1286,24 @@ def area_at(self, position: Position) -> AreaSpec | None: return None def transition_at(self, position: Position) -> TransitionSpec | None: - """Return the transition standing on a cell, or `None`. + """Return the stairs, trapdoor, or chute standing on a cell, or `None`. [`UseStairs`][osrlib.crawl.commands.UseStairs] asks this and refuses when the answer is - `None`, which is also what makes a chute one-way: the arrival cell holds no transition back. + `None`, which is also what makes a chute one-way: the arrival cell has no transition back. Call it to show a stairs marker on a map, or to offer the command only where it works. + Passages never match, because the party walks through them with + [`MoveParty`][osrlib.crawl.commands.MoveParty]. Call + [`passage_at`][osrlib.crawl.dungeon.LevelSpec.passage_at] to find one. Args: position: The cell. Returns: - The first transition authored on that cell, or `None` when none is. + The first transition authored on that cell that isn't a passage, or `None` when there's + none. """ for transition in self.transitions: - if transition.position == position: + if transition.kind != "passage" and transition.position == position: return transition return None @@ -1308,7 +1322,10 @@ def passage_at(self, position: Position, side: Direction) -> TransitionSpec | No The first passage authored on that cell and side, or `None` when there's none. Stairs, trapdoors, and chutes never match. """ - raise NotImplementedError("chunk: passage") + for transition in self.transitions: + if transition.kind == "passage" and transition.position == position and transition.side == side: + return transition + return None class DungeonSpec(BaseModel): diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 8a41a2f..a7b38c0 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -221,15 +221,21 @@ class LocationEnteredEvent(Event): after the templated one.""" via: str | None = None """How the party got there, on level and dungeon entries: the kind of the transition it took - (`"stairs_down"`, `"stairs_up"`, `"trapdoor"`, or `"chute"`), `"trap"` for a trap that dropped - the party through the floor, `"entrance"` for [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon], - and `"placed"` for [`PlaceParty`][osrlib.crawl.commands.PlaceParty]. It is `None` on area and - town entries, and on an event loaded from a save written before the field existed.""" + (`"stairs_down"`, `"stairs_up"`, `"trapdoor"`, or `"chute"`), `"passage"` for a passage it + walked through with [`MoveParty`][osrlib.crawl.commands.MoveParty], `"trap"` for a trap that + dropped the party through the floor, `"entrance"` for + [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon], and `"placed"` for + [`PlaceParty`][osrlib.crawl.commands.PlaceParty]. It is `None` on area and town entries, and on + an event loaded from a save written before the field existed.""" transition_ref: str | None = None """The cell of the authored transition the party took, as [`cell_ref`][osrlib.crawl.dungeon.cell_ref] gives it, so a consumer can find the [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] and the gate on it. Filled on a level or - dungeon entry made through [`UseStairs`][osrlib.crawl.commands.UseStairs], `None` otherwise.""" + dungeon entry made through [`UseStairs`][osrlib.crawl.commands.UseStairs] or through a passage, + `None` otherwise. For a passage crossing, it names the passage's cell. That cell can also have + stairs, and passages on its other sides, so find the passage with + [`LevelSpec.passage_at`][osrlib.crawl.dungeon.LevelSpec.passage_at] and the `direction` of the + [`MoveParty`][osrlib.crawl.commands.MoveParty] that crossed it.""" class DoorEvent(Event): diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index 1f59c75..1bea174 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -176,6 +176,7 @@ class up in its own private handler table and runs the handler it finds, which i FeatureSpec, PartyLocation, Position, + TransitionSpec, TrapSpec, cell_ref, edge_ref, @@ -1505,6 +1506,7 @@ def _relocate( narrative=None, via: str | None = None, transition_ref: str | None = None, + walked: bool = False, ) -> list[Event]: """Move the party to a cell (transitions, slides): explore, events, hooks. @@ -1513,21 +1515,29 @@ def _relocate( crosses no boundary emits no such event, so there is nowhere to put a beat. `via` and `transition_ref` are the caller's account of the crossing: the kind of - the transition taken and the cell it stands on for `UseStairs`, `"trap"` for a - slide, `"entrance"` for the way in. Every caller has both to hand and passes them, - so a consumer never has to work backwards from where the party ended up. Both go - out on the same crossing event the beat does, so a relocation within one level, - which emits no crossing event, drops them too. + the transition taken and the cell it stands on for `UseStairs` and for a passage, + `"trap"` for a slide, `"entrance"` for the way in. Every caller has both to hand + and passes them, so a consumer never has to work backwards from where the party + ended up. Both go out on the same crossing event the beat does, so a relocation + within one level, which emits no crossing event, drops them too. + + `walked` is for a passage, which the party crosses on foot with `MoveParty`. The + relocation then reports the step as well: a `PartyMovedEvent` for the landing cell + after the crossing's events and before the landing cell's own. Within one level, + the doors the party opened also swing shut behind it, as they do after any step. + A relocation to another level shuts them on the way out whether or not the party + walked. """ state = session.dungeon_state old_location = state.location old_area = None leave_events: list[Event] = [] - if ( + same_level = ( old_location.kind == "dungeon" and old_location.dungeon_id == dungeon_id and old_location.level_number == level_number - ): + ) + if same_level: old_area = _level(session).area_at(old_location.position) elif old_location.kind == "dungeon": leave_events = _swing_shut_on_leave(session) @@ -1558,8 +1568,14 @@ def _relocate( ) ) events.extend(leave_events) + if walked: + events.append( + PartyMovedEvent(code="exploration.party.moved", x=position[0], y=position[1], facing=facing.value) + ) state.mark_explored(dungeon_id, level_number, position) events.extend(_boundary_events(session, old_area, position)) + if walked and same_level: + events.extend(_swing_shut(session, previous=old_location.position)) events.extend(_enter_hooks(session)) events.extend(_area_treasure_check(session)) events.extend(_room_trap_check(session)) @@ -1634,6 +1650,9 @@ def _handle_move_party(session, command: MoveParty) -> tuple[list[Rejection], li for member in session.party.living_members(): if cannot_move(member): return [Rejection(code="exploration.move.cannot_move", params={"character": member.id})], [] + passage = level.passage_at(position, command.direction) + if passage is not None: + return _cross_passage(session, passage) edge = level.edge(position, command.direction) passable = edge.kind is EdgeKind.OPEN if edge.kind is EdgeKind.DOOR: @@ -1669,6 +1688,41 @@ def _handle_move_party(session, command: MoveParty) -> tuple[list[Rejection], li return [], events +def _cross_passage(session, passage: TransitionSpec) -> tuple[list[Rejection], list[Event]]: + """Walk the party through a passage, in place of a step that the wall on its side would block. + + The gate is the last validation step, as it is for `UseStairs`: a refusal costs no + draw, no time, and no item. The crossing reports what `UseStairs` reports for the + same destination, then the step itself, and it costs what a step onto the landing + cell costs. + """ + refusal = _gate_refusal(session, passage.requires) + if refusal is not None: + params: dict[str, int | str | tuple[int | str, ...]] = {} + if refusal: + params["refusal"] = refusal + return [Rejection(code="exploration.transition.gate_refused", params=params)], [] + explored_before = session.dungeon_state.is_explored( + passage.to_dungeon_id, passage.to_level_number, passage.to_position + ) + events = _pay_toll(session, passage.requires) + events.extend( + _relocate( + session, + passage.to_dungeon_id, + passage.to_level_number, + passage.to_position, + passage.to_facing, + narrative=_gate_success(passage.requires), + via="passage", + transition_ref=_cell_ref(session, passage.position), + walked=True, + ) + ) + events.extend(_accrue_movement(session, 10 if explored_before else 30)) + return [], events + + def _swing_shut(session, *, previous, leaving_level: bool = False) -> list[Event]: """Doors the party opened swing shut behind it unless wedged, always. diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index e574762..0fcdcb1 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -189,7 +189,9 @@ class EdgeView(BaseModel): kind: str """What occupies the edge: `"open"`, `"wall"`, or `"door"`, the wire values of [`EdgeKind`][osrlib.crawl.dungeon.EdgeKind]. An undiscovered secret door reports - `"wall"`.""" + `"wall"`. On the side of a cell that a passage stands on, it's `"open"`. The level's edges + have a wall there, but a [`MoveParty`][osrlib.crawl.commands.MoveParty] across that side lands + the party on the passage's destination.""" door_open: bool | None = None """Whether the door stands open. `None` on an edge that is not a door.""" door_wedged: bool | None = None @@ -923,12 +925,22 @@ def _explored_levels(session): known.add(cell) if key == reveal_key: visible.extend(cell for cell in reveal_cells if cell not in known) + # A passage's side is a wall in the level's edges, but the party walks through it, so + # the view reports it as open. + passage_edges = { + _canonical_edge(transition.position, transition.side) + for transition in level.transitions + if transition.kind == "passage" and transition.side is not None + } edges: dict[str, EdgeView] = {} for cell in visible: for direction in Direction: key_text = _canonical_edge(cell, direction) if key_text in edges: continue + if key_text in passage_edges: + edges[key_text] = EdgeView(kind="open") + continue edge = level.edge(cell, direction) if edge.kind is EdgeKind.DOOR: ref = edge_ref(dungeon_id, level_number, cell, direction) diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index 93cb549..3a32e26 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -66,9 +66,9 @@ def _entered(event: Any) -> str: The event's `via` is the only source of the direction. Nothing else in the event says which way the party went, and where the party stands now is no help to a line rendered from the log after - the fact. The kinds that state no direction of their own (a trapdoor, a chute, a trap, the - entrance, a referee's placement) keep the plain arrival line. A stairs crossing into another - dungeon still names the dungeon, since the level number alone would hide the bigger move. + the fact. The kinds that state no direction of their own (a trapdoor, a chute, a passage, a + trap, the entrance, a referee's placement) get the plain arrival line. A stairs crossing into + another dungeon still names the dungeon, since the level number alone would hide the bigger move. """ verb = {"stairs_up": "climbs", "stairs_down": "descends"}.get(event.via or "") if verb is not None and event.level_number is not None: From 59b7c724db095222bdac1ed90e00cefedd6674dc Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 15 Oct 2026 18:55:35 -0700 Subject: [PATCH 2/2] Close the passage chunk: correct four docstrings and remove the marker A review of the passage chunk found that the LocationEnteredEvent class docstring, the gates module docstring, the GateSpec docstring, and the odometer sentence in docs/adaptations.md left passages out. This corrects them, removes the passage tests' xfail marker, and adds docs/phase-17-plan.md as the record of the phase. --- docs/adaptations.md | 2 +- docs/phase-17-plan.md | 29 +++++++++++++++++++++++++++++ src/osrlib/crawl/events.py | 3 ++- src/osrlib/crawl/gates.py | 5 +++-- tests/test_passages.py | 2 -- 5 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 docs/phase-17-plan.md diff --git a/docs/adaptations.md b/docs/adaptations.md index 353ebf3..23d1943 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -296,7 +296,7 @@ The detection-chance precedence is thief row, then class tag, then the universal ### The time-cost census and the thirds odometer -Zero time: `TurnParty`, `ReorderParty`, `OpenDoor`, `CloseDoor`, `WedgeDoor`, `ForceDoor` (a moment of violence, and the noise is the cost), `ListenAtDoor` (the once-per-door cap is the limiter), equip/unequip, `DropItems`, `ExtinguishSource`, `PurchaseEquipment` (town time is abstract), and all referee commands. One round: `CastSpell` and `TurnUndead` outside battle, `LightSource` (per tinder attempt too, RAW). One turn: `Search`, `InspectTreasure`, `RemoveTreasureTrap` (pinned symmetric, where RAW is silent), `PickLock` (pinned, where RAW is silent and the skill is delicate work), `TakeTreasure` (RAW's packing-bags turn), `Rest(turn)`. On the odometer: `MoveParty` per cell and `UseStairs` at one unexplored-cell cost (pinned). Stated durations: `Rest(night)` 48 turns, `Rest(day)` 144, `PrepareSpells` 6, travel per content. Movement accrues in thirds-of-feet, an unexplored cell 30 units and an explored cell 10, implementing the SRD's three-times-through-familiar-areas rule exactly, and turn-costing actions snap the clock to the next boundary, absorbing the partial move. Locked by `test_exploration.py::TestTimeCostCensus` and `TestOdometer`. +Zero time: `TurnParty`, `ReorderParty`, `OpenDoor`, `CloseDoor`, `WedgeDoor`, `ForceDoor` (a moment of violence, and the noise is the cost), `ListenAtDoor` (the once-per-door cap is the limiter), equip/unequip, `DropItems`, `ExtinguishSource`, `PurchaseEquipment` (town time is abstract), and all referee commands. One round: `CastSpell` and `TurnUndead` outside battle, `LightSource` (per tinder attempt too, RAW). One turn: `Search`, `InspectTreasure`, `RemoveTreasureTrap` (pinned symmetric, where RAW is silent), `PickLock` (pinned, where RAW is silent and the skill is delicate work), `TakeTreasure` (RAW's packing-bags turn), `Rest(turn)`. On the odometer: `MoveParty` per cell, a passage at the cost of a step onto its landing cell, and `UseStairs` at one unexplored-cell cost (pinned). Stated durations: `Rest(night)` 48 turns, `Rest(day)` 144, `PrepareSpells` 6, travel per content. Movement accrues in thirds-of-feet, an unexplored cell 30 units and an explored cell 10, implementing the SRD's three-times-through-familiar-areas rule exactly, and turn-costing actions snap the clock to the next boundary, absorbing the partial move. Locked by `test_exploration.py::TestTimeCostCensus` and `TestOdometer`. ### Rest, fatigue, sleep, and provisions diff --git a/docs/phase-17-plan.md b/docs/phase-17-plan.md new file mode 100644 index 0000000..cfa2a65 --- /dev/null +++ b/docs/phase-17-plan.md @@ -0,0 +1,29 @@ +# Phase 17 plan: passages + +Phase 17 of [the osrlib spec](spec.md) adds the passage, a transition the party crosses by walking across one side of its cell with `MoveParty`. osr-forge's map tracing writes passages where two levels' floors meet on a printed map, and re-pod writes each one as a FRUA transfer. The cross-project design is the "Passages" section of re-pod's `docs/plans/map-tracing.md`, and passages ship in osrlib 2.0. + +## Chunks + +The phase was one chunk, `passage`, built under the suite's working method. + +- **Contract**, pull requests 153 and 154: the `"passage"` kind and the `side` field on `TransitionSpec`, the `LevelSpec.passage_at` stub, the spec's passage bullet and roadmap entry, and `tests/test_passages.py`. Pull request 154 added a test that a passage may land on another level's cell with its own coordinates, and on a cell whose coordinates match a far cell on a different level. +- **Implementation**: the model's side rule, `passage_at`, `transition_at` and `UseStairs` ignoring passages, the passage branch of `MoveParty`, the player view's open side, and the `validate_adventure` refusals. + +## What the contract pins + +- **Event order.** A crossing to another level emits the level or dungeon `LocationEnteredEvent` first, with `via="passage"` and the passage's cell as `transition_ref`. The swung-shut events of the doors left behind come next, then a `PartyMovedEvent` for the landing cell, then the landing cell's own events. A crossing within one level emits the `PartyMovedEvent` first, and doors the party opened swing shut as after any step. +- **Cost.** A crossing costs what a step onto the landing cell costs: 10 odometer units if the party has explored it, else 30. +- **Refusal lines.** Each `validate_adventure` refusal has an exact line, because osr-editor's diagnostics match osrlib's messages by pattern. The far-cell check covers the far cell's three sides other than the passage's own, so an open passage side is reported once. +- **Schema.** A new kind value and an optional field are additive within schema 4, so `SCHEMA_VERSION` doesn't move. + +## Verification + +The acceptance tests pass with the marker removed, and the standing gate passes. A fresh review also ran these cases, which no test covers, and each behaved as the plan says: + +- A keyed encounter on the landing cell starts after the `PartyMovedEvent`. +- A gated passage within one level takes its toll and shows no success narration. +- An overloaded party can't cross. +- A refused gate leaves the whole dungeon state unchanged. +- Arriving on a passage cell by stairs, by a trap slide, or at the entrance fires nothing. +- The far cell stays out of the player view under a lit torch. +- A replay through a passage pair matches the session. diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index a7b38c0..d8d2f53 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -179,7 +179,8 @@ class LocationEnteredEvent(Event): Emitted whenever the party's location changes at one of those four scales: [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] on arrival at a dungeon, [`UseStairs`][osrlib.crawl.commands.UseStairs] on a level or dungeon change, - [`MoveParty`][osrlib.crawl.commands.MoveParty] on stepping into a keyed area, + [`MoveParty`][osrlib.crawl.commands.MoveParty] on stepping into a keyed area, and on a + level or dungeon change through a passage, [`TravelToTown`][osrlib.crawl.commands.TravelToTown] on arriving back in town, and [`PlaceParty`][osrlib.crawl.commands.PlaceParty] when a referee puts the party somewhere. diff --git a/src/osrlib/crawl/gates.py b/src/osrlib/crawl/gates.py index 4232c38..1cb9dbe 100644 --- a/src/osrlib/crawl/gates.py +++ b/src/osrlib/crawl/gates.py @@ -11,7 +11,8 @@ [`GameSession.execute`][osrlib.crawl.session.GameSession.execute] evaluate it as the last validation step of [`OpenDoor`][osrlib.crawl.commands.OpenDoor], [`ForceDoor`][osrlib.crawl.commands.ForceDoor], and -[`UseStairs`][osrlib.crawl.commands.UseStairs]. A gate that refuses produces the +[`UseStairs`][osrlib.crawl.commands.UseStairs], and of a +[`MoveParty`][osrlib.crawl.commands.MoveParty] that crosses a passage. A gate that refuses produces the rejection `exploration.door.gate_refused` or `exploration.transition.gate_refused`, which includes the authored refusal beat, and no event at all. A gate that opens puts its success beat on the command's own event: a @@ -199,7 +200,7 @@ class GateSpec(BaseModel): Put one in the `requires` field of a [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec] or a [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] when you build the dungeon, - and the engine checks it every time the party tries that door or stair. A refused + and the engine checks it every time the party tries that door, stair, or passage. A refused attempt returns the `refusal` beat inside its rejection and costs the party nothing: no dice, no game time, no item, and no change to the door. A successful attempt puts the `success` beat on the command's own event. diff --git a/tests/test_passages.py b/tests/test_passages.py index e24446a..22e4ff1 100644 --- a/tests/test_passages.py +++ b/tests/test_passages.py @@ -50,8 +50,6 @@ from osrlib.errors import ContentValidationError from osrlib.persistence import load_game, replay_game, save_game, session_state -pytestmark = pytest.mark.xfail(reason="chunk: passage") - SEED = 17 EAST, WEST, NORTH, SOUTH = Direction.EAST, Direction.WEST, Direction.NORTH, Direction.SOUTH