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: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 31 additions & 17 deletions src/osrlib/crawl/dungeon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -1973,30 +1978,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
Expand All @@ -2016,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)
Expand Down
9 changes: 6 additions & 3 deletions src/osrlib/crawl/encounter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1253,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:
Expand Down
23 changes: 23 additions & 0 deletions src/osrlib/crawl/exploration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 20 additions & 3 deletions src/osrlib/crawl/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading