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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- [`Character`][osrlib.core.character.Character]`.literacy` is gone (#100). A character's band comes from [`AbilityTables.literacy`][osrlib.core.abilities.AbilityTables.literacy] given the intelligence score, which is the one statement of the rule. The property never reached a save, so no document changes.
- [`DungeonState`][osrlib.crawl.dungeon.DungeonState]`.discovered_features` is gone (#100). What the party has found lives where the engine writes it: a secret door on [`DoorState.discovered`][osrlib.crawl.dungeon.DoorState], and a found feature on the dungeon state's `found_traps` and `found_tricks`. A schema 3 save that carries the key loads with it dropped.
- A ward effect definition's `unbreakable` param is gone (#100). Melee against a monster a protection ward barred breaks the ward, and an effect definition has no way to hold it open.
- `osrlib.crawl.exploration.HANDLERS`, `osrlib.crawl.encounter.HANDLERS`, and `osrlib.crawl.battle.HANDLERS` are gone (#107). Each module's command-to-handler table is private now, and the merged table a session dispatches from lives on the session instance, built on its own first dispatch, rather than in a module-level cache every session shared, so replacing an entry after a session's first command reaches only that session. [`GameSession.execute`][osrlib.crawl.session.GameSession.execute] is the one documented way to run a command: a handler pulled straight out of one of these tables skips the mode gate, the command log, the listeners, and the validation pre-phase that `execute` runs around it.

### Fixed

Expand Down
17 changes: 8 additions & 9 deletions src/osrlib/crawl/battle.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
`battle` mode, `session.battle` holds a [`BattleState`][osrlib.crawl.battle.BattleState], and each
round is one [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] command with one
[`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] per living, able party member,
dispatched through [`HANDLERS`][osrlib.crawl.battle.HANDLERS]. No command ends the battle. It ends
dispatched through the session's private handler table. No command ends the battle. It ends
from inside, when the party is wiped, when every monster group is dead or routed, or when the whole
party retreats. A victory hands control straight to
[`end_encounter`][osrlib.crawl.encounter.end_encounter].
Expand Down Expand Up @@ -200,7 +200,6 @@
"BattleState",
"FIGHTER_FRONTAGE_FEET",
"FLEE_EXIT_FEET",
"HANDLERS",
"MELEE_RANGE_FEET",
"MonsterAction",
"NPC_PARTY_MORALE",
Expand Down Expand Up @@ -2648,17 +2647,17 @@ def _watch_disruption(events, pending_casters, disrupted, acted) -> None:
disrupted.add(target)


HANDLERS = {
_HANDLERS = {
ResolveBattleRound: _handle_resolve_battle_round,
}
"""The battle commands this module handles, keyed by command class.

[`GameSession.execute`][osrlib.crawl.session.GameSession.execute] merges this map with the
exploration, encounter, and referee maps and dispatches on the command's class, so a front end never
reads it. Read it to see which commands the battle machine owns, and go through
[`GameSession.execute`][osrlib.crawl.session.GameSession.execute] rather than calling a handler
directly: a handler skips the mode gate, the command log, the listeners, and the pure validation phase
that makes a rejected command cost no draw, no time, and no change to the game.
[`GameSession`][osrlib.crawl.session.GameSession] folds this map into its own private handler table
the first time it dispatches a command, alongside the exploration, encounter, and referee maps. There
is no registration point here: the only documented way to run a command is
[`GameSession.execute`][osrlib.crawl.session.GameSession.execute], which picks the handler, runs the
mode gate, and does the command log, listener, and validation-phase bookkeeping a handler alone would
skip.

The value takes `(session, command)` and returns a `(rejections, events)` pair. Battle has one command
because a round is resolved as a whole: every party member declares, and the machine runs both sides
Expand Down
25 changes: 12 additions & 13 deletions src/osrlib/crawl/encounter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
the session is in `encounter` mode and each of [`Parley`][osrlib.crawl.commands.Parley],
[`Evade`][osrlib.crawl.commands.Evade], [`Wait`][osrlib.crawl.commands.Wait],
[`TurnUndead`][osrlib.crawl.commands.TurnUndead], and
[`EngageBattle`][osrlib.crawl.commands.EngageBattle] runs one encounter round through
[`HANDLERS`][osrlib.crawl.encounter.HANDLERS], with the monsters acting per their stance after it.
[`EngageBattle`][osrlib.crawl.commands.EngageBattle] runs one encounter round through the session's
private handler table, with the monsters acting per their stance after it.
[`end_encounter`][osrlib.crawl.encounter.end_encounter] closes the encounter and puts the session
back in `exploring`.

Expand Down Expand Up @@ -124,7 +124,6 @@
__all__ = [
"EncounterGroup",
"EncounterState",
"HANDLERS",
"PURSUIT_ROUND_CAP",
"PursuitState",
"end_encounter",
Expand Down Expand Up @@ -217,8 +216,8 @@ class EncounterState(BaseModel):

You get this from `session.encounter`, which is None whenever no encounter is open. It serializes
with the session, so a saved game restores mid-encounter. Treat it as something to read and render
from, not to edit: the handlers in [`HANDLERS`][osrlib.crawl.encounter.HANDLERS] and the battle
machinery own every field on it.
from, not to edit: this module's own command handlers and the battle machinery own every field on
it.
"""

model_config = ConfigDict(validate_assignment=True)
Expand Down Expand Up @@ -1124,7 +1123,7 @@ def end_encounter(session, outcome: str) -> list[Event]:
return events


HANDLERS = {
_HANDLERS = {
Parley: _handle_parley,
Evade: _handle_evade,
EngageBattle: _handle_engage_battle,
Expand All @@ -1133,15 +1132,15 @@ def end_encounter(session, outcome: str) -> list[Event]:
}
"""The encounter commands this module handles, keyed by command class.

[`GameSession.execute`][osrlib.crawl.session.GameSession.execute] merges this map with the
exploration, battle, and referee maps and dispatches on the command's class, so a front end never
reads it. Read it to see which commands the encounter procedure owns, and call
[`GameSession.execute`][osrlib.crawl.session.GameSession.execute] rather than a handler directly: a
handler skips the mode gate, the command log, the listeners, and the rejection pre-phase that make a
rejected command cost nothing.
[`GameSession`][osrlib.crawl.session.GameSession] folds this map into its own private handler table
the first time it dispatches a command, alongside the exploration, battle, and referee maps. There is
no registration point here: the only documented way to run a command is
[`GameSession.execute`][osrlib.crawl.session.GameSession.execute], which picks the handler, runs the
mode gate, and does the command log, listener, and rejection pre-phase bookkeeping a handler alone
would skip.

Each value takes `(session, command)` and returns a `(rejections, events)` pair.
[`DropItems`][osrlib.crawl.commands.DropItems] is not listed here even though it works during an
[`DropItems`][osrlib.crawl.commands.DropItems] is not among them even though it works during an
encounter: [`osrlib.crawl.exploration`][osrlib.crawl.exploration] owns that command and forwards it
here when an encounter is open.
"""
35 changes: 9 additions & 26 deletions src/osrlib/crawl/exploration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
[`osrlib.crawl.commands`][osrlib.crawl.commands], like [`MoveParty`][osrlib.crawl.commands.MoveParty],
[`Search`][osrlib.crawl.commands.Search] or [`Rest`][osrlib.crawl.commands.Rest], and pass it to
[`GameSession.execute`][osrlib.crawl.session.GameSession.execute]. The session looks the command's
class up in [`HANDLERS`][osrlib.crawl.exploration.HANDLERS] and runs the handler it finds, which is
one function taking `(session, command)` and returning `(rejections, events)`. You get back a
class up in its own private handler table and runs the handler it finds, which is one function
taking `(session, command)` and returning `(rejections, events)`. You get back a
[`CommandResult`][osrlib.crawl.commands.CommandResult] that contains either
[`Rejection`][osrlib.core.validation.Rejection] models saying why the command was refused, or the
event models of [`osrlib.crawl.events`][osrlib.crawl.events] saying what happened. Validation is a
Expand Down Expand Up @@ -214,7 +214,6 @@ class up in [`HANDLERS`][osrlib.crawl.exploration.HANDLERS] and runs the handler
"EXHAUSTED_DEFINITION",
"EXHAUSTED_KIND",
"FATIGUE_KIND",
"HANDLERS",
"HEALING_SERVICES",
"check_fatigue",
"consume_provisions",
Expand Down Expand Up @@ -4321,7 +4320,7 @@ def _temple_cleric(spell) -> Character:
)


HANDLERS = {
_HANDLERS = {
MoveParty: _handle_move_party,
TurnParty: _handle_turn_party,
ReorderParty: _handle_reorder_party,
Expand Down Expand Up @@ -4355,26 +4354,10 @@ def _temple_cleric(spell) -> Character:
}
"""The command classes this module handles, each mapped to the function that handles it.

Read it to find out which commands the exploration and town handlers take.
[`GameSession.execute`][osrlib.crawl.session.GameSession.execute] merges this mapping with the
encounter, battle, and referee mappings the first time it dispatches, then looks your command's
class up in the result. A command class that is not a key here is handled by the encounter or battle
procedure, or is one of the referee commands. Which session modes a command is legal in is a
separate question, answered by the command's `allowed_modes`, and `execute` returns
`session.command.wrong_mode` when the session is in a mode the command doesn't allow.

Send commands through `execute` rather than calling a handler out of this mapping. A handler takes
`(session, command)` and returns `(rejections, events)`, but it is only the middle of the command
path, and calling it directly skips the mode check, the command log that makes a game replayable,
the listeners, and the party-death bookkeeping that `execute` runs around it. Replacing an entry
here does not redirect dispatch either, because the session builds its merged mapping once and keeps
it.

```python
from osrlib.crawl.commands import MoveParty, Parley
from osrlib.crawl.exploration import HANDLERS

assert MoveParty in HANDLERS
assert Parley not in HANDLERS # the encounter procedure handles that one
```
[`GameSession`][osrlib.crawl.session.GameSession] folds this mapping into its own private handler
table the first time it dispatches a command, alongside the encounter, battle, and referee mappings.
There is no registration point here: the table is private to this module, and the only documented
way to run a command is [`GameSession.execute`][osrlib.crawl.session.GameSession.execute], which
picks the handler, runs the mode check, and does the logging and bookkeeping a handler alone would
skip.
"""
42 changes: 24 additions & 18 deletions src/osrlib/crawl/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,7 @@ def __init__(
"""The battle under way, or `None`. It contains the round number and the per-battle
trackers."""
self._provisions_day = 0
self._handlers: dict[type[Command], Any] | None = None
# Runtime extension points a game re-registers like listeners. Policies are
# code, so they are never serialized.
self.action_policies: dict[str, object] = {}
Expand Down Expand Up @@ -839,6 +840,28 @@ def effective_equipment(self) -> EquipmentCatalog:

# ------------------------------------------------------------------ dispatch

def _handler_table(self) -> Mapping[type[Command], Any]:
"""Return this session's command-to-handler table, built once and cached on the instance.

Assembled lazily, on this session's first dispatch, by merging the private handler tables of
the exploration, encounter, and battle modules with this module's own referee handlers. The
import happens inside the call rather than at the top of this module because those three
modules import [`GameSession`][osrlib.crawl.session.GameSession] from here at their own top
level: importing them before this module has finished defining the class would fail. Each
session builds and keeps its own table, so replacing a handler on one session never reaches
another.
"""
if self._handlers is None:
from osrlib.crawl import battle, encounter, exploration

self._handlers = {
**_REFEREE_HANDLERS,
**exploration._HANDLERS,
**encounter._HANDLERS,
**battle._HANDLERS,
}
return self._handlers

def execute(self, command: Command) -> CommandResult:
"""Execute one command and return everything it caused.

Expand Down Expand Up @@ -920,7 +943,7 @@ def execute(self, command: Command) -> CommandResult:
),
),
)
handler = _handlers().get(type(command))
handler = self._handler_table().get(type(command))
if handler is None:
raise ValueError(f"no handler for command type {command.command_type!r}")
rejections, events = handler(self, command)
Expand Down Expand Up @@ -1959,20 +1982,3 @@ def _handle_roll_dice(session: GameSession, command: RollDice) -> tuple[list[Rej
CompleteObjective: _handle_complete_objective,
CompleteQuest: _handle_complete_quest,
}

_HANDLERS_CACHE: dict | None = None


def _handlers() -> Mapping[type[Command], Any]:
"""The command-type to handler map, assembled lazily to avoid import cycles."""
global _HANDLERS_CACHE
if _HANDLERS_CACHE is None:
from osrlib.crawl import battle, encounter, exploration

_HANDLERS_CACHE = {
**_REFEREE_HANDLERS,
**exploration.HANDLERS,
**encounter.HANDLERS,
**battle.HANDLERS,
}
return _HANDLERS_CACHE
4 changes: 0 additions & 4 deletions tests/test_handler_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
table entry after the first command cannot silently do nothing, and two sessions never share a cache.
"""

import pytest

from crawl_fixtures import build_adventure, build_party
from osrlib.core.clock import TimeUnit
from osrlib.crawl import battle, encounter, exploration
Expand All @@ -16,13 +14,11 @@


class TestDispatchHasNoModuleState:
@pytest.mark.xfail(reason="chunk: handler-privacy")
def test_the_procedure_tables_are_private(self):
for module in (exploration, encounter, battle):
assert "HANDLERS" not in module.__all__, module.__name__
assert not hasattr(module, "HANDLERS"), module.__name__

@pytest.mark.xfail(reason="chunk: handler-privacy")
def test_the_session_module_holds_no_merged_table(self):
assert not hasattr(session_module, "_HANDLERS_CACHE")
assert "HANDLERS" not in session_module.__all__
Expand Down
Loading