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

## [Unreleased]

### Fixed

- Encounter distance is now bounded by the space the encounter happens in. The 2d6 × 10' dungeon roll ignored the party's surroundings entirely, so an encounter keyed to a 20' room could open at 120' — six times the room's width — and a rolled distance routinely exceeded the whole dungeon level. The SRD rolls only "if there is uncertainty", since "the situation in which the encounter occurs often determines how far away the monster is"; the walls around the party's cell are that situation, so a rolled distance now caps at the longest unobstructed straight sight line from that cell across the four grid directions, floored at one cell (10'). A corridor still affords its full sight line and the printed 20'–120' band; a room affords no more than the room; shut and undiscovered-secret doors stop the line where they stop the party. Darkness never shortens it — light governs surprise, not distance — and a caller-supplied `distance_feet` (every referee spawn: `SpawnMonsters`, `SpawnNpcParty`) is never capped. The cap reads the rolled result and never the RNG stream, so no draw sequence changes; rolled distances in cramped spaces do change, which moved the Phase 4 and Phase 5 scenario goldens.

## [1.3.0] - 2026-07-24

### Added
Expand Down
18 changes: 16 additions & 2 deletions docs/adaptations.md
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,20 @@ of the party") but the party may be. Locked by
### The encounter procedure

Every encounter opens with surprise, a 2d6 × 10' distance roll, and reaction —
keyed encounters included (rooms are abstracted onto the same track). Keyed
keyed encounters included (rooms are abstracted onto the same track). The distance
roll is bounded by the space it happens in: RAW rolls 2d6 × 10' only "if there is
uncertainty", because "the situation in which the encounter occurs often determines
how far away the monster is", and the walls around the party's cell are that
situation. Pinned: a rolled distance caps at the longest unobstructed straight sight
line from the party's cell, taken across the four grid directions through the edges
sight crosses (open floor, open non-secret doors) — on a rectangular room exactly
that room's extent from the cell the party stands on, down a straight corridor the
whole passage, so the printed 20'–120' band stays fully available wherever the space
affords it. The floor is one cell, 10', so a sealed cell never yields 0'; darkness
never shortens the line, since light governs surprise and not distance (a monster
the party cannot see is still exactly as far away as it is); and a caller-supplied
distance — every referee spawn — is never capped. The cap reads the rolled result
and never the stream, so bounding consumes no randomness. Keyed
awareness comes from the area's flag, a failed door forcing alerts the room, the
lit-party rule skips the monsters' surprise roll entirely, and a successful listen
marks party awareness. Parley re-rolls are uncapped, each a fresh roll with the
Expand All @@ -782,7 +795,8 @@ one turn)`, which may itself land mid-turn when the encounter started mid-turn
its end — the fiction moves on (a dead troll's pending revival is post-battle
narration); an evaded keyed encounter re-triggers fresh on the next entry. Locked
by `test_encounter.py`, the free rounds by
`test_encounter.py::TestSurpriseFreeRounds`.
`test_encounter.py::TestSurpriseFreeRounds`, the distance bound by
`test_encounter.py::TestEncounterDistance`.

### Evasion and pursuit

Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ The crawl framework implements the dungeon adventuring procedures:
- Searching for secret doors and traps and listening at doors (X-in-6, race/class-modified); forcing stuck doors on the STR open-doors chance; thief skills plug into the same procedures (open locks, hear noise), preserving the B/X split between room traps (anyone searches) and treasure traps (thief-only find/remove)
- Trap triggering: 2-in-6 chance when the triggering action occurs, damage automatic (no attack roll)
- NPC adventuring parties as encounter-table entries (Basic/Expert Adventurers): generated by the SRD procedure — composition dice, class/level and alignment tables, treasure types U+V shared among the group, and (Expert parties only) a 5%-per-NPC-level chance per suitable magic-item sub-table with unusable rolls discarded — from the same character model as PCs, then handled as an encounter side like any monster group. High-Level party types appear only on wilderness tables and defer with wilderness; employing NPCs (retainers) remains out of scope
- Wandering monster checks with noise, light, and resting modifiers, rolled on the encounter tables keyed by dungeon level; encounter procedure (surprise — denied to a party carrying light in darkness or after a failed door forcing; 2d6×10' encounter distance = 2d6 cells; reaction; parley)
- Wandering monster checks with noise, light, and resting modifiers, rolled on the encounter tables keyed by dungeon level; encounter procedure (surprise — denied to a party carrying light in darkness or after a failed door forcing; 2d6×10' encounter distance = 2d6 cells, capped at the party cell's longest straight sight line since RAW rolls only "if there is uncertainty"; reaction; parley)
- Combat space as an abstract per-group range track (the Bard's Tale convention) built on the kernel's range bands; party combat ranks derive from marching order, and corridor width caps combatants fighting abreast (2–3 in a 10' passage) — documented adaptations behind `Ruleset` knobs
- Area-of-effect resolution against that combat space: geometric shapes map deterministically to groups and party ranks (how many of a group a 20'-radius *fire ball* catches, which ranks a breath weapon reaches), including friendly fire when an area overlaps a melee — a documented adaptation with `Ruleset` knobs
- Monster actions resolve through a pluggable action policy: the default follows scripted patterns where the SRD defines them (a dragon opens with breath, then breath or melee with equal chance, three breaths per day) and otherwise picks attacks by range; games and LLM referees can substitute a policy per encounter side
Expand Down
20 changes: 19 additions & 1 deletion src/osrlib/crawl/encounter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
parley, or withdraw freely; 12+ friendly. Only the attacks/hostile stances pursue
an evading party, as a documented adaptation (see the adaptations register) — RAW
leaves pursuit itself to the referee too, keyed here to low reactions.

The distance roll is bounded by the space it happens in: RAW rolls 2d6 × 10' only
"if there is uncertainty", and the walls around the party's cell resolve that
uncertainty, so the rolled distance caps at the longest straight sight line the
cell affords — the room's own span in a room, the whole passage down a corridor,
never shortened by darkness. A caller that supplies `distance_feet` is never
capped: the referee places what the referee spawns.
"""

from pydantic import BaseModel, ConfigDict, Field
Expand Down Expand Up @@ -137,7 +144,7 @@ def start_encounter(
kind: `"wandering"`, `"keyed"`, or `"spawned"`.
area_ref: The keyed area's state reference, when keyed.
distance_feet: A fixed distance; `None` rolls 2d6 × 10 on the encounter
stream.
stream and caps the result at the party cell's sight line.
monsters_roll_surprise: False when the monsters can never be surprised.
monsters_aware: True when the monsters expect intruders.
party_aware: True when the party heard the room.
Expand All @@ -146,6 +153,7 @@ def start_encounter(
Returns:
The encounter-opening events.
"""
from osrlib.crawl import exploration
from osrlib.crawl.session import ENCOUNTER_STREAM

stream = session.streams.get(ENCOUNTER_STREAM)
Expand Down Expand Up @@ -178,6 +186,16 @@ def start_encounter(
if distance_feet is None:
rolled_tens: int = stream.randbelow(6) + 1 + stream.randbelow(6) + 1
distance_feet = rolled_tens * 10
# RAW rolls 2d6 × 10' only "if there is uncertainty"; otherwise "the
# situation in which the encounter occurs" fixes the distance — and the
# walls the party is standing between are that situation. The roll is
# therefore capped at the cell's longest straight sight line: a corridor
# keeps the full hundred and twenty feet, while a twenty-foot room cannot
# open at eighty. The cap reads the result, never the stream, so the draw
# sequence is the same bounded or not.
ceiling = exploration._sight_line_feet(session)
if ceiling is not None:
distance_feet = min(distance_feet, ceiling)

group_models = [
EncounterGroup(
Expand Down
77 changes: 77 additions & 0 deletions src/osrlib/crawl/exploration.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,83 @@ def _known_door(session, direction: Direction):
return edge, state


# ---------------------------------------------------------------------- sight lines

# The dungeon grid is authored at the classic ten-foot square, so a torch's
# thirty-foot radius reaches three cells of open floor and a straight run of
# three cells is thirty feet of sight.
_CELL_FEET = 10


def _sight_passes(session, level, location, cell: Position, direction: Direction) -> bool:
"""Whether sight (and torchlight) crosses one cell edge.

Open floor and an open, non-secret door let light through; walls, blocked
edges, and shut or undiscovered-secret doors stop it — the same passability
the mover and the edge projection already agree on.

Args:
session (osrlib.crawl.session.GameSession): The running session.
level (osrlib.crawl.dungeon.LevelSpec): The level holding the cell.
location: The party's dungeon location, for the door overlay's refs.
cell: The cell sight leaves.
direction: Which of the cell's four edges sight crosses.

Returns:
True when sight crosses the edge.
"""
edge = level.edge(cell, direction)
if edge.kind is EdgeKind.OPEN:
return True
if edge.kind is EdgeKind.DOOR:
ref = edge_ref(location.dungeon_id, location.level_number, cell, direction)
state = session.dungeon_state.doors.get(ref)
if edge.door.kind == "secret" and (state is None or not state.discovered):
return False
return bool(state.open) if state is not None else edge.door.starts_open
return False


def _sight_line_feet(session) -> int | None:
"""The longest unobstructed straight sight line from the party's cell, in feet.

Sight runs cell by cell along each of the four grid directions, crossing only
the edges `_sight_passes` allows, and the longest of the four runs is how far
the space lets the party see from where it stands: a chamber's own span inside
a chamber, the whole passage down a straight corridor. On a rectangular room
this equals the room's extent from the party's cell, since the farthest corner
is exactly the longer of the two runs away in grid (Chebyshev) distance.

This is geometry, never illumination: darkness never shortens the line,
because a monster the party cannot see is still exactly as far away as it is.
Light governs surprise, not distance.

Args:
session (osrlib.crawl.session.GameSession): The running session.

Returns:
The sight line in feet — never shorter than one cell — or `None` when the
party is not standing on a dungeon cell.
"""
location = _location(session)
if location.kind != "dungeon":
return None
try:
level = _level(session)
except ValueError:
return None
origin = _position(session)
longest = 0
for direction in Direction:
cell = origin
run = 0
while _sight_passes(session, level, location, cell, direction):
cell = step(cell, direction)
run += 1
longest = max(longest, run)
return max(1, longest) * _CELL_FEET


# ---------------------------------------------------------------------- light gates


Expand Down
24 changes: 1 addition & 23 deletions src/osrlib/crawl/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from osrlib.core.effects import Condition, has_condition
from osrlib.core.items import MagicItemCategory, MagicItemInstance, magic_item_template
from osrlib.crawl.dungeon import Direction, EdgeKind, PartyLocation, Position, cell_ref, edge_ref, step
from osrlib.crawl.exploration import EXHAUSTED_KIND, FATIGUE_KIND
from osrlib.crawl.exploration import _CELL_FEET, EXHAUSTED_KIND, FATIGUE_KIND, _sight_passes

__all__ = [
"EdgeView",
Expand Down Expand Up @@ -331,9 +331,6 @@ def _visible_cell_refs(session) -> set[str]:
return refs


# The dungeon grid is authored at the classic ten-foot square, so a torch's
# thirty-foot radius reaches three cells of open floor.
_CELL_FEET = 10
_DEFAULT_LIGHT_FEET = 30


Expand All @@ -348,25 +345,6 @@ def _light_radius_feet(params) -> int:
return int(raw) if raw is not None else _DEFAULT_LIGHT_FEET


def _sight_passes(session, level, location, cell: Position, direction: Direction) -> bool:
"""Whether torchlight (and sight) crosses one cell edge.

Open floor and an open, non-secret door let light through; walls, blocked
edges, and shut or undiscovered-secret doors stop it — the same passability
the mover and the edge projection already agree on.
"""
edge = level.edge(cell, direction)
if edge.kind is EdgeKind.OPEN:
return True
if edge.kind is EdgeKind.DOOR:
ref = edge_ref(location.dungeon_id, location.level_number, cell, direction)
state = session.dungeon_state.doors.get(ref)
if edge.door.kind == "secret" and (state is None or not state.discovered):
return False
return bool(state.open) if state is not None else edge.door.starts_open
return False


def _light_reveal(session) -> tuple[str | None, set[Position]]:
"""Cells the party sees *right now* by its own light, keyed to their level.

Expand Down
35 changes: 35 additions & 0 deletions tests/crawl_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
__all__ = [
"build_adventure",
"build_party",
"build_sightline_adventure",
]


Expand Down Expand Up @@ -166,6 +167,40 @@ def build_adventure(wandering_chance: int = 1) -> Adventure:
)


def build_sightline_adventure() -> Adventure:
"""Build the one-level sight-line dungeon: four cells with four different views.

Level 1 (15 × 5), four unconnected pieces the referee places the party into:

```text
x0 x1 x2 ... x14
y0 [chamber———————] (0,0)–(2,0): a sealed 30' chamber
y2 clo|door|corr———————————— (0,2) closet, door east, then (1,2)–(14,2)
y4 seal (0,4): a sealed cell with no edges at all
```

The sight lines, in feet: the corridor cell (1,2) sees 130' east (past the
printed 2d6 × 10' maximum, so nothing there is ever clamped); the chamber's
west end (0,0) sees 20'; the sealed cell (0,4) sees nothing, and the closet
(0,2) sees nothing either until its door opens onto the corridor.
"""
edges: dict[str, Edge] = {}
_open(edges, (0, 0), Direction.EAST)
_open(edges, (1, 0), Direction.EAST)
_door(edges, (0, 2), Direction.EAST) # the closet's door onto the corridor
for x in range(1, 14):
_open(edges, (x, 2), Direction.EAST)
level = LevelSpec(
number=1, width=15, height=5, edges=edges, entrance=(1, 2), wandering=WanderingSpec(chance_in_six=0)
)
return Adventure(
name="The Sight Lines",
description="A level built to measure what the party can see.",
town=TownSpec(name="Threshold", travel_turns={"sightlines": 1}),
dungeons=(DungeonSpec(id="sightlines", name="Sight Lines", levels=(level,)),),
)


def _member(name: str, class_id: str) -> Character:
definition = load_classes().get(class_id)
scores = {ability: 11 for ability in AbilityScore}
Expand Down
2 changes: 1 addition & 1 deletion tests/generate_phase5_goldens.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@

HOARD_SEED = 20_260_705
NPC_SEED = 20_260_706
MILESTONE_SEED = 203 # pinned with test_example_crawler.py: one seed, one script, two drivers
MILESTONE_SEED = 21 # pinned with test_example_crawler.py: one seed, one script, two drivers

TREASURE_TYPES = tuple(string.ascii_uppercase[:22]) # A through V

Expand Down
Loading
Loading