diff --git a/CHANGELOG.md b/CHANGELOG.md index 9878013..6e1ddb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Authored triggers, and the library-shipped listener that plays them. A `TriggerSpec` (`osrlib.crawl.triggers`) binds an observable event pattern — `AreaEnteredPattern`, `LevelEnteredPattern`, `DungeonEnteredPattern`, `TownEnteredPattern`, `ItemAcquiredPattern`, `MonsterDefeatedPattern`, `FlagSetPattern`, a discriminated union that grows additively — optionally narrowed by the same conditions gates use, to referee-command consequences; `Adventure.triggers` carries them, and the tuple's order is document order. A game plays them by registering an `Interpreter` (`osrlib.crawl.interpreter`) on its session, once, the same way it registers any listener. A firing issues `MarkTriggerFired` first, carrying the narrative block's `fired` beat, then the consequences in authored order, then `AddJournalEntry` when the block carries a journal form — every command stamped `source="trigger:{id}"`, so the log answers *why* on its own. Triggers are once-only unless `repeatable`, and the fired-mark is session state, so once-only survives a save, a load, and a replay. The consequence surface is the new `ConsequenceCommand` union over `CONSEQUENCE_COMMAND_CLASSES` — `GrantItem`, `GrantCoins`, `AwardXP`, `SetFlag`, `SpawnMonsters`, `SpawnNpcParty`, `SetDoorState`, `PlaceParty`, `AdvanceTime` — so a document naming a lifecycle command, a player command, or an unknown type fails to parse; a consequence addressing a character uses the party selectors `PARTY_SELECTOR` (`"@party"`, expanded at issue time to one command per living member in marching order) or `FIRST_LIVING_SELECTOR` (`"@first"`, the lead survivor), because character ids are allocated per session and no document can know one. Nothing about a firing is all-or-nothing: a rejected consequence is dropped by itself while the rest still run, and a `RecordNote` names the trigger, the consequence's position and type, and the rejection code. Cascades are bounded — a trigger's events are one level deeper than the event that fired it, matching stops below depth five, and a suppressed firing is recorded as a note rather than a mark, so a once-only trigger cut short there is still fireable later. `validate_adventure` resolves every trigger reference (pattern areas, levels, dungeons, items, and monsters; condition items; consequence items, monsters, doors, and placements) and rejects a literal character id. The interpreter emits no events and keeps no state — its `listener_state` slot stays empty for the life of the session — so a replay, which runs with no listeners at all, rebuilds the same world from the same log. All of it is additive: no schema bump, no migration, and an adventure that authors no triggers plays exactly as before. +- `MarkTriggerFired.narrative` and `TriggerFiredEvent.narrative` — the authored beat for a firing, carried at referee visibility, which `format_message` appends verbatim after the templated line. Trigger wiring is the game's secret, so the `fired` beat is the referee's line; the players' line for the same moment is the trigger's journal form, which rides the player-visible journal event and the player view. +- `LocationEnteredEvent.dungeon_id` — populated on area entries, where it was the missing fact: area ids are scoped to their level, while level and dungeon entries already name the dungeon in `location_id` and town has neither. An area crossing is now self-describing, so a consumer never has to ask the session where the party is standing to know where the event happened. +- `flag_values_equal` (`osrlib.crawl.gates`) — the one strict flag comparison, equality plus matching boolness, so a stored `True` never satisfies an authored `1` in either direction. `FlagEqualsCondition` and the authored flag pattern both compare through it and can never disagree about what equality means. - The lifecycle command surface an authored trigger or quest layer writes its bookkeeping with, plus the annotation that says on whose behalf a command was issued. Every `Command` gains an optional `source` — a string naming the authored object (a trigger or quest id) or the game system that issued it, never the empty string. Execution never reads it, so a stamped command does exactly what the unstamped one does; it rides the command into the log and survives a save, a load, and a replay, which is what makes "why did the party get that item?" answerable from the log alone. Three new referee commands, legal in every mode (terminal ones included) and rejecting nothing: `MarkTriggerFired` appends a trigger id to the new `session.fired_triggers`, the state that answers once-only semantics — marking an already-marked trigger is accepted, appends nothing, and still emits its referee-visibility `TriggerFiredEvent` (`session.trigger.fired`), so state records that a trigger has fired while the log records each firing; `AddJournalEntry` appends a `JournalEntry` — the authored text plus the clock position it landed at — to the new `session.journal` and emits the player-visible `JournalEntryAddedEvent` (`session.journal.entry_added`) carrying the whole entry; and `RecordNote` records an annotation with no state effect at all, emitting the referee-visibility `NoteRecordedEvent` (`session.note.recorded`) — the mechanism for machine-issued records and a referee's own margin notes alike. `PlayerView` gains `journal`, the entries shipped verbatim; the fired-marks and the notes stay referee-only, since content wiring is the game's secret. Both blocks persist under new payload keys with empty defaults, so there is no schema bump and no migration: a save written before them loads with both empty and starts remembering, and because these commands are the blocks' only writers, a replay — which runs with no listeners registered — rebuilds them exactly by re-executing the log. Nothing in the library issues these commands yet; they are the documented referee surface, and the library-shipped interpreter that drives them arrives with the authored trigger and quest layer. - `SessionMode.VICTORY` — the second terminal mode, the session that ended by finishing what it set out to do, beside `game_over`'s ending by wipe. Both answer the new `SessionMode.terminal` property, the one place "has this session ended?" is decided, for the engine and for a front end's loop alike. The legality contract in a terminal mode: every play command is illegal (`session.command.wrong_mode`, its `mode` param carrying `victory`), and every referee command is legal — grants, awards, flags, door writes, identification, time, dice — which is what lets an adventure's rewards land after it concludes. Three referee commands are the exception, each because it would resume play in a session that is over: `SpawnMonsters` and `SpawnNpcParty` open an encounter and are illegal in both terminal modes, and `PlaceParty` teleports the party into a play mode and is illegal in `victory`. `PlaceParty` remains legal in `game_over`, where it is the salvage door — `PlaceParty(town)` then `PurchaseHealing(service="raise_dead")`, with the clock still running on the revival window. Nothing in the library transitions *into* `victory` yet; the entrance arrives with the authored quest layer, and the mode, its property, and its legality rules ship first so that transition has a contract to land on. The new enum value is additive within the current `schema_version`: a `victory` save is one an older engine has never seen, the documented accepted risk for a new serialized enum value, and there is no migration. - Authored gates on doors and level transitions: `DoorSpec.requires` and `TransitionSpec.requires` carry a `GateSpec` — a condition the party must satisfy for opening the door or taking the stair to be a legal command. The condition vocabulary is the new discriminated union in `osrlib.crawl.gates`: `HasItemCondition` (some member's carried inventory holds an item with that catalog id — equipment or magic item, equipped slots included — optionally `consumes=True`), `FlagEqualsCondition` (a session flag holds a value, compared strictly: an absent key matches nothing, not even `False`, and a stored `True` never satisfies an authored `1`), and `EffectActiveCondition` (an active effect of that kind is attached to a party member). Evaluation is pure and level-triggered — `condition_holds` reads live state at the moment of the attempt and stores nothing, so a key dropped or sold stops opening its door — and the member domain is the whole party, living or dead, because the party carries its dead and their packs. A failed gate is an ordinary rejection with its own codes, `exploration.door.gate_refused` and `exploration.transition.gate_refused`, each carrying the author's refusal text when one was written; it is checked last, after every mundane refusal, so it fires exactly when the gate alone bars the way, and on `ForceDoor` that means a refused attempt makes no noise, denies no surprise, and rolls no die. Gates and locks are orthogonal layers: a door carrying both requires both, `PickLock` addresses only the lock, `SetDoorState` rewrites only the overlay, and a door standing open admits passage unchecked until it closes again. `validate_adventure` resolves `has_item` ids against the effective equipment catalog or the magic-item catalog; flag keys and effect kinds are open domains and stay unchecked. The fields are additive with `None` defaults, so there is no schema bump and no migration: existing documents and saves load unchanged, and an ungated adventure plays exactly as before, consuming no extra draws. @@ -24,11 +28,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed +- A `CommandResult` now carries the events of commands a listener issued while reacting. A listener that reacts by executing further commands has always logged their events correctly and reported none of them back: the caller of `MoveParty` got the move and nothing of the portcullis that opened in response, and had to read `session.event_log` to find the rest. `execute` now notes where the log ends before each listener runs and folds everything logged while it ran into the result — the nested commands' events, however deeply they nest, each exactly once and in log order, followed by whatever the listener authored. A listener that emits events and issues no commands is unaffected, and nothing about what reaches the log changes. - `SpawnMonsters` and `SpawnNpcParty` no longer execute in `game_over`. Every referee command used to be legal in every mode without exception, which meant a referee could spawn a wandering patrol onto a party that had already fallen — and the encounter that opened put a concluded session back into `encounter`, or straight into `battle` on an attacking reaction, with corpses on one side of it. Spawning was never part of the salvage flow (that door is `PlaceParty`, and a session salvaged back to town can spawn again the moment it re-enters a dungeon), so both commands now reject with `session.command.wrong_mode` in `game_over` as well as in the new `victory`. Every other referee command still runs in a terminal mode. - `WedgeDoor` now reports the iron spike it consumes. Wedging a door has always taken a spike out of a member's pack, silently: nothing in the event stream said so, and a front end had to diff inventories to notice. It now emits `ItemConsumedEvent` before the `exploration.door.wedged` event, the same way a gate's toll reports itself, so there is one consumption surface rather than a speaking one and a silent one. The phase 4 and phase 5 scenario goldens moved to record the added event and the new optional `narrative` field on the door and location events; no draw sequence changed. ### Fixed +- Sight persistence now runs before the listeners, so a listener that relocates the party leaves a seen map a replay rebuilds exactly. The fold of the party's current light reveal into map memory happened after every accepted command *and after its listeners*, so a listener whose reaction teleported the party folded the destination's view in place of the view from where the party actually was — while a replay, executing the same commands with no listeners, folded both in order. Live and replay now fold identically. No stored golden changed: the goldens record state and logs, and the event log was already interleaved correctly. - A party wiped out by anything other than a lost battle now ends the session. Only battle routed to `game_over`: a party that died to a save-or-die trap, a fall, poison, or starvation was left in `exploring` (or `town`, or mid-`encounter`) forever — every play command still nominally legal with nobody alive to issue it, no `GameOverEvent`, and no way for a front end to know the game was over. Every wipe now ends the session identically, wherever the killing blow came from and whichever mode the session was in, with the one `GameOverEvent` a lost battle has always emitted; battle's own defeat branch no longer transitions, so the ending is constructed in exactly one place and the event stream a lost battle produces is unchanged. The check is triggered by a death, not by the state of the party, so the documented salvage flow still works: `PlaceParty(town)` carrying an already-fallen party out of `game_over` does not fall straight back into it. A session already in a terminal mode never transitions again — a death after the adventure has concluded leaves `victory` alone. Alongside it, the procedures that ran on after a mid-command wipe now stop: a chute that kills the party on the way down still moves the bodies and still costs its time, but no longer discovers the destination's treasure or opens its keyed encounter; an encounter round, a pursuit round, or a turning attempt whose round kills the last member resolves no stance action, no reaction re-roll, no distraction die, and starts no battle among corpses; a treasure trap that kills everyone who reached for the cache still costs the turn but no longer loots it — the authored valuables and magic items go uninstantiated, no draw is spent on them, and the cache keeps its contents for whoever comes back; and a rest or other in-field span truncates at the wipe rather than running its remaining turns of fatigue and wandering checks. Referee time is untouched — a span outside the field runs in full, because the revival window `raise_dead` reads is measured in elapsed time. Every one of these paths is reachable only once the party is dead, so no living party's draw sequence moves and no golden changed. - A refused command no longer writes a door into the state overlay. `DungeonState.door(ref)` creates on first touch, and every door handler reached it while *validating*, so a refused `OpenDoor` — locked, stuck, no door there — and a move blocked by a shut door each stored a default door entry: a command that by contract mutates nothing, mutating state. The consequences were real if quiet: a save taken after a refusal diverged from the same save taken after its replay, and the swing-shut pass iterates that map on an event-emitting path, so probing order could reorder events. Door reads now answer a transient default seeded from the authored spec — identical to what a write would have stored — and only the mutations themselves (open, close, force, wedge, unlock, secret-door discovery, and the referee's `SetDoorState`) create the entry. New saves stop accumulating entries for doors nobody ever touched; older saves load unchanged, since a redundant default entry is harmless. - A referee `SetDoorState` that sets nothing no longer writes a door into the state overlay. The command's four state fields are all optional, and one with none of them set is legal, emits nothing, and changed nothing — yet it still created the door's overlay entry, the same accumulation the read-path fix above removes. diff --git a/docs/getting-started/building-an-adventure.md b/docs/getting-started/building-an-adventure.md index 6ec0a7c..3f7143f 100644 --- a/docs/getting-started/building-an-adventure.md +++ b/docs/getting-started/building-an-adventure.md @@ -84,6 +84,62 @@ Locks and gates are separate layers, and a door that carries both requires both: A [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] holds the authored text for the mechanical object it hangs on. Gates read two of its beats: `refusal`, returned in the rejection, and `success`, which rides the successful command's event — the [`DoorEvent`][osrlib.crawl.events.DoorEvent] for a door, 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. The block's other fields — `journal`, `guidance` for an LLM narrator, `speaker` — are read by the surfaces that consume them; none of them ever reach the player view, which carries no gate wiring at all. +## Wiring the dungeon with triggers + +A gate asks "may the party do this?" every time it tries. A trigger asks the opposite question, once: "did this just happen?" A [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec] binds an observable event pattern to referee-command consequences — the lever that opens the portcullis, the idol whose theft wakes the temple, the room whose first crossing writes a line in the party's journal: + +```{.python .no-run} +sentinel_wakes = TriggerSpec( + id="sentinel-wakes", + when=ItemAcquiredPattern(item_id="brass_key"), + consequences=(SetFlag(key="barrow.key_found", value=True),), + narrative=NarrativeBlock( + fired="The sentinel's head turns a few degrees, and stops.", + journal="The brass key is ours. Something in the barrow noticed.", + ), +) +``` + +Triggers are inert content on their own. They play when your game registers the library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] on the session — once, right after the session is built (and again after loading a save, since listeners are code and a save carries data): + +```{.python .no-run} +session = GameSession.new(Party(members=[hero.character]), adventure, seed=11) +session.register_listener(Interpreter(session)) +``` + +### What a trigger watches + +`when` is one pattern from a small union, each naming an event the engine already emits: + +- [`AreaEnteredPattern`][osrlib.crawl.triggers.AreaEnteredPattern] — the party stepped into a keyed area. Area ids are scoped to their level, so the pattern names the whole triple: `dungeon_id`, `level_number`, `area_id`. +- [`LevelEnteredPattern`][osrlib.crawl.triggers.LevelEnteredPattern] — the party arrived on a level, by stair or by walking in from town. +- [`DungeonEnteredPattern`][osrlib.crawl.triggers.DungeonEnteredPattern] and [`TownEnteredPattern`][osrlib.crawl.triggers.TownEnteredPattern] — the coarser crossings; the town pattern needs no fields, since an adventure has one town. +- [`ItemAcquiredPattern`][osrlib.crawl.triggers.ItemAcquiredPattern] — a member acquired an item with that catalog id, from a cache, a grant, or another member's hands. +- [`MonsterDefeatedPattern`][osrlib.crawl.triggers.MonsterDefeatedPattern] — a monster of that template was defeated: slain, routed, and surrendered all count. Defeats are reported when the battle ends, so "the portcullis opens the instant the boss falls" is not authorable — it opens when the fighting stops. +- [`FlagSetPattern`][osrlib.crawl.triggers.FlagSetPattern] — a flag was written. This is the lever: your game (or another trigger) executes [`SetFlag`][osrlib.crawl.commands.SetFlag], and the trigger watching that key fires. The match is on the value the write carried, and `value=None` matches any value at all. + +`conditions` narrows it further with the same [condition union the gates use](#gating-a-door-or-a-stair) — all of them must hold, evaluated live at the moment of the match, so a trigger can ask "…and only if somebody is still carrying the talisman". One difference from a gate: a trigger's condition may not set `consumes=True`. A trigger reacts to something that has already happened, and there is no attempt of its own to charge a toll against. + +By default a trigger fires once ever, and the fired-mark is session state that survives a save, a load, and a replay. `repeatable=True` opts into firing every time the pattern matches. + +### What a firing does + +The interpreter issues ordinary referee commands, every one of them stamped `source="trigger:{id}"` so the command log answers *why* on its own: + +1. [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], first, carrying the `fired` beat. +2. Your `consequences`, in the order you wrote them. +3. [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry], last, when the narrative block carries a `journal` form. + +Consequences are drawn from [`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand] — `GrantItem`, `GrantCoins`, `AwardXP`, `SetFlag`, `SpawnMonsters`, `SpawnNpcParty`, `SetDoorState`, `PlaceParty`, `AdvanceTime`. Anything else fails to parse. A consequence that hands something to a character names it with a party selector rather than an id: [`PARTY_SELECTOR`][osrlib.crawl.triggers.PARTY_SELECTOR] (`"@party"`) becomes one command per living member in marching order, and [`FIRST_LIVING_SELECTOR`][osrlib.crawl.triggers.FIRST_LIVING_SELECTOR] (`"@first"`) the lead survivor. Character ids are allocated per session, so a document that named one would be naming something that does not exist when it is read — validation rejects it. + +The two beats have two different audiences, and the rule is worth stating plainly: **`fired` is the referee's line and `journal` is the players'.** The `fired` text rides a referee-visibility event, because content wiring is your game's secret; the journal entry is player-visible and ships verbatim in the [`PlayerView`][osrlib.crawl.views.PlayerView]. If you want the table to read something when a trigger fires, write the journal form. + +### When something doesn't land + +Nothing about a trigger firing is all-or-nothing. A consequence the session rejects — a spawn arriving to find an encounter already open, a grant naming an item the catalog lost — is dropped by itself, the consequences after it still run, and a [`RecordNote`][osrlib.crawl.commands.RecordNote] records the trigger, the consequence's position and type, and the rejection code. There is no retry and no queue: a consequence that fired later, out of order, would be impossible to debug. + +Cascades are bounded. A trigger's own events are one level deeper than the event that fired it, matching stops below depth five, and a firing the bound suppresses is recorded as a note rather than a mark — so a once-only trigger cut short there is still fireable later. Flag-chains are perfectly good wiring; the bound is the guarantee that a loop in them ends. + ## The dungeon, the town, and the root The level slots into a [`DungeonSpec`][osrlib.crawl.dungeon.DungeonSpec], and the dungeon into an [`Adventure`][osrlib.crawl.adventure.Adventure] beside the [`TownSpec`][osrlib.crawl.adventure.TownSpec] — the safe base where the party rests, buys equipment, and sells treasure. `travel_turns` maps each dungeon id to the town-to-entrance travel cost in exploration turns: @@ -96,10 +152,11 @@ adventure = Adventure( town=town, dungeons=(barrow,), items=(GearTemplate(id="brass_key", name="Brass key", cost_gp=0),), + triggers=(sentinel_wakes,), ) ``` -`items` bundles the adventure's own item templates — the brass key the sentinel wants is content, not shipped equipment. See [authoring custom content](../guides/authoring-custom-content.md) for the whole bundling contract. +`items` bundles the adventure's own item templates — the brass key the sentinel wants is content, not shipped equipment. See [authoring custom content](../guides/authoring-custom-content.md) for the whole bundling contract. `triggers` is the adventure's wiring, and the tuple's order is document order: when two triggers match the same event, they fire in the order you wrote them. ## Validate before play @@ -121,7 +178,7 @@ from osrlib.core.items import GearTemplate from osrlib.core.rng import RngStreams from osrlib.core.ruleset import Ruleset from osrlib.crawl.adventure import Adventure, TownSpec, validate_adventure -from osrlib.crawl.commands import EnterDungeon, GrantItem, MoveParty, OpenDoor, SessionMode +from osrlib.crawl.commands import EnterDungeon, GrantItem, MoveParty, OpenDoor, SessionMode, SetFlag from osrlib.crawl.dungeon import ( AreaSpec, Direction, @@ -134,9 +191,11 @@ from osrlib.crawl.dungeon import ( LevelSpec, ) from osrlib.crawl.gates import GateSpec, HasItemCondition +from osrlib.crawl.interpreter import Interpreter from osrlib.crawl.narrative import NarrativeBlock from osrlib.crawl.party import Party from osrlib.crawl.session import GameSession +from osrlib.crawl.triggers import ItemAcquiredPattern, TriggerSpec from osrlib.data import load_equipment, load_monsters sentinel = GateSpec( @@ -169,6 +228,16 @@ level = LevelSpec( ), ) +sentinel_wakes = TriggerSpec( + id="sentinel-wakes", + when=ItemAcquiredPattern(item_id="brass_key"), + consequences=(SetFlag(key="barrow.key_found", value=True),), + narrative=NarrativeBlock( + fired="The sentinel's head turns a few degrees, and stops.", + journal="The brass key is ours. Something in the barrow noticed.", + ), +) + barrow = DungeonSpec(id="barrow", name="The Barrow", levels=(level,)) town = TownSpec(name="Threshold", travel_turns={"barrow": 2}) adventure = Adventure( @@ -176,6 +245,7 @@ adventure = Adventure( town=town, dungeons=(barrow,), items=(GearTemplate(id="brass_key", name="Brass key", cost_gp=0),), + triggers=(sentinel_wakes,), ) # Validation catches unknown ids and broken geometry before play ever starts. @@ -185,6 +255,7 @@ rules = Ruleset() creation = RngStreams(master_seed=11).get(CHARACTER_CREATION_STREAM) hero = create_character(name="Brakka", class_id="dwarf", alignment=Alignment.LAWFUL, ruleset=rules, stream=creation) session = GameSession.new(Party(members=[hero.character]), adventure, seed=11) +session.register_listener(Interpreter(session)) session.execute(EnterDungeon(dungeon_id="barrow")) session.execute(MoveParty(direction=Direction.EAST)) @@ -196,7 +267,21 @@ assert not refused.accepted assert refused.rejections[0].code == "exploration.door.gate_refused" assert refused.rejections[0].params["refusal"].startswith("The bronze sentinel") -session.execute(GrantItem(character_id="character-0001", item_id="brass_key")) +# The key lands, and the trigger watching for it fires inside the same command: +# the result carries the acquisition and everything the trigger caused after it. +granted = session.execute(GrantItem(character_id="character-0001", item_id="brass_key")) +assert [event.code for event in granted.events] == [ + "exploration.item.acquired", + "session.trigger.fired", + "session.flag.set", + "session.journal.entry_added", +] +assert session.fired_triggers == ["sentinel-wakes"] +assert session.flags["barrow.key_found"] is True +assert session.journal[0].text == "The brass key is ours. Something in the barrow noticed." +# Every command the trigger issued says whose idea it was. +assert session.command_log[-1].source == "trigger:sentinel-wakes" + opened = session.execute(OpenDoor(direction=Direction.EAST)) assert opened.accepted assert opened.events[0].narrative == "The brass key turns in the sentinel's palm and the door swings wide." diff --git a/docs/guides/listeners-and-flags.md b/docs/guides/listeners-and-flags.md index 10eb241..ead36f1 100644 --- a/docs/guides/listeners-and-flags.md +++ b/docs/guides/listeners-and-flags.md @@ -48,6 +48,13 @@ by executing its own commands must return an empty list. A nested `session.execu already appends that command's events to the session's event log itself; returning them again from `handle` would log the same event twice. +Returning nothing costs the caller nothing. `execute` notes where the event log ends before it +calls each listener and folds everything logged while that listener ran into the result it hands +back — the nested commands' events, however deeply they nest, each exactly once and in log order, +followed by whatever the listener authored. So the `CommandResult` from a player's `MoveParty` +carries the portcullis grinding open and the journal entry that recorded it, and a front end +renders the whole chain from one envelope. + The nested-`execute` call matters for a second reason: it re-enters the entire dispatch pipeline, listener loop included. If a listener issues a command from inside `handle`, every registered listener — itself included — runs again against *that* command's events, with whatever `state` @@ -119,8 +126,36 @@ remembering things itself, the discipline this page opened with. The optional `source` stamp (see [Sessions, commands, and events](sessions-commands-events.md)) is what ties the vocabulary together: a listener that stamps the commands it issues with its own quest or trigger id leaves a -log that answers *why* every entry is there. A library-shipped trigger and quest interpreter will -be built on exactly this surface when it arrives; a game's own listener can drive it today. +log that answers *why* every entry is there. The library's own trigger interpreter is built on +exactly this surface, and a game's own listener drives it the same way. + +## The trigger interpreter: this pattern, shipped + +[`Interpreter`][osrlib.crawl.interpreter.Interpreter] is a listener like any other, and it is the +worked reference for everything above. Register one, once, after the session exists — and again +after loading a save, because listeners are code and a save carries data: + +```{.python .no-run} +session.register_listener(Interpreter(session)) +``` + +From then on it watches every command's events, matches them against the adventure's authored +[triggers](../getting-started/building-an-adventure.md#wiring-the-dungeon-with-triggers), and +reacts the only way a listener may: by executing referee commands, each stamped +`source="trigger:{id}"`. Three properties are worth copying into your own listeners: + +- **It returns no events.** Every event it causes was logged by a command it executed, and the + result envelope picks those up from the log. `handle` returns `[], {}` unconditionally. +- **It keeps no state.** Its `listener_state` slot exists — `register_listener` creates one — and + stays the empty dict for the life of the session. Fired-marks live in `session.fired_triggers`, + beats in `session.journal`, everything else in the world the commands changed. That is what + makes a triggered game replay exactly: a replay runs with no listeners at all, and re-executing + the log rebuilds every one of those blocks. +- **It has no re-entrancy guard, on purpose.** The fetch quest below needs one because its trigger + condition can look unsatisfied from inside its own reaction. The interpreter instead records the + fired-mark *before* running a trigger's consequences, so a consequence that re-matches its own + trigger finds it already fired; re-entrant self-invocation is how one trigger's consequences fire + the next, and a depth bound rather than a latch is what stops a cascade. ## The fetch quest, worked diff --git a/docs/guides/sessions-commands-events.md b/docs/guides/sessions-commands-events.md index 2d0548f..9a51a2a 100644 --- a/docs/guides/sessions-commands-events.md +++ b/docs/guides/sessions-commands-events.md @@ -31,6 +31,13 @@ far and appending its own reactions to both the result and the log. The `CommandResult` a caller receives after an accepted command carries the *complete* chain — the handler's events and every listener's events, in the order they happened. +That includes what a listener causes by executing further commands. Those nested +commands log their own events, and `execute` folds everything logged while a listener +ran into the result — each event exactly once, in log order. So one `MoveParty` can +come back carrying the move, the portcullis a trigger opened in response, and the +journal entry that recorded it, and a front end renders all of it from one envelope +without ever reading `session.event_log`. + Listeners are how a game adds its own reactive rules (a quest tracker, an achievement log) without touching the kernel; see [Listeners and flags](listeners-and-flags.md) for the extension point itself. @@ -49,6 +56,12 @@ assert session.command_log[-1].source == "trigger:lever-east" assert session.view(Visibility.PLAYER).journal[-1].text == "The lever grinds." ``` +The library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] stamps every command +it issues this way, so a log left behind by authored content reads as a transcript with +attributions: this grant came from `trigger:idol-lifted`, that door opened for +`trigger:portcullis-rises`, and the `record_note` beside them says which consequence was +dropped and why. + ## Session modes and mode gating [`SessionMode`][osrlib.crawl.commands.SessionMode] is a small, closed set: `town`, diff --git a/docs/phase-14-plan.md b/docs/phase-14-plan.md index bead22f..04368f6 100644 --- a/docs/phase-14-plan.md +++ b/docs/phase-14-plan.md @@ -44,13 +44,13 @@ Out of scope (deferred to the phase that picks each up): ### 2. The consequence surface — `crawl/commands.py` -- `CONSEQUENCE_COMMAND_CLASSES: tuple[type[Command], ...]` — the nine classes from the facts, wire order — and `ConsequenceCommand`, the annotated discriminated union over them, both in `__all__`. The docstring states the contract in the present tense: the referee commands an adventure document may carry as authored consequences, the lifecycle family excluded because those are the interpreter's own vocabulary, `IdentifyItem` excluded because instance ids are session-scoped, `RollDice` excluded because no authored construct reads a roll; and the selector rule for `character_id` fields. +- `CONSEQUENCE_COMMAND_CLASSES: tuple[type[Command], ...]` — the nine classes from the facts, wire order — and `ConsequenceCommand`, the annotated discriminated union over them, both in `__all__`. The union spells its nine members out rather than unpacking the census tuple (`Union[*CONSEQUENCE_COMMAND_CLASSES]`, the shape `AnyCommand` uses): `AnyCommand` is only ever a runtime value, while this one annotates `TriggerSpec.consequences`, and a type checker cannot read a variable in a type expression. A census test asserts the two spellings name the same classes in the same order. The docstring states the contract in the present tense: the referee commands an adventure document may carry as authored consequences, the lifecycle family excluded because those are the interpreter's own vocabulary, `IdentifyItem` excluded because instance ids are session-scoped, `RollDice` excluded because no authored construct reads a roll; and the selector rule for `character_id` fields. - `test_commands.py`'s census cross-checks: every consequence class is a referee command, and the exclusions are exactly the two named plus the lifecycle family. ### 3. Authored triggers on the adventure — `crawl/adventure.py` - `Adventure.triggers: tuple[TriggerSpec, ...] = ()` — document order is tuple order, the ordering contract matching relies on. Pre-phase documents and saves parse unchanged (the additive default); no `SCHEMA_VERSION` bump anywhere in the phase. -- `validate_adventure` grows the trigger walk, error lines in the house shape (`"trigger {id}: ..."`): ids non-empty and unique across the adventure; pattern references resolve (`AreaEnteredPattern`'s dungeon → level → area id, `LevelEnteredPattern`'s dungeon → level, `DungeonEnteredPattern`'s dungeon, `ItemAcquiredPattern`'s item id against effective equipment ∪ magic — the `_validate_gate` domain — and `MonsterDefeatedPattern`'s template id against the effective monster catalog; flag keys are the open domain, unchecked); condition `has_item` ids through the same helper the gate check uses, refactored so gate and bare-condition sites share it; consequence references per command — `GrantItem.item_id` against effective equipment, `SpawnMonsters.template_id` against effective monsters, `SetDoorState`'s dungeon/level resolving and a door edge existing at its cell and direction, `PlaceParty.location` resolving with the position in bounds (town needs no check); and the selector rule — a `character_id` in an authored consequence must be `@party` or `@first`, a literal id is an error because session-scoped ids have no meaning in a document. +- `validate_adventure` grows the trigger walk, error lines in the house shape (`"trigger {id}: ..."`): ids unique across the adventure (non-emptiness is `TriggerSpec.id`'s own `min_length=1`, so an empty id is a parse rejection and a validation check for it would be unreachable code — the model test covers it); pattern references resolve (`AreaEnteredPattern`'s dungeon → level → area id, `LevelEnteredPattern`'s dungeon → level, `DungeonEnteredPattern`'s dungeon, `ItemAcquiredPattern`'s item id against effective equipment ∪ magic — the `_validate_gate` domain — and `MonsterDefeatedPattern`'s template id against the effective monster catalog; flag keys are the open domain, unchecked); condition `has_item` ids through the same helper the gate check uses, refactored so gate and bare-condition sites share it; consequence references per command — `GrantItem.item_id` against effective equipment, `SpawnMonsters.template_id` against effective monsters, `SetDoorState`'s dungeon/level resolving and a door edge existing at its cell and direction, `PlaceParty.location` resolving with the position in bounds (town needs no check); and the selector rule — a `character_id` in an authored consequence must be `@party` or `@first`, a literal id is an error because session-scoped ids have no meaning in a document. ### 4. Session seam work — `crawl/session.py` @@ -83,7 +83,7 @@ Out of scope (deferred to the phase that picks each up): ### 9. Tests — `tests/test_triggers.py`, `tests/test_interpreter.py` (new), and the phase golden -- **Models and validation** (`test_triggers.py`): pattern and spec round-trips on the discriminators; parse rejections — a lifecycle command, a player command, and an unknown type in `consequences`; `consumes=True` in `conditions`; an authored `source`; `validate_adventure` catching each dangling reference class (pattern area/level/dungeon/item/monster, condition item, consequence item/monster/door/location, literal `character_id`, duplicate and empty trigger ids) and accepting a clean document with every pattern kind; pre-phase documents and saves loading unchanged. +- **Models and validation** (`test_triggers.py`): pattern and spec round-trips on the discriminators; parse rejections — a lifecycle command, a player command, and an unknown type in `consequences`; `consumes=True` in `conditions`; an authored `source`; an empty trigger id; `validate_adventure` catching each dangling reference class (pattern area/level/dungeon/item/monster, condition item, consequence item/monster/door/location, literal `character_id`, duplicate trigger ids) and accepting a clean document with every pattern kind; pre-phase documents and saves loading unchanged. - **Matching units** (`test_interpreter.py`): each pattern kind against matching and non-matching events, the level-subsumes-dungeon pin, `FlagSetPattern`'s any-value and strict-value modes (`1` vs `True` both directions through `flag_values_equal`), magic-instance resolution on item acquisition, condition gating (a failing condition blocks a firing and leaves no mark), once-only vs `repeatable`, document-order and batch-order firing, evaluate-as-you-go (an earlier firing's `SetFlag` satisfying a later trigger's condition in the same batch). - **Interpreter integration**: the lever-portcullis wiring end to end; selector expansion (`@party` to living members in marching order, `@first`, `@first` with nobody living dropping with its note, a dead member excluded); `source` stamps on every issued command; the drop-and-note on a colliding spawn; the truncation ladder — a flag-chain of repeatable triggers cascading to depth 5, the note recorded, the suppressed trigger unfired and provably fireable afterward; mark-before-consequences pinned by a once-only trigger whose consequence event would re-match it; the splice — a player command's result carrying the full cascade in event-log order; the reorder — a `PlaceParty` consequence teleporting the party, live seen-map equal to replay's. - **Existing-surface guards**: the listener-contract tests in `test_session.py` extended for the splice (an emit-only listener unchanged; a command-issuing listener's events riding the result); the leak test extended — the serialized player view contains no `pattern_type`, `consequences`, or trigger-wiring keys after a triggered session; `test_public_surface.py` covering both new modules' `__all__` and the two new `commands.py` exports. diff --git a/docs/spec.md b/docs/spec.md index dc94822..1a3d760 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -195,6 +195,8 @@ An adventure can be won, and its content can be wired — the lever that opens t - Bounded cascades: events produced by a player command are depth 0, events produced by a trigger's consequences are one deeper than the events that fired it, and triggers match events of depth 4 or less. Truncation records a referee-visibility note. Flag-chains are legitimate wiring — the bound is the runtime guarantee, and a cycle is suspicious authoring for an editor lint, not invalid content. - Rejected consequences are dropped and recorded as referee-visibility notes — no retry and no queue, because deferred consequences firing later would be undebuggable. (A triggered ambush can collide with a wandering encounter opened by the same movement; the spawn drops and the note says so.) - Trigger-granted treasure follows the normal valuation rules at the moment it lands: granted mid-dungeon it counts in the next return delta, granted at town it earns no treasure XP. Authored XP belongs in `AwardXP`. +- A consequence addresses characters through party selectors, never a literal id: `@party` expands at issue time to one command per living member in marching order, `@first` to the first living member, and validation rejects a document that names a character id, because character ids are allocated per session and no document can know one. The expansion happens as the commands issue, so the log stays concrete and replays exactly. +- The consequence surface is the referee command vocabulary minus three: the lifecycle commands (the interpreter's own vocabulary, never authored), `IdentifyItem` (its item ids are session-scoped instance ids, the same unknowability as character ids), and `RollDice` (a draw whose result no authored construct can read, so an authored roll would be a no-op that perturbs the adjudication stream). - Monster-defeat events emit at battle end, so "the portcullis opens mid-fight as the boss falls" is not authorable; stated so authors' expectations are set. **The interpreter and the command log.** The interpreter observes events, decides, and acts exclusively by issuing commands — anything it merely remembered would be lost to a replay. Quest, trigger, and journal state live in the engine session, mutated only by a small family of lifecycle referee commands the interpreter issues (authors author quest and trigger specs, never these commands): `MarkTriggerFired` records fired-state before a trigger's consequences issue; `ActivateQuest`, `RevealObjective`, `CompleteObjective`, and `CompleteQuest` advance quest state, emit the player-visible events carrying the authored narrative, and append journal entries; `AddJournalEntry` gives one-off triggers a journal voice; `RecordNote` has no state effect and emits a referee-visibility event — the mechanism behind dropped-consequence and truncation records. These are ordinary logged, replayed commands. The base command model gains an optional `source` field, ignored by execution, which the interpreter stamps with the owning trigger or quest id — so "why did the party get 500 XP" is answerable from the log alone. diff --git a/src/osrlib/crawl/adventure.py b/src/osrlib/crawl/adventure.py index afbe5a8..c0bf9a5 100644 --- a/src/osrlib/crawl/adventure.py +++ b/src/osrlib/crawl/adventure.py @@ -25,8 +25,19 @@ WeaponTemplate, ) from osrlib.core.monsters import MonsterCatalog, MonsterTemplate -from osrlib.crawl.dungeon import DungeonSpec, FeatureSpec, LevelSpec -from osrlib.crawl.gates import GateSpec, HasItemCondition +from osrlib.crawl.commands import AwardXP, GrantCoins, GrantItem, PlaceParty, SetDoorState, SpawnMonsters +from osrlib.crawl.dungeon import DungeonSpec, EdgeKind, FeatureSpec, LevelSpec +from osrlib.crawl.gates import ConditionSpec, GateSpec, HasItemCondition +from osrlib.crawl.triggers import ( + FIRST_LIVING_SELECTOR, + PARTY_SELECTOR, + AreaEnteredPattern, + DungeonEnteredPattern, + ItemAcquiredPattern, + LevelEnteredPattern, + MonsterDefeatedPattern, + TriggerSpec, +) from osrlib.data import load_magic_items from osrlib.errors import ContentValidationError @@ -75,6 +86,12 @@ class Adventure(BaseModel): catalog, or each other — one item id names one thing per session. The town shop is the one place they do not reach: it stocks the shipped equipment lists. + + `triggers` are the adventure's authored + [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]s, and the tuple's order *is* + document order: triggers matching one event fire in it. A game plays them by + registering an [`Interpreter`][osrlib.crawl.interpreter.Interpreter] on its + session; an adventure that authors none plays exactly as one that never could. """ model_config = ConfigDict(frozen=True) @@ -86,6 +103,7 @@ class Adventure(BaseModel): dungeons: tuple[DungeonSpec, ...] = Field(min_length=1) monsters: tuple[MonsterTemplate, ...] = () items: tuple[ItemTemplate, ...] = () + triggers: tuple[TriggerSpec, ...] = () @model_validator(mode="after") def _dungeon_ids_unique(self) -> Adventure: @@ -201,6 +219,30 @@ def _validate_feature( errors.append(f"{owner}: feature {feature.id!r} references unknown magic item {item_id!r}") +def _dangling_condition_item( + condition: ConditionSpec, equipment: EquipmentCatalog, magic: MagicItemCatalog +) -> str | None: + """The condition's item id when it names nothing that can ever be carried, else `None`. + + Equipment (base ∪ bundled) or magic item: exactly the union `has_item` + evaluates against, so an id neither catalog holds can never be satisfied and is + a dangling reference. Every other condition kind answers `None` — flag keys and + effect kinds are open domains and get no check, because a flag nobody writes is + authoring-tool territory, not a broken document. Gates and bare trigger + conditions both ask here, so the two can never disagree about the domain. + """ + if not isinstance(condition, HasItemCondition): + return None + try: + equipment.get(condition.item_id) + except ValueError: + try: + magic.get(condition.item_id) + except ValueError: + return condition.item_id + return None + + def _validate_gate( gate: GateSpec | None, owner: str, @@ -209,23 +251,102 @@ def _validate_gate( magic: MagicItemCatalog, errors: list[str], ) -> None: - """Resolve a gate's `has_item` id against the item domain the condition matches. - - Equipment (base ∪ bundled) or magic item: exactly the union `has_item` - evaluates against, so an id neither catalog holds can never be satisfied and is - a dangling reference. Flag keys and effect kinds are open domains and get no - check — a flag nobody writes is authoring-tool territory, not a broken document. - """ - if gate is None or not isinstance(gate.condition, HasItemCondition): + """Resolve a gate's `has_item` id against the item domain the condition matches.""" + if gate is None: return - item_id = gate.condition.item_id + dangling = _dangling_condition_item(gate.condition, equipment, magic) + if dangling is not None: + errors.append(f"{owner}: {site} gate references unknown item {dangling!r}") + + +def _resolve_level(adventure: Adventure, dungeon_id: str, level_number: int) -> LevelSpec | None: + """The level a dungeon id and level number name, or `None` when either dangles.""" try: - equipment.get(item_id) + return adventure.dungeon(dungeon_id).level(level_number) except ValueError: + return None + + +def _validate_trigger( + trigger: TriggerSpec, + adventure: Adventure, + monsters: MonsterCatalog, + equipment: EquipmentCatalog, + magic: MagicItemCatalog, + errors: list[str], +) -> None: + """Resolve one trigger's pattern, condition, and consequence references. + + Flag keys stay unchecked at every site — the flag namespace is open by design, + and a key nobody writes is an authoring lint rather than a broken document. + """ + owner = f"trigger {trigger.id!r}" + pattern = trigger.when + if isinstance(pattern, AreaEnteredPattern | LevelEnteredPattern): + level = _resolve_level(adventure, pattern.dungeon_id, pattern.level_number) + if level is None: + errors.append(f"{owner}: pattern references unknown {pattern.dungeon_id!r} level {pattern.level_number}") + elif isinstance(pattern, AreaEnteredPattern) and not any(area.id == pattern.area_id for area in level.areas): + errors.append( + f"{owner}: pattern references unknown area {pattern.area_id!r} " + f"on {pattern.dungeon_id!r} level {pattern.level_number}" + ) + elif isinstance(pattern, DungeonEnteredPattern): try: - magic.get(item_id) + adventure.dungeon(pattern.dungeon_id) + except ValueError: + errors.append(f"{owner}: pattern references unknown dungeon {pattern.dungeon_id!r}") + elif isinstance(pattern, ItemAcquiredPattern): + if _dangling_condition_item(HasItemCondition(item_id=pattern.item_id), equipment, magic) is not None: + errors.append(f"{owner}: pattern references unknown item {pattern.item_id!r}") + elif isinstance(pattern, MonsterDefeatedPattern): + try: + monsters.get(pattern.template_id) except ValueError: - errors.append(f"{owner}: {site} gate references unknown item {item_id!r}") + errors.append(f"{owner}: pattern references unknown monster {pattern.template_id!r}") + for condition in trigger.conditions: + dangling = _dangling_condition_item(condition, equipment, magic) + if dangling is not None: + errors.append(f"{owner}: condition references unknown item {dangling!r}") + for position, consequence in enumerate(trigger.consequences): + site = f"{owner}: consequence {position}" + if isinstance(consequence, GrantItem | GrantCoins | AwardXP): + # Character ids are allocated per session, so a document can never name + # one: authored consequences address the party through the selectors. + if consequence.character_id not in (PARTY_SELECTOR, FIRST_LIVING_SELECTOR): + errors.append( + f"{site} names character {consequence.character_id!r}; an authored consequence " + f"addresses {PARTY_SELECTOR!r} or {FIRST_LIVING_SELECTOR!r}" + ) + if isinstance(consequence, GrantItem): + try: + equipment.get(consequence.item_id) + except ValueError: + errors.append(f"{site} references unknown item {consequence.item_id!r}") + elif isinstance(consequence, SpawnMonsters): + try: + monsters.get(consequence.template_id) + except ValueError: + errors.append(f"{site} references unknown monster {consequence.template_id!r}") + elif isinstance(consequence, SetDoorState): + level = _resolve_level(adventure, consequence.dungeon_id, consequence.level_number) + if level is None: + errors.append(f"{site} references unknown {consequence.dungeon_id!r} level {consequence.level_number}") + elif level.edge((consequence.x, consequence.y), consequence.direction).kind is not EdgeKind.DOOR: + errors.append( + f"{site} names no door at ({consequence.x}, {consequence.y}) {consequence.direction.value}" + ) + elif isinstance(consequence, PlaceParty): + # A town placement names the adventure's one town and needs no check; the + # location model guarantees a dungeon location's fields travel together. + location = consequence.location + if location.dungeon_id is None or location.level_number is None: + continue + level = _resolve_level(adventure, location.dungeon_id, location.level_number) + if level is None: + errors.append(f"{site} references unknown {location.dungeon_id!r} level {location.level_number}") + elif location.position is not None and not level.in_bounds(location.position): + errors.append(f"{site} places the party out of bounds at {location.position}") def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment: EquipmentCatalog) -> None: @@ -245,6 +366,14 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment resolving to real cells, town travel entries naming real dungeons, and an entrance existing somewhere in every dungeon. + Then, per trigger: ids unique across the adventure; the pattern's area, level, + dungeon, item, and monster references resolving; the same item domain for + `has_item` conditions; and per consequence, granted item ids, spawned template + ids, a door edge at the cell a door write names, a placement landing on the + grid, and the rule that a consequence addressing a character does so through a + party selector — a session allocates character ids, so a document naming one is + naming something that cannot exist when it is read. + Args: adventure: The adventure to validate. monsters: The *base* monster catalog — validation unions it internally @@ -340,5 +469,11 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment continue if not target.in_bounds(transition.to_position): errors.append(f"{owner}: transition target cell {transition.to_position} is out of bounds") + seen_triggers: set[str] = set() + for trigger in adventure.triggers: + if trigger.id in seen_triggers: + errors.append(f"trigger {trigger.id!r}: id is not unique") + seen_triggers.add(trigger.id) + _validate_trigger(trigger, adventure, effective, effective_items, magic, errors) if errors: raise ContentValidationError("adventure validation failed:\n" + "\n".join(errors)) diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 3a41f86..8538ce3 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -37,6 +37,7 @@ __all__ = [ "ALL_COMMAND_CLASSES", + "CONSEQUENCE_COMMAND_CLASSES", "AddJournalEntry", "AdvanceTime", "AnyCommand", @@ -46,6 +47,7 @@ "CloseDoor", "Command", "CommandResult", + "ConsequenceCommand", "DropItems", "EngageBattle", "EnterDungeon", @@ -1854,11 +1856,18 @@ class MarkTriggerFired(Command): Events: [`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent] with the - trigger id, for every mark. + trigger id and the beat, for every mark. """ command_type: Literal["mark_trigger_fired"] = "mark_trigger_fired" trigger_id: str = Field(min_length=1) + narrative: str | None = Field(default=None, min_length=1) + """The authored beat for the firing, carried out on the event at referee + visibility. Trigger internals are the game's secret, so this is the referee's + line about the wiring; the players' line is a journal entry + ([`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry]). Authored text on a + command is content data in a structured field — the command still carries its + type and its facts.""" class AddJournalEntry(Command): @@ -1969,6 +1978,55 @@ class RecordNote(Command): ] """Any command, discriminated by `command_type`.""" +CONSEQUENCE_COMMAND_CLASSES: tuple[type[Command], ...] = ( + GrantItem, + GrantCoins, + AwardXP, + SetFlag, + SpawnMonsters, + SpawnNpcParty, + SetDoorState, + PlaceParty, + AdvanceTime, +) +"""The referee commands an adventure document may carry as authored consequences, in a +stable wire order. + +Three referee commands sit outside the surface, each for its own reason: + +- [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], + [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry], and + [`RecordNote`][osrlib.crawl.commands.RecordNote] are the vocabulary the trigger + interpreter writes its own bookkeeping in — it marks, journals, and annotates on the + author's behalf, so an authored copy would double the record. +- [`IdentifyItem`][osrlib.crawl.commands.IdentifyItem] addresses a magic item by its + session-scoped instance id, which no document can know. +- [`RollDice`][osrlib.crawl.commands.RollDice] produces a result no authored construct + reads, so an authored roll would be a no-op that moved the adjudication stream. + +The `character_id` of a grant or an award is a party selector in an authored +consequence — never a literal id, for the same unknowability reason `IdentifyItem` is +excluded; see [`osrlib.crawl.triggers`][osrlib.crawl.triggers] for the selector +vocabulary.""" + +ConsequenceCommand = Annotated[ + GrantItem + | GrantCoins + | AwardXP + | SetFlag + | SpawnMonsters + | SpawnNpcParty + | SetDoorState + | PlaceParty + | AdvanceTime, + Field(discriminator="command_type"), +] +"""An authored consequence, discriminated by `command_type` — the sub-union over +[`CONSEQUENCE_COMMAND_CLASSES`][osrlib.crawl.commands.CONSEQUENCE_COMMAND_CLASSES], +spelled out so a static type checker can read it. A document naming any other command +type fails to parse, which is the whole enforcement: typing a field with this union +needs no validator behind it.""" + @cache def _any_command_adapter() -> TypeAdapter: diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 03b866d..0119c23 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -100,7 +100,9 @@ class LocationEnteredEvent(Event): `location_kind` is `area`, `level`, `dungeon`, or `town`; `location_id` is the area or dungeon id (`"town"` for town). `level_number` rides level and dungeon - entries. + entries, and `dungeon_id` rides area entries — an area id is scoped to its + level, so an area entry needs all three to name where the party is, while level + and dungeon entries carry the dungeon id in `location_id` and town has neither. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.location.entered"}) @@ -111,6 +113,7 @@ class LocationEnteredEvent(Event): location_kind: str location_id: str level_number: int | None = None + dungeon_id: str | None = None narrative: str | None = None """The authored success text of the gate on the transition that was taken, when the author wrote one. Authored text on an event is content data in a structured field, @@ -815,6 +818,12 @@ class TriggerFiredEvent(Event): Emitted for every [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], a mark of an already-fired trigger included: session state records that a trigger has fired, and these events record each firing. + + `narrative` is the trigger's authored beat for the firing — content data in a + structured field, not engine-baked English: the event still carries its message + code and its facts, and the default formatter appends the line verbatim after + the templated one. It rides a referee-visibility event because trigger wiring is + the game's secret; a beat written for the table is a journal entry. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.trigger.fired"}) @@ -823,6 +832,7 @@ class TriggerFiredEvent(Event): code: str = "session.trigger.fired" visibility: Visibility = Visibility.REFEREE trigger_id: str + narrative: str | None = None class JournalEntryAddedEvent(Event): diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index 30bbd0b..5af0f9b 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -847,10 +847,17 @@ def wandering_check(session, *, resting: bool = False) -> tuple[list[Event], boo def _boundary_events(session, old_area, new_position) -> list[Event]: + dungeon_id, _, _ = _dungeon_coords(session) level = _level(session) area = level.area_at(new_position) if area is not None and area is not old_area: - return [LocationEnteredEvent(location_kind="area", location_id=area.id, level_number=level.number)] + # Area ids are level-scoped, so the crossing carries the whole triple: + # dungeon, level, area. The other kinds name themselves. + return [ + LocationEnteredEvent( + location_kind="area", location_id=area.id, level_number=level.number, dungeon_id=dungeon_id + ) + ] return [] diff --git a/src/osrlib/crawl/gates.py b/src/osrlib/crawl/gates.py index d7988e7..65dbdb7 100644 --- a/src/osrlib/crawl/gates.py +++ b/src/osrlib/crawl/gates.py @@ -43,6 +43,7 @@ "HasItemCondition", "condition_holds", "first_holder", + "flag_values_equal", ] @@ -65,8 +66,10 @@ class HasItemCondition(BaseModel): class FlagEqualsCondition(BaseModel): """A session flag holds `value` — the lever that opens the portcullis. - The comparison is strict: an absent key equals nothing (`False` included), and - a stored `True` never matches an authored `1`. + The comparison is + [`flag_values_equal`][osrlib.crawl.gates.flag_values_equal] and it is strict: an + absent key equals nothing (`False` included), and a stored `True` never matches + an authored `1`. """ model_config = ConfigDict(frozen=True) @@ -154,13 +157,40 @@ def condition_holds( if isinstance(condition, FlagEqualsCondition): if condition.key not in flags: return False - stored = flags[condition.key] - # `True == 1` in Python, so boolness has to match too: an authored 1 must - # not be satisfied by a flag somebody set to True. - return stored == condition.value and isinstance(stored, bool) == isinstance(condition.value, bool) + return flag_values_equal(flags[condition.key], condition.value) return any(member.id is not None and ledger.active_on(member.id, condition.kind) for member in members) +def flag_values_equal(stored: str | int | bool, expected: str | int | bool) -> bool: + """Compare two flag values the one strict way the engine compares them. + + Equality plus matching boolness. `True == 1` in Python, so a flag somebody set + to `True` must not satisfy an authored `1`, and the reverse: the two are + different values in an authored document even though Python calls them equal. + Every surface that asks "does this flag hold that value" — + [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition] on a gate, an + authored trigger's flag pattern — asks through here, so a condition and a + pattern can never disagree about what equality means. + + Args: + stored: The value the flag store holds (or the value an event reports written). + expected: The value the author wrote. + + Returns: + True when the two are the same value. + + Examples: + ```python + from osrlib.crawl.gates import flag_values_equal + + assert flag_values_equal("open", "open") + assert not flag_values_equal(True, 1) + assert not flag_values_equal(1, True) + ``` + """ + return stored == expected and isinstance(stored, bool) == isinstance(expected, bool) + + def first_holder(members: Sequence[Character], item_id: str) -> Character | None: """The first member in marching order carrying an item with `item_id`. diff --git a/src/osrlib/crawl/interpreter.py b/src/osrlib/crawl/interpreter.py new file mode 100644 index 0000000..69e8bb9 --- /dev/null +++ b/src/osrlib/crawl/interpreter.py @@ -0,0 +1,325 @@ +"""The trigger interpreter: the listener that plays an adventure's authored triggers. + +[`Interpreter`][osrlib.crawl.interpreter.Interpreter] is an ordinary listener the +game registers on its session +([`GameSession.register_listener`][osrlib.crawl.session.GameSession.register_listener]). +It watches the events of every accepted command, matches them against the adventure's +[`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]s, and acts the only way anything +outside the engine may act: by executing ordinary referee commands, each stamped with +the trigger it acted for. + +That discipline is what keeps a triggered game replayable. The interpreter emits no +events of its own and remembers nothing between commands, so a replay — which runs +with no listeners at all — rebuilds the same world by re-executing the same log. Every +effect a trigger has is a command in that log, and every one of those commands says +whose idea it was. +""" + +from collections.abc import Sequence + +from osrlib.core.events import Event +from osrlib.crawl.commands import ( + AddJournalEntry, + AwardXP, + Command, + CommandResult, + GrantCoins, + GrantItem, + MarkTriggerFired, + RecordNote, +) +from osrlib.crawl.events import FlagSetEvent, ItemAcquiredEvent, LocationEnteredEvent, MonsterDefeatedEvent +from osrlib.crawl.gates import condition_holds, flag_values_equal +from osrlib.crawl.session import GameSession +from osrlib.crawl.triggers import ( + FIRST_LIVING_SELECTOR, + PARTY_SELECTOR, + AreaEnteredPattern, + DungeonEnteredPattern, + FlagSetPattern, + ItemAcquiredPattern, + LevelEnteredPattern, + MonsterDefeatedPattern, + TownEnteredPattern, + TriggerPattern, + TriggerSpec, +) + +__all__ = [ + "Interpreter", +] + +_MAX_MATCH_DEPTH = 4 +"""The deepest events a trigger still matches. The events of a player's command are +depth 0, and a firing's own events are one deeper than the event that fired it.""" + + +def _matches_area_entered(pattern: AreaEnteredPattern, event: Event) -> bool: + """The party entered that area, on that level, of that dungeon.""" + return ( + isinstance(event, LocationEnteredEvent) + and event.location_kind == "area" + and event.dungeon_id == pattern.dungeon_id + and event.level_number == pattern.level_number + and event.location_id == pattern.area_id + ) + + +def _matches_level_entered(pattern: LevelEnteredPattern, event: Event) -> bool: + """The party arrived on that level of that dungeon — by stair or by dungeon entry. + + A crossing reports the coarsest boundary it passed, so a party coming in from town + reports a dungeon entry and never a level entry beneath it. Both kinds match here: + arriving on a level is arriving on it however the party got there. + """ + return ( + isinstance(event, LocationEnteredEvent) + and event.location_kind in ("level", "dungeon") + and event.location_id == pattern.dungeon_id + and event.level_number == pattern.level_number + ) + + +def _matches_dungeon_entered(pattern: DungeonEnteredPattern, event: Event) -> bool: + """The party crossed into that dungeon.""" + return ( + isinstance(event, LocationEnteredEvent) + and event.location_kind == "dungeon" + and event.location_id == pattern.dungeon_id + ) + + +def _matches_town_entered(event: Event) -> bool: + """The party arrived in town, whether it walked back or a referee put it there.""" + return isinstance(event, LocationEnteredEvent) and event.location_kind == "town" + + +def _matches_item_acquired(pattern: ItemAcquiredPattern, event: Event, session: GameSession) -> bool: + """A member acquired an item with that catalog id. + + An acquisition names mundane items by catalog id and magic items by their + session-scoped instance id, so a magic id matches by resolving the instance + against the pack it just landed in. + """ + if not isinstance(event, ItemAcquiredEvent): + return False + if pattern.item_id in event.item_ids: + return True + try: + member = session.member(event.character_id) + except ValueError: + return False + for acquired_id in event.item_ids: + instance = member.inventory.magic_item(acquired_id) + if instance is not None and instance.template_id == pattern.item_id: + return True + return False + + +def _matches_monster_defeated(pattern: MonsterDefeatedPattern, event: Event) -> bool: + """A monster of that template was defeated — slain, routed, or surrendered alike.""" + return isinstance(event, MonsterDefeatedEvent) and event.template_id == pattern.template_id + + +def _matches_flag_set(pattern: FlagSetPattern, event: Event) -> bool: + """That flag was written — with that value, or with any value at all. + + The comparison is against the value the write carried, not the value the flag + holds now: a trigger watches the edge, and a consequence earlier in the same batch + may already have written the key again. + """ + if not isinstance(event, FlagSetEvent) or event.key != pattern.key: + return False + return pattern.value is None or flag_values_equal(event.value, pattern.value) + + +def _matches(pattern: TriggerPattern, event: Event, session: GameSession) -> bool: + """Whether one event satisfies one authored pattern. + + Matching reads the event's own facts and never the party's current position: a + consequence can relocate the party mid-batch, while an event keeps describing the + moment it was emitted. + """ + if isinstance(pattern, AreaEnteredPattern): + return _matches_area_entered(pattern, event) + if isinstance(pattern, LevelEnteredPattern): + return _matches_level_entered(pattern, event) + if isinstance(pattern, DungeonEnteredPattern): + return _matches_dungeon_entered(pattern, event) + if isinstance(pattern, TownEnteredPattern): + return _matches_town_entered(event) + if isinstance(pattern, ItemAcquiredPattern): + return _matches_item_acquired(pattern, event, session) + if isinstance(pattern, MonsterDefeatedPattern): + return _matches_monster_defeated(pattern, event) + return _matches_flag_set(pattern, event) + + +class Interpreter: + """Plays an adventure's authored triggers by issuing referee commands. + + Register one, once, on a session that has already been built: + + ```{.python .no-run} + session.register_listener(Interpreter(session)) + ``` + + Registering twice fires everything twice — the same rule every listener follows — + and a session restored from a save needs the registration again, because listeners + are code and a save carries data. Nothing migrates: the interpreter's slot in + `listener_state` is empty and stays empty forever. + + **What it does with a command's events.** It walks them in the order they + happened and, per event, the adventure's triggers in document order. A trigger + matches when its pattern fits the event, its fired-state allows it (once-only + unless `repeatable`), and every one of its conditions holds against session state + right now. A match fires immediately, before the walk moves on, so a later + trigger's conditions see what an earlier firing has already changed. + + **What a firing issues**, all of it stamped `source="trigger:{id}"`: + + 1. [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], carrying the + `fired` beat. The mark goes in first, which is what makes once-only safe + against a trigger whose own consequences would match it again. + 2. The consequences, in authored order, with `@party` and `@first` expanded to the + living members they name — so the log records concrete character ids and + replays exactly. + 3. [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry] when the trigger's + narrative carries a journal form, last, so the beat is stamped with the clock + the consequences left behind. + + **When something does not work out**, the run continues and the log says why. A + rejected consequence is dropped on its own — a spawn that meets an open encounter, + a grant to a character who is not there — and a + [`RecordNote`][osrlib.crawl.commands.RecordNote] records the trigger, the + consequence's position and type, and the rejection. If a wipe mid-cascade ends the + session, the remaining consequences land or drop by the ordinary rules of a + terminal mode. And a cascade is bounded: a trigger's events are one deeper than + the event that fired it, matching stops below depth five, and every firing the + bound suppresses is recorded as a note rather than a mark — so a once-only trigger + cut short here is still fireable later. + + **What it never does.** It returns no events, because everything it causes is + already logged by the commands it executed, and it keeps no memory between + commands. Read what a trigger did from the command log, the journal, and + `session.fired_triggers`, all of which a replay rebuilds. + """ + + key = "osrlib.interpreter" + """The listener key; its state entry exists because registration creates one, and + is the empty dict for the life of the session.""" + + def __init__(self, session: GameSession) -> None: + """Bind the interpreter to the session it watches and issues commands through. + + Args: + session: The session; its adventure's triggers are read once here, being + frozen content. + """ + self._session = session + self._triggers = session.adventure.triggers + self._depth = 0 + + def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dict]: + """Match one command's events and fire what they fired. + + Args: + events: The command's accumulated events, in the order they happened. + state: The listener's state slot, always the empty dict. + + Returns: + No events and the empty state — everything the interpreter does is a + command it executed, and it remembers nothing. + """ + depth = self._depth + for event in events: + for trigger in self._triggers: + if not self._would_fire(trigger, event): + continue + if depth > _MAX_MATCH_DEPTH: + # Evaluated in full and suppressed: no mark, so a once-only + # trigger cut short here stays fireable later. + self._issue( + RecordNote( + text=( + f"trigger {trigger.id}: not fired, the cascade reached " + f"depth {depth} past the limit of {_MAX_MATCH_DEPTH}" + ) + ), + trigger.id, + ) + continue + self._fire(trigger, depth) + return [], {} + + def _would_fire(self, trigger: TriggerSpec, event: Event) -> bool: + """Whether this trigger fires on this event: pattern, fired-state, conditions.""" + if not _matches(trigger.when, event, self._session): + return False + if trigger.id in self._session.fired_triggers and not trigger.repeatable: + return False + return all( + condition_holds( + condition, + members=self._session.party.members, + flags=self._session.flags, + ledger=self._session.ledger, + ) + for condition in trigger.conditions + ) + + def _fire(self, trigger: TriggerSpec, depth: int) -> None: + """Issue one firing's whole batch, one level deeper than the event that fired it.""" + narrative = trigger.narrative + previous = self._depth + self._depth = depth + 1 + try: + self._issue( + MarkTriggerFired(trigger_id=trigger.id, narrative=(narrative.fired or None) if narrative else None), + trigger.id, + ) + for position, consequence in enumerate(trigger.consequences): + for command in self._expand(consequence, trigger, position): + result = self._issue(command, trigger.id) + if not result.accepted: + self._note_drop(trigger, position, command, result.rejections[0].code) + if narrative is not None and narrative.journal: + self._issue(AddJournalEntry(text=narrative.journal), trigger.id) + finally: + self._depth = previous + + def _expand(self, consequence: Command, trigger: TriggerSpec, position: int) -> list[Command]: + """Resolve a consequence's party selector into the commands it stands for. + + A literal character id is not a selector and is passed through untouched: an + id a document could not have known lands as an ordinary rejection, dropped and + noted like any other. + """ + if not isinstance(consequence, GrantItem | GrantCoins | AwardXP): + return [consequence] + living = self._session.party.living_members() + if consequence.character_id == PARTY_SELECTOR: + return [consequence.model_copy(update={"character_id": member.id}) for member in living] + if consequence.character_id == FIRST_LIVING_SELECTOR: + if not living: + self._note_drop(trigger, position, consequence, f"no living member for {FIRST_LIVING_SELECTOR}") + return [] + return [consequence.model_copy(update={"character_id": living[0].id})] + return [consequence] + + def _note_drop(self, trigger: TriggerSpec, position: int, command: Command, reason: str) -> None: + """Record a consequence that did not land, from the facts alone.""" + self._issue( + RecordNote( + text=f"trigger {trigger.id}: consequence {position} ({command.command_type}) dropped ({reason})" + ), + trigger.id, + ) + + def _issue(self, command: Command, trigger_id: str) -> CommandResult: + """Execute one command on the trigger's behalf, stamped with its id. + + Commands are frozen, so the stamp is a copy — the authored consequence in the + document is never touched. + """ + return self._session.execute(command.model_copy(update={"source": f"trigger:{trigger_id}"})) diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index fe72234..2c2c4a1 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -234,9 +234,20 @@ class Listener(Protocol): """The extension-point protocol: games register listeners on the session. Listeners never mutate game state — they react by executing ordinary - commands. `handle` receives the accumulated events of the command (earlier - listeners' included) and the listener's own state snapshot, and returns the - events to append plus the new state (snapshotted into saves under `key`). + commands. `handle` receives the accumulated events of the command (the events + earlier listeners authored included) and the listener's own state snapshot, and + returns the events to append plus the new state (snapshotted into saves under + `key`). + + A listener that reacts by executing commands returns no events: those commands + logged their own, and the result envelope picks them up from the log. Returning + them again would log them twice. The returned list is for events a listener + *authors* directly. + + Every listener sees every event exactly once. A nested command runs the whole + listener loop itself, so the events it produced reach each listener through that + nested dispatch and are never dispatched again at the outer level — only the + caller's result envelope gathers them up a second time. """ key: str @@ -408,6 +419,11 @@ def execute(self, command: Command) -> CommandResult: whatever killed the party, and from whichever mode. A session already in a terminal mode is left alone. + The result carries everything the command caused, in event-log order: the + handler's own events, then, per listener in registration order, the events + of the commands that listener executed — however deeply nested — followed by + the events it authored itself. + Args: command: The command to execute. @@ -469,14 +485,30 @@ def execute(self, command: Command) -> CommandResult: if self._record_deaths(events): events.extend(self._end_on_party_wipe()) self.event_log.extend(events) + # Two lists with two jobs. `accumulated` is what the listeners are dispatched + # over: this command's own events plus what earlier listeners *authored*. A + # listener's nested commands ran the whole listener loop themselves, so every + # listener has already seen those events at the nested level; putting them in + # here would deliver them to later listeners a second time. `envelope` is what + # the caller gets back, and it does take them — a front end reads the result + # once and wants the whole chain. accumulated = list(events) + envelope = list(events) + self._persist_sight() for listener in self.listeners: + mark = len(self.event_log) emitted, state = listener.handle(tuple(accumulated), self.listener_state.get(listener.key, {})) self.listener_state[listener.key] = state + # Everything the listener's own commands logged while it ran — their + # events and any deeper listener reactions, each already in the log + # exactly once, in log order — then the events it authored itself. + # (The log holds serialized entries only for a session restored from a + # save; nothing executing appends one.) + envelope.extend(entry for entry in self.event_log[mark:] if isinstance(entry, Event)) + envelope.extend(emitted) accumulated.extend(emitted) self.event_log.extend(emitted) - self._persist_sight() - return CommandResult(accepted=True, events=tuple(accumulated)) + return CommandResult(accepted=True, events=tuple(envelope)) def _persist_sight(self) -> None: """Fold the party's current light reveal into the seen map memory. @@ -487,6 +519,13 @@ def _persist_sight(self) -> None: [`mark_seen`][osrlib.crawl.dungeon.DungeonState.mark_seen] with what `_light_reveal` shows from the party's cell. Rejected commands change no state, so they never reach it. + + It runs *before* the listeners, so that the map a live session remembers is + the map a replay rebuilds. A listener that relocates the party — an authored + teleport — executes its own command, which folds its own destination in + turn; folding this command's reveal afterwards instead would fold the + destination's view over the move the party actually made, while a replay, + running the same commands with no listeners, folds both in order. """ from osrlib.crawl.exploration import _light_reveal @@ -975,7 +1014,7 @@ def _handle_mark_trigger_fired(session: GameSession, command: MarkTriggerFired) # marked and the log is the record of each one. if command.trigger_id not in session.fired_triggers: session.fired_triggers.append(command.trigger_id) - return [], [TriggerFiredEvent(trigger_id=command.trigger_id)] + return [], [TriggerFiredEvent(trigger_id=command.trigger_id, narrative=command.narrative)] def _handle_add_journal_entry(session: GameSession, command: AddJournalEntry) -> tuple[list[Rejection], list[Event]]: diff --git a/src/osrlib/crawl/triggers.py b/src/osrlib/crawl/triggers.py new file mode 100644 index 0000000..c17ad97 --- /dev/null +++ b/src/osrlib/crawl/triggers.py @@ -0,0 +1,252 @@ +"""Authored triggers: the observable-event patterns and the trigger spec. + +A trigger is an authored binding from an observable event pattern, optionally +gated by conditions, to referee-command consequences. Where a gate +([`GateSpec`][osrlib.crawl.gates.GateSpec]) is level-triggered — evaluated live at +the moment the party attempts something — a trigger is edge-triggered: it watches +the events a command produced and fires on the crossing itself. The lever that +opens the portcullis is a trigger; the door that wants the brass key is a gate. + +The pieces: + +- A **pattern** ([`TriggerPattern`][osrlib.crawl.triggers.TriggerPattern]) names the + observable: a location crossed, an item acquired, a monster defeated, a flag + written. Patterns are matched against the events themselves, never against + current state, because a consequence can move the party mid-batch and an event + still carries the facts of the moment it described. +- **Conditions** ([`ConditionSpec`][osrlib.crawl.gates.ConditionSpec]) narrow the + firing further, all of them evaluated live against session state at match time. + A trigger fires, it does not take: a condition with `consumes=True` is rejected + at parse, because a trigger reacts to something that has already happened and has + no attempt of its own to charge a toll against. +- **Consequences** ([`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand]) + are the referee commands the firing issues, in authored order. +- A **narrative block** ([`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock]) + carries the trigger's text: `fired` is the referee's beat, `journal` is the + players' — a trigger that should say something to the table authors a journal + form. + +Document order is the order of the [`Adventure.triggers`][osrlib.crawl.adventure.Adventure] +tuple: triggers matching one event fire in that order, and a trigger's consequences +execute in authored order. +""" + +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from osrlib.crawl.commands import ConsequenceCommand +from osrlib.crawl.gates import ConditionSpec +from osrlib.crawl.narrative import NarrativeBlock + +__all__ = [ + "FIRST_LIVING_SELECTOR", + "PARTY_SELECTOR", + "AreaEnteredPattern", + "DungeonEnteredPattern", + "FlagSetPattern", + "ItemAcquiredPattern", + "LevelEnteredPattern", + "MonsterDefeatedPattern", + "TownEnteredPattern", + "TriggerPattern", + "TriggerSpec", +] + +PARTY_SELECTOR = "@party" +"""The `character_id` an authored consequence writes to address the whole party. + +Expanded at issue time to one command per *living* member, in marching order, so the +command log stays fully concrete and replays exactly. A party with nobody left +standing expands to no commands at all — a reward for the dead is nothing, not an +error.""" + +FIRST_LIVING_SELECTOR = "@first" +"""The `character_id` an authored consequence writes to address one member. + +Expanded at issue time to the first living member in marching order — the +treasure-recipient convention. With nobody standing there is no recipient, and the +consequence is dropped with a note rather than guessed at.""" + + +class AreaEnteredPattern(BaseModel): + """The party entered a keyed area. + + Area ids are level-scoped, so the pattern names the whole triple: an `id` of + `"crypt"` means nothing without the dungeon and level it belongs to. + """ + + model_config = ConfigDict(frozen=True) + + pattern_type: Literal["area_entered"] = "area_entered" + dungeon_id: str = Field(min_length=1) + level_number: int = Field(ge=1) + area_id: str = Field(min_length=1) + + +class LevelEnteredPattern(BaseModel): + """The party arrived on a dungeon level. + + However it got there: a stair between levels and an entry from town both land + the party on the level, and both match. (The engine reports the coarser crossing + when a move changes dungeons, and a dungeon crossing is a level arrival too.) + """ + + model_config = ConfigDict(frozen=True) + + pattern_type: Literal["level_entered"] = "level_entered" + dungeon_id: str = Field(min_length=1) + level_number: int = Field(ge=1) + + +class DungeonEnteredPattern(BaseModel): + """The party crossed into a dungeon — from town, or from another dungeon.""" + + model_config = ConfigDict(frozen=True) + + pattern_type: Literal["dungeon_entered"] = "dungeon_entered" + dungeon_id: str = Field(min_length=1) + + +class TownEnteredPattern(BaseModel): + """The party arrived in the base town, however it got there. + + An adventure has one town, so the pattern needs no fields: the homecoming beat + fires on the return trip and on a referee's placement alike. + """ + + model_config = ConfigDict(frozen=True) + + pattern_type: Literal["town_entered"] = "town_entered" + + +class ItemAcquiredPattern(BaseModel): + """A party member acquired an item with `item_id`. + + The id domain is the one `has_item` reads: the effective equipment catalog + (shipped ∪ adventure-bundled) or the magic-item catalog. Acquisitions report + mundane items by catalog id and magic items by their session-scoped instance id, + so a magic `item_id` matches by resolving that instance against the acquiring + character's inventory. + """ + + model_config = ConfigDict(frozen=True) + + pattern_type: Literal["item_acquired"] = "item_acquired" + item_id: str = Field(min_length=1) + + +class MonsterDefeatedPattern(BaseModel): + """A monster of `template_id` was defeated — slain, routed, or surrendered. + + Every outcome is a defeat, so the pattern does not filter on one. Defeats are + reported at battle end, so the boss falling opens the portcullis after the + fighting stops, never mid-round. + """ + + model_config = ConfigDict(frozen=True) + + pattern_type: Literal["monster_defeated"] = "monster_defeated" + template_id: str = Field(min_length=1) + + +class FlagSetPattern(BaseModel): + """A session flag was written — the edge, not the state. + + The match is against the value the write carried, so a flag rewritten with the + value it already held still fires. `value=None` matches any written value, which + is unambiguous because a flag value is a `str`, an `int`, or a `bool` and never + `None`; an authored value compares through + [`flag_values_equal`][osrlib.crawl.gates.flag_values_equal], the same strict + comparison [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition] uses. + """ + + model_config = ConfigDict(frozen=True) + + pattern_type: Literal["flag_set"] = "flag_set" + key: str = Field(min_length=1) + value: str | int | bool | None = None + + +TriggerPattern = Annotated[ + AreaEnteredPattern + | LevelEnteredPattern + | DungeonEnteredPattern + | TownEnteredPattern + | ItemAcquiredPattern + | MonsterDefeatedPattern + | FlagSetPattern, + Field(discriminator="pattern_type"), +] +"""The pattern union, discriminated on `pattern_type`. New observables join it +additively; the discriminator values are wire values and serialize into every +document that carries a trigger.""" + + +class TriggerSpec(BaseModel): + """One authored trigger: when it fires, what must hold, and what happens. + + Once-only by default — the fired-mark that + [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired] writes is session + state, so once-only survives a save, a load, and a replay. `repeatable=True` is + the authored opt-in for a trigger that fires every time its pattern matches. + + `conditions` all have to hold: the tuple is an AND with no combinators, each + condition evaluated live at the moment of the match. `consequences` may be empty + — a trigger whose whole job is its journal beat is a normal shape. + + Examples: + ```python + from osrlib.crawl.commands import SetDoorState + from osrlib.crawl.dungeon import Direction + from osrlib.crawl.narrative import NarrativeBlock + from osrlib.crawl.triggers import FlagSetPattern, TriggerSpec + + portcullis = SetDoorState(dungeon_id="crypt", level_number=1, x=2, y=0, direction=Direction.SOUTH, open=True) + trigger = TriggerSpec( + id="portcullis-rises", + when=FlagSetPattern(key="crypt.lever", value="pulled"), + consequences=(portcullis,), + narrative=NarrativeBlock( + fired="The counterweight drops somewhere in the wall.", + journal="The east lever gives; below, a portcullis grinds upward.", + ), + ) + assert not trigger.repeatable + ``` + """ + + model_config = ConfigDict(frozen=True) + + id: str = Field(min_length=1) + when: TriggerPattern + conditions: tuple[ConditionSpec, ...] = () + repeatable: bool = False + consequences: tuple[ConsequenceCommand, ...] = () + narrative: NarrativeBlock | None = None + + @model_validator(mode="after") + def _conditions_never_consume(self) -> TriggerSpec: + """A trigger's conditions are tests, never tolls. + + Consumption is an effect of a *successful command*, reported through that + command's events. A trigger observes an event that has already happened, so a + toll here would have nothing to charge against. + """ + for condition in self.conditions: + if getattr(condition, "consumes", False): + raise ValueError("a trigger condition cannot consume: a trigger fires, it does not take") + return self + + @model_validator(mode="after") + def _consequences_carry_no_source(self) -> TriggerSpec: + """The `source` stamp belongs to whoever issues the command, not the document. + + Consequences are issued stamped with the trigger's own id, so an authored + stamp would either be overwritten or, worse, believed — a line in the log + claiming a provenance nothing produced. + """ + for position, consequence in enumerate(self.consequences): + if consequence.source is not None: + raise ValueError(f"consequence {position} carries a source; the issuing trigger stamps it") + return self diff --git a/tests/crawl_fixtures.py b/tests/crawl_fixtures.py index a5d73c6..38261f3 100644 --- a/tests/crawl_fixtures.py +++ b/tests/crawl_fixtures.py @@ -23,6 +23,7 @@ from osrlib.core.character import Character from osrlib.core.items import Coins, GearTemplate from osrlib.crawl.adventure import Adventure, TownSpec +from osrlib.crawl.commands import SetDoorState, SpawnMonsters from osrlib.crawl.dungeon import ( AreaSpec, AreaTreasureSpec, @@ -45,6 +46,7 @@ from osrlib.crawl.gates import GateSpec, HasItemCondition from osrlib.crawl.narrative import NarrativeBlock from osrlib.crawl.party import Party +from osrlib.crawl.triggers import AreaEnteredPattern, FlagSetPattern, TriggerSpec from osrlib.data import load_classes __all__ = [ @@ -52,6 +54,10 @@ "GATE_SEAL", "GATE_SIGIL", "GATE_TOKEN", + "LEVER_KEY", + "PORTCULLIS_CRANK", + "PORTCULLIS_FIRED", + "PORTCULLIS_JOURNAL", "STOCK_ROSTER", "build_adventure", "build_blade_adventure", @@ -62,6 +68,7 @@ "build_lethal_coffer_adventure", "build_open_door_adventure", "build_party", + "build_portcullis_adventure", "build_sightline_adventure", ] @@ -364,6 +371,93 @@ def build_gated_adventure() -> Adventure: ) +PORTCULLIS_CRANK = GearTemplate(id="portcullis_crank", name="Portcullis crank", cost_gp=0) +"""The portcullis gate's requirement: a real item the keep never places, so the +grille answers nothing but the lever's trigger.""" + +LEVER_KEY = "keep.lever" +"""The flag the lever writes — the portcullis trigger's pattern.""" + +PORTCULLIS_FIRED = "Chain rattles in the wall; the counterweight drops." +"""The portcullis trigger's referee beat.""" + +PORTCULLIS_JOURNAL = "The east lever gives, and the portcullis grinds up into its slot." +"""The portcullis trigger's journal form — the players' side of the same moment.""" + + +def build_portcullis_adventure() -> Adventure: + """Build the one-level keep: a lever, a gated portcullis, and a guarded room. + + Level 1 (5 × 1), entrance (0,0): + + ```text + x0 x1 x2 ‖ x3 x4 + y0 ENT————corr———corr—D—corr———[guardroom] + ``` + + - The door east of (2,0) is the portcullis: it requires a crank nothing in the + keep holds, so nobody opens it by hand. + - `portcullis-rises` fires when the lever flag is written and sets that door + open, with a referee beat and a journal form. + - `guard-ambush` fires when the party steps into `guardroom` at (4,0) — which + keeps two goblins of its own, so the trigger's spawn meets an encounter that + is already open. + """ + gate = GateSpec( + condition=HasItemCondition(item_id="portcullis_crank"), + narrative=NarrativeBlock(refusal="The portcullis is a grille of iron. It has no handle on this side."), + ) + edges: dict[str, Edge] = {} + _open(edges, (0, 0), Direction.EAST) + _open(edges, (1, 0), Direction.EAST) + _door(edges, (2, 0), Direction.EAST, requires=gate) + _open(edges, (3, 0), Direction.EAST) + level = LevelSpec( + number=1, + width=5, + height=1, + edges=edges, + areas=( + AreaSpec( + id="guardroom", + name="Guardroom", + description="Two goblins at a table of scarred oak.", + cells=((4, 0),), + encounter=KeyedEncounter(monsters=(KeyedMonster(template_id="goblin", count_fixed=2),), aware=True), + ), + ), + entrance=(0, 0), + wandering=WanderingSpec(chance_in_six=0), + ) + triggers = ( + TriggerSpec( + id="portcullis-rises", + when=FlagSetPattern(key=LEVER_KEY, value="pulled"), + consequences=( + SetDoorState(dungeon_id="keep", level_number=1, x=2, y=0, direction=Direction.EAST, open=True), + ), + narrative=NarrativeBlock(fired=PORTCULLIS_FIRED, journal=PORTCULLIS_JOURNAL), + ), + TriggerSpec( + id="guard-ambush", + when=AreaEnteredPattern(dungeon_id="keep", level_number=1, area_id="guardroom"), + consequences=(SpawnMonsters(template_id="goblin", count_fixed=1, distance_feet=30),), + narrative=NarrativeBlock( + fired="A third goblin was meant to drop from the rafters.", + journal="Something moved in the rafters of the guardroom.", + ), + ), + ) + return Adventure( + name="The Lever Keep", + description="A keep whose one door answers a lever and nothing else.", + town=TownSpec(name="Threshold", travel_turns={"keep": 1}), + dungeons=(DungeonSpec(id="keep", name="The Keep", levels=(level,)),), + items=(PORTCULLIS_CRANK,), + triggers=triggers, + ) + + def build_open_door_adventure() -> Adventure: """Build the one-level pair of cells joined by an authored-open door. diff --git a/tests/generate_phase14_goldens.py b/tests/generate_phase14_goldens.py new file mode 100644 index 0000000..d777548 --- /dev/null +++ b/tests/generate_phase14_goldens.py @@ -0,0 +1,168 @@ +"""Generate the phase 14 golden: the lever, the portcullis, and the ambush that missed. + +One golden file, `phase14_triggers.json` — a scripted delve through a keep whose +wiring is authored data and whose only actor is the registered interpreter: + +- the portcullis wants a crank nobody has, so the party's probe is refused with the + gate's authored text and costs nothing; +- a game-issued `SetFlag` pulls the lever, and the trigger watching that key marks + itself, sets the door open, and writes its journal beat — all in the result of the + player's own command; +- the party walks through the grille that now stands open; +- stepping into the guardroom opens the room's own keyed encounter, and the + area-entered trigger's spawn arrives to find it already open: the consequence is + dropped alone and a note says exactly why, while the trigger's journal beat still + lands. + +The milestone the file records: the scenario authored as data, live with the +interpreter registered, replays identically with no listeners — command and event logs +byte-equal, every state block equal but the interpreter's provably empty listener slot. + +Run `uv run python tests/generate_phase14_goldens.py` and explain any golden change in +the commit message. +""" + +import json +from pathlib import Path + +from crawl_fixtures import LEVER_KEY, build_party, build_portcullis_adventure +from osrlib.core.events import Event +from osrlib.crawl.commands import Command, EnterDungeon, MoveParty, OpenDoor, SetFlag, parse_command +from osrlib.crawl.dungeon import Direction +from osrlib.crawl.interpreter import Interpreter +from osrlib.crawl.session import GameSession +from osrlib.messages import format_message + +GOLDEN_PATH = Path(__file__).parent / "goldens" / "phase14_triggers.json" +SEED = 20_260_808 + +PORTCULLIS = "trigger:portcullis-rises" +AMBUSH = "trigger:guard-ambush" + +# Each step is a command and the rejection code it must draw — `None` for the commands +# that must be accepted. The one refusal is the probe: it leaves no trace in either log. +SCRIPT: tuple[tuple[Command, str | None], ...] = ( + (EnterDungeon(dungeon_id="keep"), None), + (MoveParty(direction=Direction.EAST), None), + (MoveParty(direction=Direction.EAST), None), + # The grille has no handle on this side, and the crank is nowhere in the keep. + (OpenDoor(direction=Direction.EAST), "exploration.door.gate_refused"), + # The lever: a flag the game writes, and the trigger that watches it. + (SetFlag(key=LEVER_KEY, value="pulled"), None), + (MoveParty(direction=Direction.EAST), None), + # Into the guardroom, whose goblins open an encounter before the trigger can spawn one. + (MoveParty(direction=Direction.EAST), None), +) + + +def new_session(seed: int, *, listening: bool) -> GameSession: + """The scenario's session; `listening` decides whether the interpreter plays.""" + session = GameSession.new(build_party(), build_portcullis_adventure(), seed=seed) + if listening: + session.register_listener(Interpreter(session)) + return session + + +def snapshot(session: GameSession) -> dict: + """The end state: draws, time, the door the trigger opened, and the trigger blocks.""" + return { + "streams": {key: state.model_dump(mode="json") for key, state in session.streams.export_states().items()}, + "clock_rounds": session.clock.rounds, + "mode": session.mode.value, + "location": session.dungeon_state.location.model_dump(mode="json"), + "doors": {ref: state.model_dump(mode="json") for ref, state in session.dungeon_state.doors.items()}, + "flags": dict(session.flags), + "fired_triggers": list(session.fired_triggers), + "journal": [entry.model_dump(mode="json") for entry in session.journal], + } + + +def run_scenario(seed: int) -> tuple[GameSession, list[dict]]: + """Play the script with the interpreter registered, recording the gate probe. + + Returns: + The finished session and the recorded refusals, each carrying the position in + the accepted-command log where it happened. + + Raises: + RuntimeError: If a command draws the wrong answer, if a refusal changes state, + or if the milestone's beats are not all in the run. + """ + session = new_session(seed, listening=True) + refusals: list[dict] = [] + for command, expected in SCRIPT: + before = snapshot(session) + result = session.execute(command) + if expected is None: + if not result.accepted: + codes = [rejection.code for rejection in result.rejections] + raise RuntimeError(f"{command.command_type} was refused with {codes}") + continue + if result.accepted: + raise RuntimeError(f"{command.command_type} was accepted where {expected} was expected") + codes = [rejection.code for rejection in result.rejections] + if codes != [expected]: + raise RuntimeError(f"{command.command_type} drew {codes}, expected [{expected!r}]") + if snapshot(session) != before: + raise RuntimeError(f"the refused {command.command_type} changed session state") + refusals.append( + { + "after_commands": len(session.command_log), + "command": command.model_dump(mode="json"), + "code": result.rejections[0].code, + "params": dict(result.rejections[0].params), + } + ) + if session.fired_triggers != ["portcullis-rises", "guard-ambush"]: + raise RuntimeError(f"both triggers must fire, in order: {session.fired_triggers}") + if len(session.journal) != 2: + raise RuntimeError(f"the run wrote {len(session.journal)} journal entries, not 2") + if session.listener_state != {Interpreter.key: {}}: + raise RuntimeError(f"the interpreter kept state: {session.listener_state}") + if not any(command.command_type == "record_note" for command in session.command_log): + raise RuntimeError("the colliding spawn recorded no note") + if any(command.command_type == "spawn_monsters" for command in session.command_log): + raise RuntimeError("the colliding spawn was accepted; it must be dropped") + return session, refusals + + +def replay_scenario(seed: int, commands) -> GameSession: + """Replay the accepted-command log with no listeners at all — the milestone's proof.""" + session = new_session(seed, listening=False) + for entry in commands: + command = entry if isinstance(entry, Command) else parse_command(entry) + result = session.execute(command) + if not result.accepted: + codes = [rejection.code for rejection in result.rejections] + raise RuntimeError(f"replay diverged: {command.command_type} rejected with {codes}") + return session + + +def build_golden(seed: int) -> dict: + session, refusals = run_scenario(seed) + transcript = [format_message(entry) for entry in session.event_log if isinstance(entry, Event)] + return { + "master_seed": seed, + "refusals": refusals, + "command_log": [command.model_dump(mode="json") for command in session.command_log], + "event_log": [ + entry if isinstance(entry, dict) else entry.model_dump(mode="json") for entry in session.event_log + ], + "transcript": transcript, + "final_state": snapshot(session), + } + + +def write(path: Path, golden: dict) -> None: + path.write_text(json.dumps(golden, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def main() -> None: + golden = build_golden(SEED) + write(GOLDEN_PATH, golden) + commands, fired = len(golden["command_log"]), len(golden["final_state"]["fired_triggers"]) + print(f"wrote {GOLDEN_PATH} from seed {SEED} ({commands} commands, {fired} triggers fired)") + + +if __name__ == "__main__": + main() diff --git a/tests/goldens/phase11_gates.json b/tests/goldens/phase11_gates.json index 81fd174..63f9e5f 100644 --- a/tests/goldens/phase11_gates.json +++ b/tests/goldens/phase11_gates.json @@ -131,6 +131,7 @@ "event_log": [ { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": 1, "location_id": "warren", @@ -213,6 +214,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": 2, "location_id": "warren", @@ -256,6 +258,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": 1, "location_id": "warren", @@ -327,6 +330,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "warren", "event_type": "location_entered", "level_number": 1, "location_id": "reliquary", diff --git a/tests/goldens/phase12_wipe.json b/tests/goldens/phase12_wipe.json index 92f20ec..8e78982 100644 --- a/tests/goldens/phase12_wipe.json +++ b/tests/goldens/phase12_wipe.json @@ -25,6 +25,7 @@ "event_log": [ { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": 1, "location_id": "vault", @@ -42,6 +43,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "vault", "event_type": "location_entered", "level_number": 1, "location_id": "gas_room", diff --git a/tests/goldens/phase13_journal.json b/tests/goldens/phase13_journal.json index 54a0258..68a2885 100644 --- a/tests/goldens/phase13_journal.json +++ b/tests/goldens/phase13_journal.json @@ -12,6 +12,7 @@ }, { "command_type": "mark_trigger_fired", + "narrative": null, "source": "trigger:lever-east", "trigger_id": "lever-east" }, @@ -33,6 +34,7 @@ }, { "command_type": "mark_trigger_fired", + "narrative": null, "source": "trigger:lever-east", "trigger_id": "lever-east" }, @@ -57,6 +59,7 @@ "event_log": [ { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": 1, "location_id": "delve", @@ -75,6 +78,7 @@ { "code": "session.trigger.fired", "event_type": "trigger_fired", + "narrative": null, "trigger_id": "lever-east", "visibility": "referee" }, @@ -101,6 +105,7 @@ { "code": "session.trigger.fired", "event_type": "trigger_fired", + "narrative": null, "trigger_id": "lever-east", "visibility": "referee" }, diff --git a/tests/goldens/phase14_triggers.json b/tests/goldens/phase14_triggers.json new file mode 100644 index 0000000..49b2a93 --- /dev/null +++ b/tests/goldens/phase14_triggers.json @@ -0,0 +1,340 @@ +{ + "command_log": [ + { + "command_type": "enter_dungeon", + "dungeon_id": "keep", + "source": null + }, + { + "command_type": "move_party", + "direction": "east", + "source": null + }, + { + "command_type": "move_party", + "direction": "east", + "source": null + }, + { + "command_type": "set_flag", + "key": "keep.lever", + "source": null, + "value": "pulled" + }, + { + "command_type": "mark_trigger_fired", + "narrative": "Chain rattles in the wall; the counterweight drops.", + "source": "trigger:portcullis-rises", + "trigger_id": "portcullis-rises" + }, + { + "command_type": "set_door_state", + "direction": "east", + "discovered": null, + "dungeon_id": "keep", + "level_number": 1, + "open": true, + "source": "trigger:portcullis-rises", + "unlocked": null, + "wedged": null, + "x": 2, + "y": 0 + }, + { + "command_type": "add_journal_entry", + "source": "trigger:portcullis-rises", + "text": "The east lever gives, and the portcullis grinds up into its slot." + }, + { + "command_type": "move_party", + "direction": "east", + "source": null + }, + { + "command_type": "move_party", + "direction": "east", + "source": null + }, + { + "command_type": "mark_trigger_fired", + "narrative": "A third goblin was meant to drop from the rafters.", + "source": "trigger:guard-ambush", + "trigger_id": "guard-ambush" + }, + { + "command_type": "record_note", + "source": "trigger:guard-ambush", + "text": "trigger guard-ambush: consequence 0 (spawn_monsters) dropped (session.command.encounter_in_progress)" + }, + { + "command_type": "add_journal_entry", + "source": "trigger:guard-ambush", + "text": "Something moved in the rafters of the guardroom." + } + ], + "event_log": [ + { + "code": "exploration.location.entered", + "dungeon_id": null, + "event_type": "location_entered", + "level_number": 1, + "location_id": "keep", + "location_kind": "dungeon", + "narrative": null, + "visibility": "player" + }, + { + "code": "exploration.party.moved", + "event_type": "party_moved", + "facing": "east", + "visibility": "player", + "x": 1, + "y": 0 + }, + { + "code": "exploration.party.moved", + "event_type": "party_moved", + "facing": "east", + "visibility": "player", + "x": 2, + "y": 0 + }, + { + "code": "session.flag.set", + "event_type": "flag_set", + "key": "keep.lever", + "value": "pulled", + "visibility": "referee" + }, + { + "code": "session.trigger.fired", + "event_type": "trigger_fired", + "narrative": "Chain rattles in the wall; the counterweight drops.", + "trigger_id": "portcullis-rises", + "visibility": "referee" + }, + { + "character_id": null, + "code": "exploration.door.opened", + "direction": "east", + "event_type": "door", + "narrative": null, + "visibility": "referee", + "x": 2, + "y": 0 + }, + { + "code": "session.journal.entry_added", + "event_type": "journal_entry_added", + "rounds": 60, + "text": "The east lever gives, and the portcullis grinds up into its slot.", + "visibility": "player" + }, + { + "code": "exploration.party.moved", + "event_type": "party_moved", + "facing": "east", + "visibility": "player", + "x": 3, + "y": 0 + }, + { + "code": "exploration.party.moved", + "event_type": "party_moved", + "facing": "east", + "visibility": "player", + "x": 4, + "y": 0 + }, + { + "code": "exploration.location.entered", + "dungeon_id": "keep", + "event_type": "location_entered", + "level_number": 1, + "location_id": "guardroom", + "location_kind": "area", + "narrative": null, + "visibility": "player" + }, + { + "cache_ref": "cache-0001", + "code": "treasure.hoard.generated", + "coins_gp_value": 20, + "event_type": "hoard_generated", + "magic_item_ids": [], + "treasure_types": [ + "C" + ], + "valuable_ids": [], + "visibility": "referee" + }, + { + "code": "encounter.surprise.rolled", + "event_type": "surprise_rolled", + "roll": null, + "side": "monsters", + "surprised": false, + "threshold": 2, + "visibility": "referee" + }, + { + "code": "encounter.surprise.rolled", + "event_type": "surprise_rolled", + "roll": 3, + "side": "party", + "surprised": true, + "threshold": 3, + "visibility": "referee" + }, + { + "code": "encounter.started", + "count": 2, + "distance_feet": 40, + "event_type": "encounter_started", + "monster_name": "Goblin", + "monsters_surprised": false, + "party_surprised": true, + "visibility": "player" + }, + { + "code": "encounter.reaction.rolled", + "event_type": "reaction_rolled", + "modifier": 0, + "result": "uncertain", + "roll": 7, + "total": 7, + "visibility": "referee" + }, + { + "code": "encounter.stance.changed", + "event_type": "stance_changed", + "stance": "uncertain", + "visibility": "player" + }, + { + "code": "encounter.reaction.rolled", + "event_type": "reaction_rolled", + "modifier": 0, + "result": "uncertain", + "roll": 7, + "total": 7, + "visibility": "referee" + }, + { + "code": "session.trigger.fired", + "event_type": "trigger_fired", + "narrative": "A third goblin was meant to drop from the rafters.", + "trigger_id": "guard-ambush", + "visibility": "referee" + }, + { + "code": "session.note.recorded", + "event_type": "note_recorded", + "text": "trigger guard-ambush: consequence 0 (spawn_monsters) dropped (session.command.encounter_in_progress)", + "visibility": "referee" + }, + { + "code": "session.journal.entry_added", + "event_type": "journal_entry_added", + "rounds": 61, + "text": "Something moved in the rafters of the guardroom.", + "visibility": "player" + } + ], + "final_state": { + "clock_rounds": 61, + "doors": { + "keep:1:3,0:west": { + "discovered": false, + "open": true, + "opened_by_party": false, + "unlocked": false, + "wedged": false + } + }, + "fired_triggers": [ + "portcullis-rises", + "guard-ambush" + ], + "flags": { + "keep.lever": "pulled" + }, + "journal": [ + { + "rounds": 60, + "text": "The east lever gives, and the portcullis grinds up into its slot." + }, + { + "rounds": 61, + "text": "Something moved in the rafters of the guardroom." + } + ], + "location": { + "dungeon_id": "keep", + "facing": "east", + "kind": "dungeon", + "level_number": 1, + "position": [ + 4, + 0 + ] + }, + "mode": "encounter", + "streams": { + "effects": { + "inc": 180038854346277849538965952521149149593, + "state": 16146687859000645448365573807897689872 + }, + "encounter": { + "inc": 294016909799287458598601567891839646743, + "state": 195005871530580866225427265757904205789 + }, + "monster_spawn": { + "inc": 122248844825476364257671620342058704215, + "state": 288998010088381104956447344210552317964 + }, + "treasure": { + "inc": 241878447094209998521488183906797731573, + "state": 271297742964503649748437485890025818459 + } + } + }, + "master_seed": 20260808, + "refusals": [ + { + "after_commands": 3, + "code": "exploration.door.gate_refused", + "command": { + "command_type": "open_door", + "direction": "east", + "source": null + }, + "params": { + "direction": "east", + "refusal": "The portcullis is a grille of iron. It has no handle on this side." + } + } + ], + "transcript": [ + "The party enters dungeon keep (level 1).", + "The party moves to (1, 0), facing east.", + "The party moves to (2, 0), facing east.", + "Flag keep.lever = 'pulled'.", + "Trigger portcullis-rises fired. Chain rattles in the wall; the counterweight drops.", + "The door east of (2, 0) opens.", + "Journal: The east lever gives, and the portcullis grinds up into its slot.", + "The party moves to (3, 0), facing east.", + "The party moves to (4, 0), facing east.", + "The party enters area guardroom (level 1).", + "Treasure generated at cache-0001 (C): 20 gp in coin, 0 valuable(s), 0 magic item(s).", + "Surprise (monsters): no roll, surprised on 1-2 — not surprised.", + "Surprise (party): rolled 3, surprised on 1-3 — surprised.", + "Encounter: 2 × Goblin at 40' — the party is surprised.", + "Reaction roll: 7+0 = 7 — uncertain.", + "The monsters' bearing: uncertain.", + "Reaction roll: 7+0 = 7 — uncertain.", + "Trigger guard-ambush fired. A third goblin was meant to drop from the rafters.", + "Referee note: trigger guard-ambush: consequence 0 (spawn_monsters) dropped (session.command.encounter_in_progress)", + "Journal: Something moved in the rafters of the guardroom." + ] +} diff --git a/tests/goldens/phase4_delve.json b/tests/goldens/phase4_delve.json index dfc827f..d6337ba 100644 --- a/tests/goldens/phase4_delve.json +++ b/tests/goldens/phase4_delve.json @@ -2171,6 +2171,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": 1, "location_id": "halls", @@ -2261,6 +2262,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "halls", "event_type": "location_entered", "level_number": 1, "location_id": "pit_hall", @@ -2404,6 +2406,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "halls", "event_type": "location_entered", "level_number": 1, "location_id": "guard_room", @@ -4327,6 +4330,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": 2, "location_id": "halls", @@ -4362,6 +4366,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "halls", "event_type": "location_entered", "level_number": 2, "location_id": "crypt", @@ -5011,6 +5016,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "halls", "event_type": "location_entered", "level_number": 2, "location_id": "kennel_a", @@ -5173,6 +5179,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "halls", "event_type": "location_entered", "level_number": 2, "location_id": "kennel_b", @@ -6029,6 +6036,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": 1, "location_id": "halls", @@ -6054,6 +6062,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "halls", "event_type": "location_entered", "level_number": 1, "location_id": "guard_room", @@ -8077,6 +8086,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": null, "location_id": "town", diff --git a/tests/goldens/phase5_milestone.json b/tests/goldens/phase5_milestone.json index 5b251ae..4069f69 100644 --- a/tests/goldens/phase5_milestone.json +++ b/tests/goldens/phase5_milestone.json @@ -984,6 +984,7 @@ "event_log": [ { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": 1, "location_id": "barrow", @@ -1009,6 +1010,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "barrow", "event_type": "location_entered", "level_number": 1, "location_id": "guard_room", @@ -1685,6 +1687,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "barrow", "event_type": "location_entered", "level_number": 1, "location_id": "shrine", @@ -1744,6 +1747,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": 2, "location_id": "barrow", @@ -1769,6 +1773,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "barrow", "event_type": "location_entered", "level_number": 2, "location_id": "vault", @@ -2978,6 +2983,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": 1, "location_id": "barrow", @@ -2987,6 +2993,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "barrow", "event_type": "location_entered", "level_number": 1, "location_id": "shrine", @@ -3004,6 +3011,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": "barrow", "event_type": "location_entered", "level_number": 1, "location_id": "guard_room", @@ -3037,6 +3045,7 @@ }, { "code": "exploration.location.entered", + "dungeon_id": null, "event_type": "location_entered", "level_number": null, "location_id": "town", diff --git a/tests/test_commands.py b/tests/test_commands.py index cbedcaa..c851fee 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -7,8 +7,10 @@ from osrlib.core.validation import Rejection from osrlib.crawl.commands import ( ALL_COMMAND_CLASSES, + CONSEQUENCE_COMMAND_CLASSES, BattleDeclaration, CommandResult, + ConsequenceCommand, MoveParty, ResolveBattleRound, SessionMode, @@ -158,6 +160,50 @@ def test_turn_undead_is_encounter_only(self): assert turn_undead.allowed_modes == frozenset({SessionMode.ENCOUNTER}) +class TestConsequenceCensus: + """The authored-consequence sub-union against the referee census it draws from.""" + + def test_every_consequence_is_a_referee_command(self): + types = {cls.model_fields["command_type"].default for cls in CONSEQUENCE_COMMAND_CLASSES} + assert types <= REFEREE_COMMANDS + + def test_the_exclusions_are_the_lifecycle_family_identify_item_and_roll_dice(self): + types = {cls.model_fields["command_type"].default for cls in CONSEQUENCE_COMMAND_CLASSES} + assert REFEREE_COMMANDS - types == { + # The interpreter's own vocabulary: it marks, journals, and annotates + # on the author's behalf. + "mark_trigger_fired", + "add_journal_entry", + "record_note", + # Session-scoped instance ids no document can know. + "identify_item", + # A draw no authored construct reads. + "roll_dice", + } + + def test_the_consequence_classes_are_a_subset_of_the_command_census(self): + assert set(CONSEQUENCE_COMMAND_CLASSES) <= set(ALL_COMMAND_CLASSES) + names = [cls.__name__ for cls in CONSEQUENCE_COMMAND_CLASSES] + assert len(set(names)) == len(names) + + def test_the_character_addressing_consequences_are_exactly_three(self): + # Two sites enumerate this trio by hand and must grow together when it does: + # `Interpreter._expand` (which turns a party selector into concrete commands) + # and `_validate_trigger` in adventure.py (which rejects a literal id). A new + # consequence class carrying `character_id` that skipped both would silently + # take a selector string as a character id. + addressing = {cls.__name__ for cls in CONSEQUENCE_COMMAND_CLASSES if "character_id" in cls.model_fields} + assert addressing == {"GrantItem", "GrantCoins", "AwardXP"} + + def test_the_union_members_are_the_census_in_order(self): + # The union is spelled out for the type checker; this is the tripwire that + # keeps the two spellings the same list. + from typing import get_args + + union, _field = get_args(ConsequenceCommand) + assert get_args(union) == CONSEQUENCE_COMMAND_CLASSES + + class TestSpawnMonstersValidation: def test_exactly_one_count_form(self): with pytest.raises(ValueError): diff --git a/tests/test_crawl_properties.py b/tests/test_crawl_properties.py index 95a4000..7968256 100644 --- a/tests/test_crawl_properties.py +++ b/tests/test_crawl_properties.py @@ -5,7 +5,7 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -from crawl_fixtures import build_adventure, build_gated_adventure, build_party +from crawl_fixtures import build_adventure, build_gated_adventure, build_party, build_portcullis_adventure from osrlib.core.events import Visibility from osrlib.core.items import Coins from osrlib.crawl.commands import ( @@ -15,6 +15,7 @@ GrantItem, LightSource, MoveParty, + SetFlag, ) from osrlib.crawl.dungeon import Direction, PartyLocation from osrlib.crawl.session import GameSession @@ -151,7 +152,19 @@ def command_strategy(): fields[field_name] = st.builds(Coins, gp=st.integers(min_value=0, max_value=50)) elif field_name in ("feature_id", "dungeon_id", "spell_id", "template_id", "key"): fields[field_name] = st.sampled_from( - ["delve", "warren", "chest", "niche", "vault", "pile", "sleep", "goblin", "lever"] + [ + "delve", + "warren", + "keep", + "chest", + "niche", + "vault", + "pile", + "sleep", + "goblin", + "lever", + "keep.lever", + ] ) elif field_name == "service": fields[field_name] = st.sampled_from(["cure_light_wounds", "remove_curse", "raise_dead"]) @@ -164,7 +177,9 @@ def command_strategy(): elif field_name == "mode": fields[field_name] = st.sampled_from(["hd_budget", "damage", "illuminate"]) elif field_name == "value": - fields[field_name] = st.sampled_from([True, 7, "open"]) + # "pulled" is the value an authored trigger watches for, so the fuzz + # reaches the firing path as well as the flag store. + fields[field_name] = st.sampled_from([True, 7, "open", "pulled"]) elif field_name == "trigger_id": # "lever-east" is listed twice on purpose: the doubled weight makes a # re-mark of an already-marked trigger likely within a short sequence. @@ -245,6 +260,46 @@ def test_gated_content_only_ever_rejects_and_never_leaks_its_wiring(seed, comman assert wiring not in blob +@settings(max_examples=25, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@given( + seed=st.integers(min_value=0, max_value=2**32), + commands=st.lists(command_strategy(), min_size=1, max_size=25), +) +def test_triggered_content_never_raises_and_never_leaks_its_wiring(seed, commands): + """The fuzz contract with the interpreter registered: a keep whose door answers a lever. + + The interpreter reacts to whatever the fuzz produces by issuing its own commands, + so this drives the firing path, the drop-and-note path, and the cascade bound + together — none of which may raise — and then reads the player view, which must + show the journal beats and none of the wiring behind them. + """ + from osrlib.crawl.interpreter import Interpreter + + session = GameSession.new(build_party(), build_portcullis_adventure(), seed=seed) + session.register_listener(Interpreter(session)) + # The prologue guarantees one real firing before the fuzz starts; what the fuzz + # then does to a keep with its portcullis up is the point. + session.execute(EnterDungeon(dungeon_id="keep")) + session.execute(SetFlag(key="keep.lever", value="pulled")) + assert session.fired_triggers == ["portcullis-rises"] + for command in commands: + session.execute(command) # must never raise + assert session.listener_state == {Interpreter.key: {}}, "the interpreter holds nothing, whatever it saw" + blob = session.view(Visibility.PLAYER).model_dump_json() + for wiring in ( + "pattern_type", + "consequences", + "fired_triggers", + "portcullis-rises", + "guard-ambush", + "Chain rattles", # the fired beat is the referee's line + "requires", + "condition_type", + "guidance", + ): + assert wiring not in blob, wiring + + @settings(max_examples=10, deadline=None, suppress_health_check=[HealthCheck.too_slow]) @given( seed=st.integers(min_value=0, max_value=2**32), diff --git a/tests/test_exploration.py b/tests/test_exploration.py index c595312..67c6737 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -1470,6 +1470,37 @@ def test_a_hit_spawns_from_the_level_table_and_opens_an_encounter(self): assert surprise.roll is None and surprise.surprised is False +class TestLocationBoundaryFacts: + """Every crossing event names where it happened, without asking the session.""" + + def _entered(self, result, kind: str): + return next( + event + for event in result.events + if event.code == "exploration.location.entered" and event.location_kind == kind + ) + + def test_an_area_entry_carries_the_whole_triple(self): + session = quiet_session() + entered(session) + place(session, (1, 0), level_number=2) + result = session.execute(MoveParty(direction=Direction.EAST)) + assert result.accepted + event = self._entered(result, "area") + assert (event.dungeon_id, event.level_number, event.location_id) == ("delve", 2, "crypt") + + def test_level_dungeon_and_town_entries_leave_the_field_unset(self): + session = quiet_session() + dungeon = self._entered(session.execute(EnterDungeon(dungeon_id="delve")), "dungeon") + assert (dungeon.location_id, dungeon.level_number, dungeon.dungeon_id) == ("delve", 1, None) + place(session, (4, 1)) + level = self._entered(session.execute(UseStairs()), "level") + assert (level.location_id, level.level_number, level.dungeon_id) == ("delve", 2, None) + place(session, (0, 0)) + town = self._entered(session.execute(TravelToTown()), "town") + assert (town.location_id, town.level_number, town.dungeon_id) == ("town", None, None) + + class TestStairsAndTravel: def test_stairs_relocate_and_cost_one_unexplored_cell(self): session = quiet_session() diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py new file mode 100644 index 0000000..92f0ad3 --- /dev/null +++ b/tests/test_interpreter.py @@ -0,0 +1,600 @@ +"""The interpreter: matching, firing, selectors, drops, and the cascade bound. + +`TestMatching` pins each pattern against events that fit and events that don't, +straight through the private matcher. `TestFiring` covers what decides whether a +match becomes a firing — fired-state, conditions, document and batch order — and +`TestSelectors`, `TestDrops`, and `TestCascadeDepth` cover what a firing issues and +what happens when part of it cannot land. `TestReplayEquivalence` is the standing +guarantee: a session with the interpreter registered and a replay of its log with no +listeners at all reach the same world. +""" + +import json + +import pytest + +from crawl_fixtures import LEVER_KEY, PORTCULLIS_FIRED, PORTCULLIS_JOURNAL, build_adventure, build_party +from crawl_fixtures import build_portcullis_adventure as build_keep +from osrlib.core.effects import kill +from osrlib.core.events import Visibility +from osrlib.core.items import Coins, MagicItemInstance +from osrlib.crawl.adventure import Adventure, TownSpec +from osrlib.crawl.commands import ( + AwardXP, + EnterDungeon, + GrantCoins, + GrantItem, + LightSource, + MoveParty, + OpenDoor, + PlaceParty, + SetFlag, + SpawnMonsters, + UseStairs, +) +from osrlib.crawl.dungeon import ( + AreaSpec, + Direction, + DungeonSpec, + LevelSpec, + PartyLocation, + TransitionSpec, + WanderingSpec, +) +from osrlib.crawl.events import ( + FlagSetEvent, + ItemAcquiredEvent, + LocationEnteredEvent, + MonsterDefeatedEvent, + NoteRecordedEvent, +) +from osrlib.crawl.gates import FlagEqualsCondition, HasItemCondition +from osrlib.crawl.interpreter import Interpreter, _matches +from osrlib.crawl.narrative import NarrativeBlock +from osrlib.crawl.session import GameSession +from osrlib.crawl.triggers import ( + FIRST_LIVING_SELECTOR, + PARTY_SELECTOR, + AreaEnteredPattern, + DungeonEnteredPattern, + FlagSetPattern, + ItemAcquiredPattern, + LevelEnteredPattern, + MonsterDefeatedPattern, + TownEnteredPattern, + TriggerSpec, +) +from osrlib.persistence import load_game, save_game, session_state + + +def with_triggers(*triggers: TriggerSpec, adventure: Adventure | None = None) -> Adventure: + """The shared delve (or another adventure) with authored triggers bolted on.""" + base = adventure if adventure is not None else build_adventure(wandering_chance=0) + return base.model_copy(update={"triggers": triggers}) + + +def build_tower() -> Adventure: + """A two-cell tower whose stair lands the party inside a keyed area. + + One command — `UseStairs` — therefore reports two crossings in one batch: the + level arrival, then the area the party landed in. + """ + upper = LevelSpec( + number=1, + width=1, + height=1, + entrance=(0, 0), + transitions=( + TransitionSpec( + kind="stairs_down", + position=(0, 0), + to_dungeon_id="tower", + to_level_number=2, + to_position=(0, 0), + to_facing=Direction.EAST, + ), + ), + wandering=WanderingSpec(chance_in_six=0), + ) + lower = LevelSpec( + number=2, + width=1, + height=1, + areas=(AreaSpec(id="cellar", name="Cellar", cells=((0, 0),)),), + wandering=WanderingSpec(chance_in_six=0), + ) + return Adventure( + name="The Tower", + town=TownSpec(name="Threshold", travel_turns={"tower": 1}), + dungeons=(DungeonSpec(id="tower", name="The Tower", levels=(upper, lower)),), + ) + + +def played(adventure: Adventure, seed: int = 31) -> GameSession: + """A session running `adventure` with the interpreter registered.""" + session = GameSession.new(build_party(), adventure, seed=seed) + session.register_listener(Interpreter(session)) + return session + + +def replayed(session: GameSession) -> GameSession: + """The same seed and the same accepted commands, with no listeners at all.""" + fresh = GameSession.new(build_party(), session.adventure, seed=session.master_seed) + for command in session.command_log: + result = fresh.execute(command) + assert result.accepted, f"replay diverged on {command.command_type}" + return fresh + + +def notes(session: GameSession) -> list[str]: + """Every referee note the run recorded, in order.""" + return [event.text for event in session.event_log if isinstance(event, NoteRecordedEvent)] + + +def torchlit(session: GameSession) -> None: + """Put a lit torch in the lead member's hand, in town, before the delve.""" + session.execute(GrantItem(character_id="character-0001", item_id="torch", quantity=6)) + session.execute(GrantItem(character_id="character-0001", item_id="tinder_box")) + for _ in range(20): # tinder is 2-in-6 per round; retry until it takes + lit = session.execute(LightSource(character_id="character-0001", item_id="torch")) + if any(event.code == "exploration.light.lit" for event in lit.events): + return + raise AssertionError("torch never lit in twenty tinder attempts") + + +class TestMatching: + def match(self, pattern, event, session=None) -> bool: + return _matches(pattern, event, session if session is not None else played(build_adventure())) + + def test_an_area_pattern_wants_the_whole_triple(self): + pattern = AreaEnteredPattern(dungeon_id="delve", level_number=2, area_id="crypt") + event = LocationEnteredEvent(location_kind="area", location_id="crypt", level_number=2, dungeon_id="delve") + assert self.match(pattern, event) + assert not self.match(pattern, event.model_copy(update={"dungeon_id": "elsewhere"})) + assert not self.match(pattern, event.model_copy(update={"level_number": 1})) + assert not self.match(pattern, event.model_copy(update={"location_id": "room_a"})) + + def test_a_level_pattern_matches_a_dungeon_crossing_too(self): + pattern = LevelEnteredPattern(dungeon_id="delve", level_number=1) + by_stair = LocationEnteredEvent(location_kind="level", location_id="delve", level_number=1) + from_town = LocationEnteredEvent(location_kind="dungeon", location_id="delve", level_number=1) + assert self.match(pattern, by_stair) + assert self.match(pattern, from_town), "arriving on a level is arriving on it, however the party got there" + assert not self.match(pattern, from_town.model_copy(update={"level_number": 2})) + + def test_a_dungeon_pattern_ignores_the_levels_beneath_it(self): + pattern = DungeonEnteredPattern(dungeon_id="delve") + assert self.match(pattern, LocationEnteredEvent(location_kind="dungeon", location_id="delve", level_number=1)) + assert not self.match(pattern, LocationEnteredEvent(location_kind="level", location_id="delve", level_number=2)) + + def test_a_town_pattern_matches_the_homecoming_however_it_happened(self): + assert self.match(TownEnteredPattern(), LocationEnteredEvent(location_kind="town", location_id="town")) + assert not self.match( + TownEnteredPattern(), LocationEnteredEvent(location_kind="dungeon", location_id="delve", level_number=1) + ) + + def test_a_flag_pattern_reads_the_written_value_strictly(self): + assert self.match(FlagSetPattern(key="lever"), FlagSetEvent(key="lever", value="pulled")) + assert self.match(FlagSetPattern(key="lever"), FlagSetEvent(key="lever", value=False)), "any value at all" + assert not self.match(FlagSetPattern(key="lever"), FlagSetEvent(key="other", value="pulled")) + assert self.match(FlagSetPattern(key="lever", value=1), FlagSetEvent(key="lever", value=1)) + assert not self.match(FlagSetPattern(key="lever", value=1), FlagSetEvent(key="lever", value=True)) + assert not self.match(FlagSetPattern(key="lever", value=True), FlagSetEvent(key="lever", value=1)) + + def test_a_flag_pattern_watches_the_edge_not_the_store(self): + session = played(build_adventure(wandering_chance=0)) + session.flags["lever"] = "shut" + # The store says shut; the write said pulled, and the write is the edge. + assert self.match( + FlagSetPattern(key="lever", value="pulled"), FlagSetEvent(key="lever", value="pulled"), session + ) + + def test_a_monster_pattern_takes_every_outcome(self): + pattern = MonsterDefeatedPattern(template_id="goblin") + for outcome in ("slain", "routed", "surrendered"): + event = MonsterDefeatedEvent(monster_id="monster-0001", template_id="goblin", outcome=outcome, xp=5) + assert self.match(pattern, event), outcome + skeleton = MonsterDefeatedEvent(monster_id="monster-0002", template_id="skeleton", outcome="slain", xp=10) + assert not self.match(pattern, skeleton) + + def test_an_item_pattern_takes_a_mundane_catalog_id_directly(self): + session = played(build_adventure(wandering_chance=0)) + event = ItemAcquiredEvent(character_id="character-0001", item_ids=("torch", "holy_water")) + assert self.match(ItemAcquiredPattern(item_id="holy_water"), event, session) + assert not self.match(ItemAcquiredPattern(item_id="rope"), event, session) + + def test_an_item_pattern_resolves_a_magic_instance_through_the_acquirer(self): + session = played(build_adventure(wandering_chance=0)) + member = session.member("character-0002") + instance = MagicItemInstance(instance_id="magic-item-0001", template_id="potion_of_healing") + member.inventory.items.append(instance) + event = ItemAcquiredEvent(character_id="character-0002", item_ids=("magic-item-0001",)) + assert self.match(ItemAcquiredPattern(item_id="potion_of_healing"), event, session) + # Somebody else's pack is not the acquirer's. + elsewhere = event.model_copy(update={"character_id": "character-0003"}) + assert not self.match(ItemAcquiredPattern(item_id="potion_of_healing"), elsewhere, session) + + def test_a_pattern_never_asks_the_session_where_the_party_is(self): + session = played(build_adventure(wandering_chance=0)) + session.execute(EnterDungeon(dungeon_id="delve")) + # The party stands on level 1; the event says level 2, and the event wins. + pattern = LevelEnteredPattern(dungeon_id="delve", level_number=2) + assert self.match(pattern, LocationEnteredEvent(location_kind="level", location_id="delve", level_number=2)) + + +class TestFiring: + def test_a_firing_marks_first_then_runs_its_consequences_then_journals(self): + session = played(build_keep()) + session.execute(EnterDungeon(dungeon_id="keep")) + session.execute(SetFlag(key=LEVER_KEY, value="pulled")) + issued = [ + command.command_type for command in session.command_log if command.source == "trigger:portcullis-rises" + ] + assert issued == ["mark_trigger_fired", "set_door_state", "add_journal_entry"] + assert session.fired_triggers == ["portcullis-rises"] + assert [entry.text for entry in session.journal] == [PORTCULLIS_JOURNAL] + + def test_the_fired_beat_rides_the_referee_event_and_the_journal_speaks_to_the_table(self): + session = played(build_keep()) + session.execute(EnterDungeon(dungeon_id="keep")) + result = session.execute(SetFlag(key=LEVER_KEY, value="pulled")) + fired = next(event for event in result.events if event.code == "session.trigger.fired") + assert fired.narrative == PORTCULLIS_FIRED + assert fired.visibility.value == "referee" + beat = next(event for event in result.events if event.code == "session.journal.entry_added") + assert beat.text == PORTCULLIS_JOURNAL + assert beat.visibility.value == "player" + + def test_a_failing_condition_blocks_the_firing_and_leaves_no_mark(self): + trigger = TriggerSpec( + id="warded", + when=FlagSetPattern(key="lever"), + conditions=(HasItemCondition(item_id="holy_water"),), + consequences=(SetFlag(key="opened", value=True),), + ) + session = played(with_triggers(trigger)) + session.execute(SetFlag(key="lever", value=True)) + assert session.fired_triggers == [] + assert "opened" not in session.flags + session.execute(GrantItem(character_id="character-0001", item_id="holy_water")) + session.execute(SetFlag(key="lever", value=True)) + assert session.fired_triggers == ["warded"] + assert session.flags["opened"] is True + + def test_once_only_fires_once_and_repeatable_fires_every_time(self): + once = TriggerSpec( + id="once", when=FlagSetPattern(key="bell"), consequences=(SetFlag(key="once-rang", value=1),) + ) + again = TriggerSpec( + id="again", when=FlagSetPattern(key="bell"), repeatable=True, consequences=(SetFlag(key="rung", value=1),) + ) + session = played(with_triggers(once, again)) + for _ in range(3): + session.execute(SetFlag(key="bell", value=True)) + marks = [command for command in session.command_log if command.command_type == "mark_trigger_fired"] + assert [command.trigger_id for command in marks] == ["once", "again", "again", "again"] + assert session.fired_triggers == ["once", "again"], "state records that a trigger has fired, not how often" + + def test_two_triggers_on_one_event_fire_in_document_order(self): + first = TriggerSpec(id="first", when=FlagSetPattern(key="bell")) + second = TriggerSpec(id="second", when=FlagSetPattern(key="bell")) + session = played(with_triggers(second, first)) + session.execute(SetFlag(key="bell", value=True)) + assert session.fired_triggers == ["second", "first"], "document order, not alphabetical, not registration" + + def test_two_events_in_one_command_fire_in_the_order_they_happened(self): + # `UseStairs` reports the level crossing and then the area the party landed + # in. The area trigger is written first in the document and still fires + # second, because the batch's order outranks the document's. + area = TriggerSpec( + id="area-written-first", when=AreaEnteredPattern(dungeon_id="tower", level_number=2, area_id="cellar") + ) + level = TriggerSpec(id="level-written-second", when=LevelEnteredPattern(dungeon_id="tower", level_number=2)) + session = played(with_triggers(area, level, adventure=build_tower())) + session.execute(EnterDungeon(dungeon_id="tower")) + session.execute(UseStairs()) + assert session.fired_triggers == ["level-written-second", "area-written-first"] + + def test_evaluate_as_you_go_lets_an_earlier_firing_satisfy_a_later_condition(self): + opener = TriggerSpec( + id="opener", when=FlagSetPattern(key="bell"), consequences=(SetFlag(key="power", value="on"),) + ) + follower = TriggerSpec( + id="follower", + when=FlagSetPattern(key="bell"), + conditions=(FlagEqualsCondition(key="power", value="on"),), + ) + session = played(with_triggers(opener, follower)) + session.execute(SetFlag(key="bell", value=True)) + assert session.fired_triggers == ["opener", "follower"] + # The other way round, the follower's condition is still false when it looks. + reversed_session = played(with_triggers(follower, opener)) + reversed_session.execute(SetFlag(key="bell", value=True)) + assert reversed_session.fired_triggers == ["opener"] + + def test_the_mark_lands_before_a_consequence_that_would_re_match_the_trigger(self): + loop = TriggerSpec( + id="loop", when=FlagSetPattern(key="loop"), consequences=(SetFlag(key="loop", value="again"),) + ) + session = played(with_triggers(loop)) + session.execute(SetFlag(key="loop", value="first")) + marks = [command for command in session.command_log if command.command_type == "mark_trigger_fired"] + assert len(marks) == 1, "the mark is in place before the consequence that would match again" + assert session.flags["loop"] == "again" + assert notes(session) == [] + + +class TestSelectors: + def test_party_expands_to_the_living_in_marching_order(self): + trigger = TriggerSpec( + id="boon", + when=FlagSetPattern(key="bell"), + consequences=(AwardXP(character_id=PARTY_SELECTOR, amount=50),), + ) + session = played(with_triggers(trigger)) + kill(session.member("character-0002")) + session.execute(SetFlag(key="bell", value=True)) + awards = [command for command in session.command_log if command.command_type == "award_xp"] + assert [command.character_id for command in awards] == ["character-0001", "character-0003", "character-0004"] + + def test_first_addresses_the_lead_survivor(self): + trigger = TriggerSpec( + id="purse", + when=FlagSetPattern(key="bell"), + consequences=(GrantCoins(character_id=FIRST_LIVING_SELECTOR, coins=Coins(gp=25)),), + ) + session = played(with_triggers(trigger)) + kill(session.member("character-0001")) + session.execute(SetFlag(key="bell", value=True)) + grants = [command for command in session.command_log if command.command_type == "grant_coins"] + assert [command.character_id for command in grants] == ["character-0002"] + + def test_party_with_nobody_standing_expands_to_nothing_and_says_nothing(self): + trigger = TriggerSpec( + id="boon", + when=FlagSetPattern(key="bell"), + consequences=(AwardXP(character_id=PARTY_SELECTOR, amount=50),), + ) + session = played(with_triggers(trigger)) + for member in session.party.members: + kill(member) + session.execute(SetFlag(key="bell", value=True)) + assert [command for command in session.command_log if command.command_type == "award_xp"] == [] + assert notes(session) == [], "a reward for the dead is nothing, not a problem" + + def test_first_with_nobody_standing_drops_and_says_so(self): + trigger = TriggerSpec( + id="purse", + when=FlagSetPattern(key="bell"), + consequences=(GrantCoins(character_id=FIRST_LIVING_SELECTOR, coins=Coins(gp=25)),), + ) + session = played(with_triggers(trigger)) + for member in session.party.members: + kill(member) + session.execute(SetFlag(key="bell", value=True)) + assert [command for command in session.command_log if command.command_type == "grant_coins"] == [] + assert notes(session) == ["trigger purse: consequence 0 (grant_coins) dropped (no living member for @first)"] + + def test_every_issued_command_carries_the_triggers_stamp(self): + trigger = TriggerSpec( + id="boon", + when=FlagSetPattern(key="bell"), + consequences=(AwardXP(character_id=PARTY_SELECTOR, amount=50),), + narrative=NarrativeBlock(fired="A warmth in the chest.", journal="Something was given."), + ) + session = played(with_triggers(trigger)) + session.execute(SetFlag(key="bell", value=True)) + issued = session.command_log[1:] + assert issued, "the firing issued something" + assert {command.source for command in issued} == {"trigger:boon"} + + def test_the_authored_consequence_is_never_mutated_by_the_stamp(self): + trigger = TriggerSpec( + id="boon", + when=FlagSetPattern(key="bell"), + consequences=(AwardXP(character_id=PARTY_SELECTOR, amount=50),), + ) + session = played(with_triggers(trigger)) + session.execute(SetFlag(key="bell", value=True)) + authored = session.adventure.triggers[0].consequences[0] + assert authored.source is None + assert authored.character_id == PARTY_SELECTOR + + +class TestDrops: + def test_a_spawn_that_meets_an_open_encounter_drops_and_records_why(self): + session = played(build_keep()) + session.execute(EnterDungeon(dungeon_id="keep")) + session.execute(SetFlag(key=LEVER_KEY, value="pulled")) + for _ in range(4): + session.execute(MoveParty(direction=Direction.EAST)) + assert session.mode.value == "encounter", "the guardroom's own goblins opened it" + assert "guard-ambush" in session.fired_triggers, "the trigger fired; its consequence is what dropped" + assert notes(session) == [ + "trigger guard-ambush: consequence 0 (spawn_monsters) dropped (session.command.encounter_in_progress)" + ] + assert [command for command in session.command_log if command.command_type == "spawn_monsters"] == [] + + def test_a_dropped_consequence_does_not_stop_the_ones_after_it(self): + trigger = TriggerSpec( + id="mixed", + when=FlagSetPattern(key="bell"), + consequences=( + SpawnMonsters(template_id="goblin", count_fixed=1, distance_feet=30), + SetFlag(key="after", value=True), + ), + narrative=NarrativeBlock(journal="The wiring ran to the end."), + ) + session = played(with_triggers(trigger)) + session.execute(SetFlag(key="bell", value=True)) # in town: a spawn needs a dungeon cell + assert notes(session) == [ + "trigger mixed: consequence 0 (spawn_monsters) dropped (session.command.not_in_dungeon)" + ] + assert session.flags["after"] is True + assert [entry.text for entry in session.journal] == ["The wiring ran to the end."] + + def test_a_literal_character_id_lands_as_an_ordinary_rejection(self): + # Hand-built content that never went through validation: the id flows + # through untouched and the machinery degrades into a drop-and-note. + trigger = TriggerSpec( + id="misaddressed", + when=FlagSetPattern(key="bell"), + consequences=(AwardXP(character_id="character-9999", amount=10),), + ) + # The spec parses; it is `validate_adventure` that rejects the literal id, and + # this trigger reaches the session behind its back. + session = GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=31) + session.adventure = session.adventure.model_copy(update={"triggers": (trigger,)}) + session.register_listener(Interpreter(session)) + session.execute(SetFlag(key="bell", value=True)) + assert notes(session) == [ + "trigger misaddressed: consequence 0 (award_xp) dropped (session.command.unknown_member)" + ] + + +class TestCascadeDepth: + """A flag chain deep enough to hit the bound, and what the bound does.""" + + def chain(self) -> tuple[TriggerSpec, ...]: + links = [ + TriggerSpec( + id=f"chain-{step}", + when=FlagSetPattern(key=f"step{step}"), + repeatable=True, + consequences=(SetFlag(key=f"step{step + 1}", value=True),), + ) + for step in range(1, 6) + ] + # The sixth link is once-only, so its suppression is provably not a mark. + links.append( + TriggerSpec( + id="chain-6", + when=FlagSetPattern(key="step6"), + consequences=(SetFlag(key="step7", value=True),), + ) + ) + return tuple(links) + + def test_the_cascade_runs_to_the_bound_and_records_the_truncation(self): + session = played(with_triggers(*self.chain())) + session.execute(SetFlag(key="step1", value=True)) + assert session.fired_triggers == [f"chain-{step}" for step in range(1, 6)] + assert sorted(session.flags) == [f"step{step}" for step in range(1, 7)] + assert notes(session) == ["trigger chain-6: not fired, the cascade reached depth 5 past the limit of 4"] + + def test_a_truncated_once_only_trigger_is_still_fireable_afterwards(self): + session = played(with_triggers(*self.chain())) + session.execute(SetFlag(key="step1", value=True)) + assert "chain-6" not in session.fired_triggers + session.execute(SetFlag(key="step6", value=True)) + assert "chain-6" in session.fired_triggers, "a suppressed firing leaves the trigger unfired, not spent" + assert session.flags["step7"] is True + + def test_the_truncation_note_is_stamped_like_everything_else(self): + session = played(with_triggers(*self.chain())) + session.execute(SetFlag(key="step1", value=True)) + note = next(command for command in session.command_log if command.command_type == "record_note") + assert note.source == "trigger:chain-6" + + +class TestTheResultEnvelope: + def test_a_player_commands_result_carries_the_whole_cascade_in_log_order(self): + session = played(build_keep()) + session.execute(EnterDungeon(dungeon_id="keep")) + before = len(session.event_log) + result = session.execute(SetFlag(key=LEVER_KEY, value="pulled")) + assert [event.code for event in result.events] == [ + "session.flag.set", + "session.trigger.fired", + "exploration.door.opened", + "session.journal.entry_added", + ] + logged = [event.code for event in session.event_log[before:]] + assert logged == [event.code for event in result.events], "each event once, in the log's own order" + + def test_the_gate_refuses_until_the_trigger_opens_the_grille(self): + session = played(build_keep()) + session.execute(EnterDungeon(dungeon_id="keep")) + session.execute(MoveParty(direction=Direction.EAST)) + session.execute(MoveParty(direction=Direction.EAST)) + refused = session.execute(OpenDoor(direction=Direction.EAST)) + assert refused.rejections[0].code == "exploration.door.gate_refused" + assert refused.rejections[0].params["refusal"].startswith("The portcullis is a grille") + session.execute(SetFlag(key=LEVER_KEY, value="pulled")) + moved = session.execute(MoveParty(direction=Direction.EAST)) + assert moved.accepted, "a door standing open admits passage without a gate check" + + +class TestReplayEquivalence: + def test_the_interpreter_holds_nothing_and_emits_nothing(self): + session = played(build_keep()) + session.execute(EnterDungeon(dungeon_id="keep")) + session.execute(SetFlag(key=LEVER_KEY, value="pulled")) + assert session.listener_state == {"osrlib.interpreter": {}} + + def test_a_triggered_run_replays_with_no_listeners_at_all(self): + session = played(build_keep()) + session.execute(EnterDungeon(dungeon_id="keep")) + session.execute(SetFlag(key=LEVER_KEY, value="pulled")) + for _ in range(2): + session.execute(MoveParty(direction=Direction.EAST)) + fresh = replayed(session) + live, again = session_state(session), session_state(fresh) + assert live.pop("listener_state") == {"osrlib.interpreter": {}} + assert again.pop("listener_state") == {} + assert live == again + + def test_a_teleporting_consequence_replays_the_same_map_memory(self): + trigger = TriggerSpec( + id="pitfall", + when=DungeonEnteredPattern(dungeon_id="delve"), + consequences=( + PlaceParty( + location=PartyLocation( + kind="dungeon", dungeon_id="delve", level_number=2, position=(1, 0), facing=Direction.EAST + ) + ), + ), + ) + adventure = with_triggers(trigger) + session = played(adventure) + torchlit(session) + session.execute(EnterDungeon(dungeon_id="delve")) + assert session.dungeon_state.location.level_number == 2, "the trigger moved the party on arrival" + fresh = replayed(session) + assert fresh.dungeon_state.seen == session.dungeon_state.seen + assert set(session.dungeon_state.seen) == {"delve:1", "delve:2"}, "both folds landed, in order" + + def test_load_of_a_save_equals_the_replay(self): + session = played(build_keep()) + session.execute(EnterDungeon(dungeon_id="keep")) + session.execute(SetFlag(key=LEVER_KEY, value="pulled")) + restored = load_game(json.loads(json.dumps(save_game(session)))) + fresh = replayed(session) + assert restored.journal == session.journal == fresh.journal + assert restored.fired_triggers == session.fired_triggers == fresh.fired_triggers + state = session_state(restored) + assert state.pop("listener_state") == {"osrlib.interpreter": {}} + again = session_state(fresh) + assert again.pop("listener_state") == {} + assert state == again + + def test_the_player_view_never_shows_the_wiring(self): + session = played(build_keep()) + session.execute(EnterDungeon(dungeon_id="keep")) + session.execute(SetFlag(key=LEVER_KEY, value="pulled")) + payload = json.dumps(session.view(Visibility.PLAYER).model_dump(mode="json")) + for forbidden in ("pattern_type", "consequences", "fired_triggers", "portcullis-rises", PORTCULLIS_FIRED): + assert forbidden not in payload + assert PORTCULLIS_JOURNAL in payload, "the journal is the players' side of the beat" + + +@pytest.mark.parametrize("adventure_builder", [build_keep], ids=["keep"]) +def test_an_unregistered_interpreter_changes_nothing(adventure_builder): + """Triggers are inert content until a game registers the listener that plays them.""" + session = GameSession.new(build_party(), adventure_builder(), seed=31) + session.execute(EnterDungeon(dungeon_id="keep")) + session.execute(SetFlag(key=LEVER_KEY, value="pulled")) + assert session.fired_triggers == [] + assert session.journal == [] + assert [command.command_type for command in session.command_log] == ["enter_dungeon", "set_flag"] diff --git a/tests/test_journal_lifecycle.py b/tests/test_journal_lifecycle.py index ae6e7ee..d34130d 100644 --- a/tests/test_journal_lifecycle.py +++ b/tests/test_journal_lifecycle.py @@ -109,6 +109,25 @@ def test_the_trigger_id_is_open_domain_but_never_empty(self): with pytest.raises(ValidationError): MarkTriggerFired(trigger_id="") + def test_the_beat_rides_the_event_verbatim_behind_the_screen(self): + from osrlib.messages import format_message + + beat = "The counterweight drops somewhere in the wall." + session = make_session() + result = session.execute(MarkTriggerFired(trigger_id="lever-east", narrative=beat)) + event = next(event for event in result.events if isinstance(event, TriggerFiredEvent)) + assert event.narrative == beat + assert event.visibility is Visibility.REFEREE + assert format_message(event).endswith(beat) + + def test_an_unwritten_beat_is_absent_and_never_empty(self): + session = make_session() + result = session.execute(MarkTriggerFired(trigger_id="lever-east")) + event = next(event for event in result.events if isinstance(event, TriggerFiredEvent)) + assert event.narrative is None + with pytest.raises(ValidationError): + MarkTriggerFired(trigger_id="lever-east", narrative="") + class TestAddJournalEntry: def test_entries_append_in_order_stamped_with_the_clock(self): diff --git a/tests/test_phase14_goldens.py b/tests/test_phase14_goldens.py new file mode 100644 index 0000000..e7d56bd --- /dev/null +++ b/tests/test_phase14_goldens.py @@ -0,0 +1,175 @@ +"""The phase 14 golden: the lever keep, played by the interpreter and replayed without it. + +Regenerate with `uv run python tests/generate_phase14_goldens.py` (and explain why in +the commit message). The golden records a scripted delve whose only actor besides the +player is the registered interpreter: a gate refusal that costs nothing, a lever flag +whose trigger opens the portcullis and writes the party's journal, a walk through the +raised grille, and an ambush trigger whose spawn arrives to find the guardroom's own +encounter already open and is dropped with a note. + +It is where the milestone is checked: the scenario is authored data, and the same log +replayed with no listeners at all reaches the same world — command and event logs +byte-equal, every state block equal but the interpreter's provably empty listener slot. +""" + +import json +from pathlib import Path + +import pytest + +from generate_phase14_goldens import ( + AMBUSH, + PORTCULLIS, + SCRIPT, + build_golden, + replay_scenario, + run_scenario, + snapshot, +) +from osrlib.core.events import Event +from osrlib.crawl.commands import parse_command +from osrlib.crawl.interpreter import Interpreter +from osrlib.messages import format_message +from osrlib.persistence import load_game, save_game, session_state + +GOLDEN_PATH = Path(__file__).parent / "goldens" / "phase14_triggers.json" + +REGENERATE_HINT = ( + "golden mismatch; if the change is intentional, regenerate with " + "`uv run python tests/generate_phase14_goldens.py` and explain why in the commit message" +) + +INTERPRETER_ONLY = {Interpreter.key: {}} +"""What a live session's listener store holds: one slot, created by registration, empty +for the life of the session.""" + + +def canonical(value) -> str: + return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + + +def without_listener_state(session) -> tuple[dict, dict]: + """The session's state split into the listener store and everything else.""" + state = session_state(session) + return state.pop("listener_state"), state + + +@pytest.fixture(scope="module") +def golden() -> dict: + return json.loads(GOLDEN_PATH.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def scripted(golden): + """The scripted run, with the interpreter registered.""" + return run_scenario(golden["master_seed"]) + + +@pytest.fixture(scope="module") +def replayed(golden): + """The determinism contract: the accepted-command log alone, no listeners at all.""" + return replay_scenario(golden["master_seed"], golden["command_log"]) + + +class TestScriptedRun: + def test_the_whole_golden_matches_byte_for_byte(self, golden): + assert canonical(build_golden(golden["master_seed"])) == canonical(golden), REGENERATE_HINT + + def test_the_command_log_round_trips(self, golden): + for entry in golden["command_log"]: + assert parse_command(entry) is not None + + def test_every_step_is_either_logged_or_refused_never_both(self, golden, scripted): + session, refusals = scripted + player_commands = [command for command in session.command_log if command.source is None] + assert len(SCRIPT) == len(player_commands) + len(refusals) + assert len(session.command_log) == len(golden["command_log"]) + + +class TestMilestoneBeats: + def test_the_portcullis_refuses_with_its_authored_text_and_costs_nothing(self, golden): + refusal = golden["refusals"][0] + assert refusal["code"] == "exploration.door.gate_refused" + assert refusal["params"]["refusal"].startswith("The portcullis is a grille") + assert "exploration.door.gate_refused" not in [event.get("code") for event in golden["event_log"]] + + def test_the_lever_marks_opens_and_journals_in_that_order(self, golden): + issued = [entry["command_type"] for entry in golden["command_log"] if entry["source"] == PORTCULLIS] + assert issued == ["mark_trigger_fired", "set_door_state", "add_journal_entry"] + opened = next(event for event in golden["event_log"] if event.get("code") == "exploration.door.opened") + assert (opened["x"], opened["y"], opened["direction"]) == (2, 0, "east") + assert golden["final_state"]["doors"]["keep:1:3,0:west"]["open"] is True + + def test_the_fired_beat_is_the_referees_and_the_journal_is_the_players(self, golden): + fired = [event for event in golden["event_log"] if event.get("code") == "session.trigger.fired"] + assert [event["visibility"] for event in fired] == ["referee", "referee"] + assert fired[0]["narrative"].startswith("Chain rattles in the wall") + journal = [event for event in golden["event_log"] if event.get("code") == "session.journal.entry_added"] + assert [event["visibility"] for event in journal] == ["player", "player"] + assert [entry["text"] for entry in golden["final_state"]["journal"]] == [event["text"] for event in journal] + + def test_the_party_walks_through_the_grille_the_trigger_raised(self, golden): + moves = [event for event in golden["event_log"] if event.get("code") == "exploration.party.moved"] + assert [event["x"] for event in moves] == [1, 2, 3, 4], "past the door at (2,0) and on to the guardroom" + + def test_the_colliding_spawn_drops_alone_and_the_note_says_why(self, golden): + assert not any(entry["command_type"] == "spawn_monsters" for entry in golden["command_log"]) + note = next(event for event in golden["event_log"] if event.get("code") == "session.note.recorded") + assert note["text"] == ( + "trigger guard-ambush: consequence 0 (spawn_monsters) dropped (session.command.encounter_in_progress)" + ) + assert note["visibility"] == "referee" + # The firing itself still happened, and its journal beat still landed. + assert golden["final_state"]["fired_triggers"] == ["portcullis-rises", "guard-ambush"] + assert [entry["command_type"] for entry in golden["command_log"] if entry["source"] == AMBUSH] == [ + "mark_trigger_fired", + "record_note", + "add_journal_entry", + ] + + def test_every_interpreter_issued_command_carries_its_stamp(self, golden): + stamped = [entry for entry in golden["command_log"] if entry["source"] is not None] + assert len(stamped) == 6 + assert all(entry["source"].startswith("trigger:") for entry in stamped) + assert {entry["source"] for entry in stamped} == {PORTCULLIS, AMBUSH} + + +class TestReplayIsTheMilestone: + def test_the_replay_reaches_the_same_state_but_the_empty_listener_slot(self, scripted, replayed): + session, _ = scripted + live_slot, live = without_listener_state(session) + replay_slot, again = without_listener_state(replayed) + assert live_slot == INTERPRETER_ONLY, "registration creates the slot; the interpreter never fills it" + assert replay_slot == {}, "a replay runs with no listeners at all" + assert live == again + + def test_the_command_and_event_logs_are_byte_equal(self, golden, replayed): + commands = [command.model_dump(mode="json") for command in replayed.command_log] + assert canonical(commands) == canonical(golden["command_log"]), REGENERATE_HINT + events = [entry if isinstance(entry, dict) else entry.model_dump(mode="json") for entry in replayed.event_log] + assert canonical(events) == canonical(golden["event_log"]), REGENERATE_HINT + transcript = [format_message(entry) for entry in replayed.event_log if isinstance(entry, Event)] + assert transcript == golden["transcript"], REGENERATE_HINT + + def test_streams_clock_doors_and_the_trigger_blocks_match_the_golden(self, golden, replayed): + assert canonical(snapshot(replayed)) == canonical(golden["final_state"]), REGENERATE_HINT + + def test_a_save_loads_back_to_the_replayed_state(self, scripted, replayed): + session, _ = scripted + restored = load_game(json.loads(json.dumps(save_game(session)))) + assert restored.fired_triggers == session.fired_triggers == replayed.fired_triggers + assert restored.journal == session.journal == replayed.journal + restored_slot, restored_state = without_listener_state(restored) + replay_slot, replay_state = without_listener_state(replayed) + assert restored_slot == INTERPRETER_ONLY, "the empty slot round-trips like any listener state" + assert replay_slot == {} + assert restored_state == replay_state, "load(save) equals replay(seed, commands)" + + def test_the_player_view_carries_the_beats_and_none_of_the_wiring(self, scripted): + from osrlib.core.events import Visibility + + session, _ = scripted + payload = json.dumps(session.view(Visibility.PLAYER).model_dump(mode="json")) + for forbidden in ("pattern_type", "consequences", "fired_triggers", "portcullis-rises", "Chain rattles"): + assert forbidden not in payload, forbidden + assert "the portcullis grinds up into its slot" in payload diff --git a/tests/test_session.py b/tests/test_session.py index 5312acf..3d55662 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -16,6 +16,7 @@ EnterDungeon, GrantCoins, GrantItem, + LightSource, ListenAtDoor, MoveParty, OpenDoor, @@ -440,6 +441,175 @@ def handle(self, events, state): session.execute(SetFlag(key="lever", value=True)) assert order == [("first", 1), ("second", 2)] + def test_a_command_issuing_listeners_events_ride_the_result_in_log_order(self): + class Portcullis: + key = "portcullis" + + def __init__(self, session): + self._session = session + + def handle(self, events, state): + for event in events: + if isinstance(event, FlagSetEvent) and event.key == "lever": + self._session.execute(AddJournalEntry(text="Something grinds open below.")) + return [], state + + session = make_session() + session.register_listener(Portcullis(session)) + result = session.execute(SetFlag(key="lever", value=True)) + codes = [event.code for event in result.events] + assert codes == ["session.flag.set", "session.journal.entry_added"] + # Logged once, and the envelope is the log's tail — the nested command's + # event is not duplicated by riding the result. + assert [getattr(event, "code", None) for event in session.event_log] == codes + + def test_a_nested_cascade_rides_the_result_once_each_in_log_order(self): + class Cascade: + key = "cascade" + + def __init__(self, session): + self._session = session + + def handle(self, events, state): + for event in events: + if isinstance(event, FlagSetEvent) and event.key.startswith("step") and event.key < "step3": + following = f"step{int(event.key[-1]) + 1}" + self._session.execute(SetFlag(key=following, value=True)) + return [], state + + session = make_session() + session.register_listener(Cascade(session)) + result = session.execute(SetFlag(key="step1", value=True)) + assert [event.key for event in result.events] == ["step1", "step2", "step3"] + assert [event.key for event in session.event_log] == ["step1", "step2", "step3"] + + def test_a_later_listener_sees_a_nested_commands_events_exactly_once(self): + """The dispatch input and the result envelope are two different lists. + + A nested `execute` runs the whole listener loop itself, so the second + listener already saw the nested command's events at the nested level. Handing + them to it again at the outer level would double every reaction downstream of + a command-issuing listener. + """ + seen: list[str] = [] + + class Relay: + key = "relay" + + def __init__(self, session): + self._session = session + + def handle(self, events, state): + for event in events: + if isinstance(event, FlagSetEvent) and event.key == "lever": + self._session.execute(SetFlag(key="relayed", value=True)) + return [], state + + class Counter: + key = "counter" + + def handle(self, events, state): + seen.extend(event.key for event in events if isinstance(event, FlagSetEvent)) + return [], state + + session = make_session() + session.register_listener(Relay(session)) + session.register_listener(Counter()) + result = session.execute(SetFlag(key="lever", value=True)) + # Two dispatches reached the counter: the nested command's, then the outer + # one's — and the outer one carries no copy of the nested event. + assert seen == ["relayed", "lever"] + assert [event.key for event in result.events] == ["lever", "relayed"] + assert [event.key for event in session.event_log] == ["lever", "relayed"] + + def test_a_repeatable_trigger_behind_a_relay_listener_fires_once(self): + from osrlib.crawl.interpreter import Interpreter + from osrlib.crawl.triggers import FlagSetPattern, TriggerSpec + + # The trigger watches the key the *relay* writes, so a nested event handed + # back to the outer dispatch would match a second time. + trigger = TriggerSpec( + id="bell", + when=FlagSetPattern(key="relayed"), + repeatable=True, + consequences=(SetFlag(key="rung", value=1),), + ) + session = GameSession.new(build_party(), build_adventure().model_copy(update={"triggers": (trigger,)}), seed=11) + + class Relay: + key = "relay" + + def __init__(self, session): + self._session = session + + def handle(self, events, state): + for event in events: + if isinstance(event, FlagSetEvent) and event.key == "relayed": + return [], state + if isinstance(event, FlagSetEvent) and event.key == "lever": + self._session.execute(SetFlag(key="relayed", value=True)) + return [], state + + session.register_listener(Relay(session)) + session.register_listener(Interpreter(session)) + session.execute(SetFlag(key="lever", value=True)) + marks = [command for command in session.command_log if command.command_type == "mark_trigger_fired"] + assert len(marks) == 1, "one write of the lever key is one firing, relay or no relay" + + def test_an_emit_only_listener_is_unchanged_by_the_splice(self): + class Echo: + key = "echo" + + def handle(self, events, state): + return [FlagSetEvent(key="echo", value=len(events))], state + + session = make_session() + session.register_listener(Echo()) + result = session.execute(SetFlag(key="lever", value=True)) + assert [event.key for event in result.events] == ["lever", "echo"] + assert [event.key for event in session.event_log] == ["lever", "echo"] + + def test_sight_persists_before_the_listeners_so_a_relocation_replays(self): + from osrlib.crawl.commands import Command + from osrlib.persistence import session_state + + teleport = PlaceParty( + location=PartyLocation( + kind="dungeon", dungeon_id="delve", level_number=2, position=(2, 0), facing=Direction.EAST + ) + ) + + class Teleporter: + key = "teleporter" + + def __init__(self, session): + self._session = session + + def handle(self, events, state): + if any(getattr(event, "code", None) == "exploration.party.moved" for event in events): + self._session.execute(teleport) + return [], state + + def outfitted(seed: int = 11) -> GameSession: + session = make_session(seed) + outfit(session) + session.execute(EnterDungeon(dungeon_id="delve")) + for _ in range(20): + lit = session.execute(LightSource(character_id="character-0001", item_id="torch")) + if any(event.code == "exploration.light.lit" for event in lit.events): + break + return session + + live = outfitted() + live.register_listener(Teleporter(live)) + live.execute(MoveParty(direction=Direction.EAST)) + + replayed = outfitted() + for command in list(live.command_log)[len(replayed.command_log) :]: + assert isinstance(command, Command) + replayed.execute(command) + assert session_state(replayed)["dungeon_state"]["seen"] == session_state(live)["dungeon_state"]["seen"] + class TestDeathRecords: def test_poison_death_records_the_cause(self): diff --git a/tests/test_triggers.py b/tests/test_triggers.py new file mode 100644 index 0000000..605b62d --- /dev/null +++ b/tests/test_triggers.py @@ -0,0 +1,386 @@ +"""The trigger document surface: patterns, the spec's parse rules, and validation. + +`TestPatternModels` and `TestTriggerSpec` pin what an authored document may say — +the discriminated pattern union, the consequence sub-union, and the two things a +trigger may never carry (a consuming condition, a hand-written `source`). +`TestFlagValuesEqual` pins the shared strict comparison. `TestTriggerValidation` +walks `validate_adventure`'s trigger checks, reference class by reference class. +""" + +import pytest +from pydantic import TypeAdapter, ValidationError + +from crawl_fixtures import build_adventure +from osrlib.crawl.adventure import Adventure +from osrlib.crawl.commands import ( + CONSEQUENCE_COMMAND_CLASSES, + AddJournalEntry, + AdvanceTime, + ConsequenceCommand, + GrantItem, + MoveParty, + SetDoorState, + SetFlag, +) +from osrlib.crawl.dungeon import Direction +from osrlib.crawl.gates import FlagEqualsCondition, HasItemCondition, flag_values_equal +from osrlib.crawl.narrative import NarrativeBlock +from osrlib.crawl.triggers import ( + FIRST_LIVING_SELECTOR, + PARTY_SELECTOR, + AreaEnteredPattern, + DungeonEnteredPattern, + FlagSetPattern, + ItemAcquiredPattern, + LevelEnteredPattern, + MonsterDefeatedPattern, + TownEnteredPattern, + TriggerPattern, + TriggerSpec, +) + +PATTERNS = ( + AreaEnteredPattern(dungeon_id="delve", level_number=1, area_id="room_a"), + LevelEnteredPattern(dungeon_id="delve", level_number=2), + DungeonEnteredPattern(dungeon_id="delve"), + TownEnteredPattern(), + ItemAcquiredPattern(item_id="holy_water"), + MonsterDefeatedPattern(template_id="goblin"), + FlagSetPattern(key="crypt.lever"), +) + + +class TestPatternModels: + @pytest.mark.parametrize("pattern", PATTERNS, ids=lambda pattern: pattern.pattern_type) + def test_every_pattern_round_trips_through_its_discriminator(self, pattern): + adapter = TypeAdapter(TriggerPattern) + assert adapter.validate_python(pattern.model_dump(mode="json")) == pattern + + def test_pattern_types_are_unique(self): + types = [pattern.pattern_type for pattern in PATTERNS] + assert len(set(types)) == len(types) + + def test_patterns_are_frozen(self): + with pytest.raises(ValidationError): + PATTERNS[0].dungeon_id = "elsewhere" + + def test_string_fields_reject_the_empty_string(self): + with pytest.raises(ValidationError): + DungeonEnteredPattern(dungeon_id="") + with pytest.raises(ValidationError): + ItemAcquiredPattern(item_id="") + + def test_a_flag_pattern_takes_any_written_value_or_one(self): + assert FlagSetPattern(key="lever").value is None + assert FlagSetPattern(key="lever", value=True).value is True + assert FlagSetPattern(key="lever", value="open").value == "open" + + def test_an_unknown_pattern_type_does_not_parse(self): + with pytest.raises(ValidationError): + TypeAdapter(TriggerPattern).validate_python({"pattern_type": "moon_rose", "key": "x"}) + + +class TestConsequenceSurface: + def test_the_union_admits_every_consequence_class(self): + adapter = TypeAdapter(ConsequenceCommand) + samples = { + "GrantItem": dict(character_id=PARTY_SELECTOR, item_id="holy_water"), + "GrantCoins": dict(character_id=FIRST_LIVING_SELECTOR, coins={"gp": 10}), + "AwardXP": dict(character_id=PARTY_SELECTOR, amount=100), + "SetFlag": dict(key="lever", value=True), + "SpawnMonsters": dict(template_id="goblin", count_fixed=2, distance_feet=30), + "SpawnNpcParty": dict(party_kind="basic", distance_feet=30), + "SetDoorState": dict(dungeon_id="delve", level_number=1, x=2, y=0, direction="south", open=True), + "PlaceParty": dict(location={"kind": "town"}), + "AdvanceTime": dict(n=1, unit="turn"), + } + for command_class in CONSEQUENCE_COMMAND_CLASSES: + command = command_class(**samples[command_class.__name__]) + assert adapter.validate_python(command.model_dump(mode="json")) == command + + @pytest.mark.parametrize( + "payload", + [ + AddJournalEntry(text="The lever grinds.").model_dump(mode="json"), + MoveParty(direction=Direction.EAST).model_dump(mode="json"), + {"command_type": "cast_wish"}, + ], + ids=["lifecycle", "player", "unknown"], + ) + def test_a_consequence_outside_the_surface_does_not_parse(self, payload): + with pytest.raises(ValidationError): + TriggerSpec.model_validate({"id": "t", "when": {"pattern_type": "town_entered"}, "consequences": [payload]}) + + +class TestTriggerSpec: + def test_defaults_are_the_common_shape(self): + trigger = TriggerSpec(id="homecoming", when=TownEnteredPattern()) + assert trigger.conditions == () + assert trigger.consequences == () + assert trigger.narrative is None + assert not trigger.repeatable + + def test_a_full_spec_round_trips(self): + trigger = TriggerSpec( + id="portcullis", + when=FlagSetPattern(key="crypt.lever", value="pulled"), + conditions=(FlagEqualsCondition(key="crypt.power", value=True),), + repeatable=True, + consequences=( + SetDoorState(dungeon_id="delve", level_number=1, x=2, y=0, direction=Direction.SOUTH, open=True), + AdvanceTime(n=1, unit="turn"), + ), + narrative=NarrativeBlock(fired="Counterweights drop.", journal="A portcullis grinds upward."), + ) + assert TriggerSpec.model_validate(trigger.model_dump(mode="json")) == trigger + + def test_an_empty_id_is_rejected(self): + with pytest.raises(ValidationError): + TriggerSpec(id="", when=TownEnteredPattern()) + + def test_a_consuming_condition_is_rejected_at_parse(self): + with pytest.raises(ValidationError, match="cannot consume"): + TriggerSpec( + id="toll", + when=TownEnteredPattern(), + conditions=(HasItemCondition(item_id="toll_token", consumes=True),), + ) + + def test_a_non_consuming_condition_is_fine(self): + trigger = TriggerSpec( + id="toll", + when=TownEnteredPattern(), + conditions=(HasItemCondition(item_id="toll_token"),), + ) + assert trigger.conditions[0].consumes is False + + def test_an_authored_source_on_a_consequence_is_rejected_at_parse(self): + with pytest.raises(ValidationError, match="carries a source"): + TriggerSpec( + id="portcullis", + when=TownEnteredPattern(), + consequences=(SetFlag(key="lever", value=True, source="trigger:someone-else"),), + ) + + def test_the_spec_is_frozen(self): + trigger = TriggerSpec(id="homecoming", when=TownEnteredPattern()) + with pytest.raises(ValidationError): + trigger.repeatable = True + + +class TestFlagValuesEqual: + @pytest.mark.parametrize( + ("stored", "expected", "equal"), + [ + ("open", "open", True), + ("open", "shut", False), + (3, 3, True), + (True, True, True), + (True, 1, False), + (1, True, False), + (False, 0, False), + (0, False, False), + ], + ) + def test_equality_plus_matching_boolness(self, stored, expected, equal): + assert flag_values_equal(stored, expected) is equal + + def test_the_condition_and_the_helper_agree(self): + from osrlib.core.effects import EffectsLedger + from osrlib.crawl.gates import condition_holds + + condition = FlagEqualsCondition(key="lever", value=1) + held = condition_holds(condition, members=[], flags={"lever": True}, ledger=EffectsLedger()) + assert held is flag_values_equal(True, 1) is False + + +def with_triggers(*triggers: TriggerSpec) -> Adventure: + """The shared test delve with authored triggers bolted on.""" + return build_adventure(wandering_chance=0).model_copy(update={"triggers": triggers}) + + +class TestTriggerValidation: + def validate(self, adventure: Adventure) -> str: + from osrlib.crawl.adventure import validate_adventure + from osrlib.data import load_equipment, load_monsters + from osrlib.errors import ContentValidationError + + with pytest.raises(ContentValidationError) as raised: + validate_adventure(adventure, load_monsters(), load_equipment()) + return str(raised.value) + + def test_a_clean_document_with_every_pattern_kind_validates(self): + from osrlib.crawl.adventure import validate_adventure + from osrlib.data import load_equipment, load_monsters + + triggers = ( + TriggerSpec(id="area", when=AreaEnteredPattern(dungeon_id="delve", level_number=1, area_id="room_a")), + TriggerSpec(id="level", when=LevelEnteredPattern(dungeon_id="delve", level_number=2)), + TriggerSpec(id="dungeon", when=DungeonEnteredPattern(dungeon_id="delve")), + TriggerSpec(id="town", when=TownEnteredPattern()), + TriggerSpec(id="item", when=ItemAcquiredPattern(item_id="holy_water")), + TriggerSpec(id="magic", when=ItemAcquiredPattern(item_id="potion_of_healing")), + TriggerSpec(id="monster", when=MonsterDefeatedPattern(template_id="goblin")), + TriggerSpec(id="flag", when=FlagSetPattern(key="anything.at.all")), + ) + validate_adventure(with_triggers(*triggers), load_monsters(), load_equipment()) + + def test_duplicate_trigger_ids_are_caught(self): + adventure = with_triggers( + TriggerSpec(id="twice", when=TownEnteredPattern()), + TriggerSpec(id="twice", when=TownEnteredPattern()), + ) + assert "trigger 'twice': id is not unique" in self.validate(adventure) + + def test_a_dangling_area_reference_is_caught(self): + adventure = with_triggers( + TriggerSpec(id="ghost", when=AreaEnteredPattern(dungeon_id="delve", level_number=1, area_id="nowhere")) + ) + message = self.validate(adventure) + assert "trigger 'ghost': pattern references unknown area 'nowhere'" in message + + def test_a_dangling_level_reference_is_caught(self): + adventure = with_triggers(TriggerSpec(id="deep", when=LevelEnteredPattern(dungeon_id="delve", level_number=9))) + assert "trigger 'deep': pattern references unknown 'delve' level 9" in self.validate(adventure) + + def test_a_dangling_dungeon_reference_is_caught(self): + adventure = with_triggers(TriggerSpec(id="elsewhere", when=DungeonEnteredPattern(dungeon_id="atlantis"))) + assert "trigger 'elsewhere': pattern references unknown dungeon 'atlantis'" in self.validate(adventure) + + def test_a_dangling_pattern_item_is_caught(self): + adventure = with_triggers(TriggerSpec(id="loot", when=ItemAcquiredPattern(item_id="jade_idol"))) + assert "trigger 'loot': pattern references unknown item 'jade_idol'" in self.validate(adventure) + + def test_a_dangling_pattern_monster_is_caught(self): + adventure = with_triggers(TriggerSpec(id="boss", when=MonsterDefeatedPattern(template_id="tarrasque"))) + assert "trigger 'boss': pattern references unknown monster 'tarrasque'" in self.validate(adventure) + + def test_a_dangling_condition_item_is_caught(self): + adventure = with_triggers( + TriggerSpec( + id="keyed", + when=TownEnteredPattern(), + conditions=(HasItemCondition(item_id="jade_idol"),), + ) + ) + assert "trigger 'keyed': condition references unknown item 'jade_idol'" in self.validate(adventure) + + def test_a_dangling_consequence_item_is_caught(self): + adventure = with_triggers( + TriggerSpec( + id="reward", + when=TownEnteredPattern(), + consequences=(GrantItem(character_id=PARTY_SELECTOR, item_id="jade_idol"),), + ) + ) + assert "trigger 'reward': consequence 0 references unknown item 'jade_idol'" in self.validate(adventure) + + def test_a_dangling_consequence_monster_is_caught(self): + from osrlib.crawl.commands import SpawnMonsters + + adventure = with_triggers( + TriggerSpec( + id="ambush", + when=TownEnteredPattern(), + consequences=(SpawnMonsters(template_id="tarrasque", count_fixed=1, distance_feet=30),), + ) + ) + assert "trigger 'ambush': consequence 0 references unknown monster 'tarrasque'" in self.validate(adventure) + + def test_a_consequence_door_must_exist_at_the_cell_and_direction(self): + adventure = with_triggers( + TriggerSpec( + id="portcullis", + when=TownEnteredPattern(), + consequences=( + SetDoorState(dungeon_id="delve", level_number=1, x=0, y=0, direction=Direction.NORTH, open=True), + ), + ) + ) + assert "trigger 'portcullis': consequence 0 names no door at (0, 0) north" in self.validate(adventure) + + def test_a_consequence_door_on_an_unknown_level_is_caught(self): + adventure = with_triggers( + TriggerSpec( + id="portcullis", + when=TownEnteredPattern(), + consequences=( + SetDoorState(dungeon_id="delve", level_number=9, x=0, y=0, direction=Direction.SOUTH, open=True), + ), + ) + ) + assert "trigger 'portcullis': consequence 0 references unknown 'delve' level 9" in self.validate(adventure) + + def test_a_consequence_placement_must_land_on_the_grid(self): + from osrlib.crawl.commands import PlaceParty + from osrlib.crawl.dungeon import PartyLocation + + adventure = with_triggers( + TriggerSpec( + id="teleport", + when=TownEnteredPattern(), + consequences=( + PlaceParty( + location=PartyLocation( + kind="dungeon", + dungeon_id="delve", + level_number=1, + position=(99, 99), + facing=Direction.EAST, + ) + ), + ), + ) + ) + assert "trigger 'teleport': consequence 0 places the party out of bounds" in self.validate(adventure) + + def test_a_literal_character_id_in_a_consequence_is_an_error(self): + adventure = with_triggers( + TriggerSpec( + id="reward", + when=TownEnteredPattern(), + consequences=(GrantItem(character_id="character-0001", item_id="holy_water"),), + ) + ) + message = self.validate(adventure) + assert "trigger 'reward': consequence 0 names character 'character-0001'" in message + assert PARTY_SELECTOR in message and FIRST_LIVING_SELECTOR in message + + def test_the_selectors_are_accepted_on_every_character_addressing_consequence(self): + from osrlib.crawl.adventure import validate_adventure + from osrlib.crawl.commands import AwardXP, GrantCoins + from osrlib.data import load_equipment, load_monsters + + adventure = with_triggers( + TriggerSpec( + id="reward", + when=TownEnteredPattern(), + consequences=( + GrantItem(character_id=PARTY_SELECTOR, item_id="holy_water"), + GrantCoins(character_id=FIRST_LIVING_SELECTOR, coins={"gp": 50}), + AwardXP(character_id=PARTY_SELECTOR, amount=100), + ), + ) + ) + validate_adventure(adventure, load_monsters(), load_equipment()) + + def test_a_document_written_before_triggers_parses_with_none(self): + payload = build_adventure(wandering_chance=0).model_dump(mode="json") + payload.pop("triggers") + assert Adventure.model_validate(payload).triggers == () + + def test_a_save_written_before_triggers_loads_unchanged(self): + import json + + from crawl_fixtures import build_party + from osrlib.crawl.commands import EnterDungeon + from osrlib.crawl.session import GameSession + from osrlib.persistence import load_game, save_game, session_state + + session = GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=5) + session.execute(EnterDungeon(dungeon_id="delve")) + document = json.loads(json.dumps(save_game(session))) + del document["payload"]["adventure"]["triggers"] + restored = load_game(document) + assert restored.adventure.triggers == () + assert session_state(restored) == session_state(session)