From c8510f5ce09f9b3fbcb809246907bf8cb30d635a Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 21:39:33 -0700 Subject: [PATCH 1/2] Make the exploration, encounter, and battle handler tables private (#107) Each module's HANDLERS map was public and documented as an extension point, but GameSession merged the three into a module-level cache on its first dispatch, so replacing an entry afterward reached no session. The tables are now private (_HANDLERS), and each session builds its own merged table lazily on its first dispatch and keeps it on the instance, so two sessions never share one and GameSession.execute is the only documented way to run a command. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- CHANGELOG.md | 1 + src/osrlib/crawl/battle.py | 17 +++++++------ src/osrlib/crawl/encounter.py | 25 ++++++++++---------- src/osrlib/crawl/exploration.py | 35 +++++++-------------------- src/osrlib/crawl/session.py | 42 +++++++++++++++++++-------------- 5 files changed, 54 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4007ff2..5b7d954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, and was already, 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 diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 1363909..9da1c3d 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -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]. @@ -200,7 +200,6 @@ "BattleState", "FIGHTER_FRONTAGE_FEET", "FLEE_EXIT_FEET", - "HANDLERS", "MELEE_RANGE_FEET", "MonsterAction", "NPC_PARTY_MORALE", @@ -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 diff --git a/src/osrlib/crawl/encounter.py b/src/osrlib/crawl/encounter.py index 745a740..3f27468 100644 --- a/src/osrlib/crawl/encounter.py +++ b/src/osrlib/crawl/encounter.py @@ -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`. @@ -124,7 +124,6 @@ __all__ = [ "EncounterGroup", "EncounterState", - "HANDLERS", "PURSUIT_ROUND_CAP", "PursuitState", "end_encounter", @@ -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) @@ -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, @@ -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. """ diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index 2934c17..f87d2b2 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -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 @@ -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", @@ -4321,7 +4320,7 @@ def _temple_cleric(spell) -> Character: ) -HANDLERS = { +_HANDLERS = { MoveParty: _handle_move_party, TurnParty: _handle_turn_party, ReorderParty: _handle_reorder_party, @@ -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. """ diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index 53db83b..eb9d9d7 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -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] = {} @@ -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. @@ -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) @@ -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 From 7176fd8c1d30f8769014b4d886e33b8f2712af3c Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 21:44:42 -0700 Subject: [PATCH 2/2] Unmark the handler-privacy tests and drop a history clause from the changelog Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- CHANGELOG.md | 2 +- tests/test_handler_dispatch.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b7d954..bd568fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +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, and was already, 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. +- `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 diff --git a/tests/test_handler_dispatch.py b/tests/test_handler_dispatch.py index 1dec3e0..2337a21 100644 --- a/tests/test_handler_dispatch.py +++ b/tests/test_handler_dispatch.py @@ -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 @@ -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__