Skip to content
Merged
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- Authored quests, the state they advance, and the ending they reach. A `QuestSpec` (`osrlib.crawl.quests`) is adventure data like a trigger: an optional activation clause, `ObjectiveSpec`s each with a completion clause of their own, `rewards` from the same `ConsequenceCommand` surface under the same `@party`/`@first` selectors, a completion rule of `"all"` or `"any"`, and an optional `concludes_adventure` marker. Every "did this happen?" is a `TriggerClause` — a `TriggerPattern` plus the conditions gates use, matched by the same matcher and the same live evaluation triggers get, so the two surfaces cannot drift; a consuming condition is rejected at parse, because a quest observes and does not take. `Adventure.quests` carries them in document order, with an `Adventure.quest(id)` accessor, and `validate_adventure` walks every clause and reward through the same reference and party-selector checks a trigger gets. Quest state is engine-owned: `GameSession.quests` maps each quest id to a `QuestState` (`inactive` → `active` → `completed`) holding an `ObjectiveState` (`revealed`, `complete`) per objective, seeded at construction from the adventure — activation-less quests active from round 0, objectives visible unless authored `hidden` — so `new`, `load_game`, and replay all begin from the same block. Four referee commands are its only writers: `ActivateQuest`, `RevealObjective`, `CompleteObjective` (which reveals as it completes), and `CompleteQuest`, each emitting a player-visible event carrying the authored beat — `QuestActivatedEvent`, `ObjectiveRevealedEvent`, `ObjectiveCompletedEvent`, `QuestCompletedEvent`, and, when a concluding quest finishes outside a terminal mode, `AdventureCompletedEvent` as the session clears any open encounter or battle and enters `victory`. Unlike `MarkTriggerFired`'s open trigger id, their ids are a closed domain resolved against the adventure's own specs (`session.command.unknown_quest`, `session.command.unknown_objective`), and a command contradicting the state it finds rejects with `session.command.quest_state`; `CompleteQuest` deliberately checks only that the quest is active, because ruling a quest done is the referee's call. Each beat appends its display text to the journal as itself, with no `JournalEntryAddedEvent` behind it — the lifecycle event *is* that beat's event. The `Interpreter` plays all of it: per event, the adventure's triggers in document order and then its quests, issuing every lifecycle command, reward, and note stamped `source="quest:{id}"`, checking the completion rule the moment a completion it issued lands, and dropping a reward that cannot land — a spawn in `victory`, say — with a note rather than a raise. The block persists under a new `quests` key with no `schema_version` bump: a save written without it keeps the constructor's seed, and an adventure that authors no quests plays exactly as before.
- `PlayerView.quests` — the errands the party is on, as `QuestView`s: id, name, the offer beat, the narrative block's `speaker` attribution (a wire client holds no adventure document to resolve one from), and the revealed objectives as `ObjectiveView`s with `"incomplete"` or `"complete"` states. Active quests only, in document order: a quest nobody has been given is absent, and a finished one leaves the list, its record standing in the journal. Hidden objectives have no view at all until something surfaces them, and no clause, pattern, condition, reward, or guidance ever crosses — quest wiring is the game's secret exactly as trigger wiring is.
- `LevelSpec.guidance` — one ambient steering slot per dungeon level, for the tone of a place that hangs on no mechanical object. Inert authored data: the engine reads it nowhere, no event carries it, and it applies while the party occupies the level. A narrating front end reaches it through the adventure document, which is referee-side by construction, since the player view ships no level internals.
- 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.
Expand All @@ -28,12 +31,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Changed

- Both example front ends now author their fetch quest as adventure data and register the library's `Interpreter` to play it; the hand-rolled `FetchQuestListener` is deleted with no shim. The Jade Idol became a bundled `GearTemplate` placed in the shrine cache by id, so taking it reports a catalog id an `ItemAcquiredPattern` matches and a `has_item` condition tests, and the homecoming objective is a `TownEnteredPattern` narrowed by that condition — walking back empty-handed is not a return. The quest concludes the adventure, so the milestone transcript restructured into two trips: the delve and the town business first, because selling and healing are illegal once the session is in `victory`, then back down for the idol and home to the completion, the rewards, and the ending — the TUI gaining a `give` verb along the way, because a sold haul is a purse full of coin and coin weighs a coin apiece. Its economics moved with it — the idol is mundane gear now, worth no treasure XP by RAW, and the reward lands in town after the last award has fired — so the authored `AwardXP` rose from 600 to 1200 per member, which restores the run's XP totals exactly. The example listener was the extension-surface proof; the guides keep teaching that pattern with a self-contained listener of their own, and the interpreter as the shipped instance of it.
- 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

- Naming one magic item twice in a single `GiveItems` or `DropItems` now rejects instead of raising. A magic instance id names exactly one instance, and the whole instance leaves its owner on the first naming — but validation counted only mundane ids, so the second naming passed the pre-phase and then found nothing: `GiveItems` raised a `ValueError` out of `execute`, breaking the contract that a schema-valid command rejects rather than throws, and `DropItems` silently half-ran. Both now answer `exploration.item.not_carried` in the validation pre-phase, mutating nothing.
- 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.
Expand Down
Loading
Loading