From abbd4fe248f946e59694cbea1f117a140c10b096 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 6 Aug 2026 22:27:18 -0700 Subject: [PATCH 1/9] The quest vocabulary, the lifecycle commands, and the state they advance Work items 1-5 of the phase 15 plan: TriggerClause/ObjectiveSpec/QuestSpec in the new crawl/quests.py; the four lifecycle commands with their closed id domain and three rejection codes; the five player-visible quest events; QuestState/ObjectiveState seeded at session construction (activation-less quests active from round 0); the handlers with the beat mapping, the victory transition, and terminal stickiness; and the quests block in persistence with no schema bump. Claude-Session: https://claude.ai/code/session_01NQ83sqNnXVeXEd55NK1xR7 --- src/osrlib/crawl/adventure.py | 28 ++ src/osrlib/crawl/commands.py | 195 +++++++++- src/osrlib/crawl/events.py | 111 ++++++ src/osrlib/crawl/quests.py | 210 +++++++++++ src/osrlib/crawl/session.py | 226 ++++++++++- src/osrlib/messages.py | 7 + src/osrlib/persistence.py | 25 +- tests/test_commands.py | 16 +- tests/test_crawl_properties.py | 7 + tests/test_events_kernel.py | 7 + tests/test_quests.py | 643 ++++++++++++++++++++++++++++++++ tools/docs/rejection_codes.json | 3 + 12 files changed, 1457 insertions(+), 21 deletions(-) create mode 100644 src/osrlib/crawl/quests.py create mode 100644 tests/test_quests.py diff --git a/src/osrlib/crawl/adventure.py b/src/osrlib/crawl/adventure.py index c0bf9a5..9e51e8a 100644 --- a/src/osrlib/crawl/adventure.py +++ b/src/osrlib/crawl/adventure.py @@ -28,6 +28,7 @@ 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.quests import QuestSpec from osrlib.crawl.triggers import ( FIRST_LIVING_SELECTOR, PARTY_SELECTOR, @@ -92,6 +93,12 @@ class Adventure(BaseModel): 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. + + `quests` are the adventure's authored + [`QuestSpec`][osrlib.crawl.quests.QuestSpec]s, in document order too: a session + seeds one state block per quest at construction, in this order, and every walk + over them follows it. Quest ids and trigger ids are separate namespaces — they + live in separate state blocks — so a quest and a trigger may share an id. """ model_config = ConfigDict(frozen=True) @@ -104,6 +111,7 @@ class Adventure(BaseModel): monsters: tuple[MonsterTemplate, ...] = () items: tuple[ItemTemplate, ...] = () triggers: tuple[TriggerSpec, ...] = () + quests: tuple[QuestSpec, ...] = () @model_validator(mode="after") def _dungeon_ids_unique(self) -> Adventure: @@ -129,6 +137,26 @@ def dungeon(self, dungeon_id: str) -> DungeonSpec: return dungeon raise ValueError(f"unknown dungeon id {dungeon_id!r}") + def quest(self, quest_id: str) -> QuestSpec: + """Return the quest with `quest_id`. + + The resolution behind the quest lifecycle commands' closed id domain: an id + this cannot answer names no quest of this adventure. + + Args: + quest_id: The quest id. + + Returns: + The quest spec. + + Raises: + ValueError: If no quest has that id. + """ + for quest in self.quests: + if quest.id == quest_id: + return quest + raise ValueError(f"unknown quest id {quest_id!r}") + def _effective_monsters(adventure: Adventure, base: MonsterCatalog) -> tuple[MonsterCatalog, tuple[str, ...]]: """Build the adventure's effective monster catalog: base ∪ bundled, first occurrence wins. diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 8538ce3..b4021c8 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -38,6 +38,7 @@ __all__ = [ "ALL_COMMAND_CLASSES", "CONSEQUENCE_COMMAND_CLASSES", + "ActivateQuest", "AddJournalEntry", "AdvanceTime", "AnyCommand", @@ -47,6 +48,8 @@ "CloseDoor", "Command", "CommandResult", + "CompleteObjective", + "CompleteQuest", "ConsequenceCommand", "DropItems", "EngageBattle", @@ -77,6 +80,7 @@ "ReorderParty", "ResolveBattleRound", "Rest", + "RevealObjective", "RollDice", "Search", "SessionMode", @@ -1848,6 +1852,14 @@ class MarkTriggerFired(Command): trigger is accepted and changes nothing: session state records that a trigger *has* fired, while each mark in the command log records *one* firing. + `trigger_id` is an **open domain**: a mark records that something fired, needs no + authored trigger behind it, and a game drives it with ids from its own systems. + The quest lifecycle commands invert that deliberately — + [`ActivateQuest`][osrlib.crawl.commands.ActivateQuest] and its three siblings + resolve their ids against the adventure's quest specs, because the state they + advance is projected into the player view and an id with no spec behind it has + nothing to show. + Modes: `town`, `exploring`, `encounter`, `battle`, `game_over`, `victory` @@ -1918,6 +1930,170 @@ class RecordNote(Command): text: str = Field(min_length=1) +class ActivateQuest(Command): + """Referee: put an authored quest into play. + + The first of the four commands that drive an adventure's quest state — a + per-quest status (`inactive` → `active` → `completed`) and, under it, a + revealed/complete pair per objective. That state is engine-owned session state + beside the flag store, and these four are its only writers, so a replay rebuilds + it by re-executing the log. + + Quest and objective ids are a **closed domain**: they resolve against the + adventure's [`QuestSpec`][osrlib.crawl.quests.QuestSpec]s, and an id no quest + spec holds is rejected. That is the deliberate opposite of + [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired]'s open trigger id: + a mark is bookkeeping a game may drive with ids from its own systems, while an + activated quest is projected into the player view with a name, an offer, and an + objective list — an id with no quest behind it has none of them. + + An accepted activation appends the quest's `offer` beat to the journal when its + author wrote one, and the event carries the same line; the append emits no + [`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent], because + the lifecycle event *is* that beat's event. Quest state is monotonic, so only an + `inactive` quest activates. Referee commands are legal in every mode, terminal + modes included. + + Modes: + `town`, `exploring`, `encounter`, `battle`, `game_over`, `victory` + + Rejections: + - `session.command.unknown_quest` — `quest_id` names no quest of the + adventure. + - `session.command.quest_state` — the quest is already active or already + completed; the rejection names the quest and the state that refused it. + + Events: + [`QuestActivatedEvent`][osrlib.crawl.events.QuestActivatedEvent] with the + quest id, the quest's name, and the offer beat. + """ + + command_type: Literal["activate_quest"] = "activate_quest" + quest_id: str = Field(min_length=1) + + +class RevealObjective(Command): + """Referee: surface a hidden objective of an active quest. + + A hidden objective is absent from the player view until it is revealed or until + it completes — completing an objective reveals it, so a quest whose hidden + objective simply lands needs no reveal at all. Ids are the closed domain + [`ActivateQuest`][osrlib.crawl.commands.ActivateQuest] documents. + + An accepted reveal appends the objective's `offer` beat to the journal when its + author wrote one, and the event carries the same line; no + [`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] follows, + because the lifecycle event is that beat's event. Referee commands are legal in + every mode, terminal modes included. + + Modes: + `town`, `exploring`, `encounter`, `battle`, `game_over`, `victory` + + Rejections: + - `session.command.unknown_quest` — `quest_id` names no quest of the + adventure. + - `session.command.unknown_objective` — `objective_id` names no objective of + that quest. + - `session.command.quest_state` — the quest is not active, or the objective + is already visible or already complete; the rejection names the quest and + the state that refused it. + + Events: + [`ObjectiveRevealedEvent`][osrlib.crawl.events.ObjectiveRevealedEvent] with + the quest id, the objective id, and the objective's offer beat. + """ + + command_type: Literal["reveal_objective"] = "reveal_objective" + quest_id: str = Field(min_length=1) + objective_id: str = Field(min_length=1) + + +class CompleteObjective(Command): + """Referee: mark one objective of an active quest done. + + Completing surfaces a hidden objective on the way: an objective the party + finished before it was ever announced is revealed and complete in one step, with + no separate [`RevealObjective`][osrlib.crawl.commands.RevealObjective]. Ids are + the closed domain [`ActivateQuest`][osrlib.crawl.commands.ActivateQuest] + documents. + + Completing the last objective a quest's completion rule needs does *not* + complete the quest: [`CompleteQuest`][osrlib.crawl.commands.CompleteQuest] is its + own command, so whoever drives the quest layer decides when the rule is + satisfied. An accepted completion appends the objective's `progress` beat to the + journal when its author wrote one, and the event carries the same line; no + [`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] follows. + Referee commands are legal in every mode, terminal modes included. + + Modes: + `town`, `exploring`, `encounter`, `battle`, `game_over`, `victory` + + Rejections: + - `session.command.unknown_quest` — `quest_id` names no quest of the + adventure. + - `session.command.unknown_objective` — `objective_id` names no objective of + that quest. + - `session.command.quest_state` — the quest is not active, or the objective + is already complete; the rejection names the quest and the state that + refused it. + + Events: + [`ObjectiveCompletedEvent`][osrlib.crawl.events.ObjectiveCompletedEvent] with + the quest id, the objective id, and the objective's progress beat. + """ + + command_type: Literal["complete_objective"] = "complete_objective" + quest_id: str = Field(min_length=1) + objective_id: str = Field(min_length=1) + + +class CompleteQuest(Command): + """Referee: finish an active quest — and, on the concluding quest, the adventure. + + The quest must be active, and that is the whole test: the completion rule is + *not* checked here. Ruling a quest done is the referee's call, and an authored + quest layer is simply a disciplined issuer that checks the rule before issuing. + Ids are the closed domain + [`ActivateQuest`][osrlib.crawl.commands.ActivateQuest] documents. + + Rewards are not this command's business: whoever completes the quest issues the + authored rewards afterwards as ordinary commands of their own, so a completion + driven by hand grants nothing and every reward that does land is a line in the + log. The completion appends the quest's `completion` beat to the journal when + its author wrote one, and the events carry the same line; no + [`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] follows. + + **The victory transition.** Completing a quest whose spec carries + `concludes_adventure` from a non-terminal mode clears any open encounter and + battle — a concluded session holds no live play state — and switches the session + to `victory`. This is the one entrance to that mode. From a terminal mode + (`game_over` or `victory`) the quest still completes and still journals, but + nothing transitions and no adventure-completed event lands: an ended session + never ends again, so the record shows a fallen party finishing the job without + resurrecting the adventure around it. Referee commands are legal in every mode, + terminal modes included. + + Modes: + `town`, `exploring`, `encounter`, `battle`, `game_over`, `victory` + + Rejections: + - `session.command.unknown_quest` — `quest_id` names no quest of the + adventure. + - `session.command.quest_state` — the quest is inactive or already + completed; the rejection names the quest and the state that refused it. + + Events: + [`QuestCompletedEvent`][osrlib.crawl.events.QuestCompletedEvent] with the + quest id, the quest's name, and the completion beat, followed by + [`AdventureCompletedEvent`][osrlib.crawl.events.AdventureCompletedEvent] + carrying the same beat when the quest concludes the adventure and the + session had not already ended. + """ + + command_type: Literal["complete_quest"] = "complete_quest" + quest_id: str = Field(min_length=1) + + ALL_COMMAND_CLASSES: tuple[type[Command], ...] = ( MoveParty, TurnParty, @@ -1969,6 +2145,10 @@ class RecordNote(Command): MarkTriggerFired, AddJournalEntry, RecordNote, + ActivateQuest, + RevealObjective, + CompleteObjective, + CompleteQuest, ) """Every command class — the discriminated union's members, in a stable wire order.""" @@ -1992,13 +2172,18 @@ class RecordNote(Command): """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: +Referee commands sit outside the surface for one of three reasons: - [`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. + [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry], + [`RecordNote`][osrlib.crawl.commands.RecordNote], + [`ActivateQuest`][osrlib.crawl.commands.ActivateQuest], + [`RevealObjective`][osrlib.crawl.commands.RevealObjective], + [`CompleteObjective`][osrlib.crawl.commands.CompleteObjective], and + [`CompleteQuest`][osrlib.crawl.commands.CompleteQuest] are the vocabulary the + trigger and quest interpreter writes its own bookkeeping in — it marks, journals, + annotates, and advances quest state 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 diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 0119c23..7d4f6b7 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -23,6 +23,7 @@ __all__ = [ "ALL_EVENT_CLASSES", + "AdventureCompletedEvent", "AdventureXpAwardEvent", "AnyEvent", "BattleEndedEvent", @@ -61,9 +62,13 @@ "MonstersSpawnedEvent", "NoteRecordedEvent", "NpcPartySpawnedEvent", + "ObjectiveCompletedEvent", + "ObjectiveRevealedEvent", "PartyMovedEvent", "ProvisionsEvent", "PursuitEvent", + "QuestActivatedEvent", + "QuestCompletedEvent", "RestedEvent", "SearchCompletedEvent", "SpellDeclaredEvent", @@ -842,6 +847,15 @@ class JournalEntryAddedEvent(Event): content data in a structured field, not engine-baked English — the event still carries its message code and its facts — and `rounds` is the clock position the entry landed at, the same stamp the stored entry carries. + + It is not the only event a growing journal emits. A quest beat appends its entry + and reports itself through its own lifecycle event — the whole set is this event + plus [`QuestActivatedEvent`][osrlib.crawl.events.QuestActivatedEvent], + [`ObjectiveRevealedEvent`][osrlib.crawl.events.ObjectiveRevealedEvent], + [`ObjectiveCompletedEvent`][osrlib.crawl.events.ObjectiveCompletedEvent], and + [`QuestCompletedEvent`][osrlib.crawl.events.QuestCompletedEvent] — because + emitting both for one beat would report the same line to the table twice. A + client that wants the whole journal reads it from the view. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.journal.entry_added"}) @@ -868,6 +882,98 @@ class NoteRecordedEvent(Event): text: str +class QuestActivatedEvent(Event): + """An authored quest came into play — the table's news, not the wiring behind it. + + Player-visible: a quest the party has taken on is theirs to know, while the + clause that started it stays behind the screen with the trigger and flag events. + `narrative` is the quest's authored offer beat, `None` when unauthored — content + data in a structured field, not engine-baked English, appended verbatim by the + default formatter after the templated line. The same beat is appended to the + journal, so this event and its entry are one report of one moment. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.quest.activated"}) + + event_type: Literal["quest_activated"] = "quest_activated" + code: str = "session.quest.activated" + visibility: Visibility = Visibility.PLAYER + quest_id: str + name: str + narrative: str | None = None + + +class ObjectiveRevealedEvent(Event): + """A hidden objective surfaced: the party can see what it is being asked for. + + `narrative` is the objective's authored offer beat, `None` when unauthored, and + the journal carries the same line. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.quest.objective_revealed"}) + + event_type: Literal["objective_revealed"] = "objective_revealed" + code: str = "session.quest.objective_revealed" + visibility: Visibility = Visibility.PLAYER + quest_id: str + objective_id: str + narrative: str | None = None + + +class ObjectiveCompletedEvent(Event): + """One objective of a quest is done — including one nobody had announced yet. + + `narrative` is the objective's authored progress beat, `None` when unauthored, + and the journal carries the same line. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.quest.objective_completed"}) + + event_type: Literal["objective_completed"] = "objective_completed" + code: str = "session.quest.objective_completed" + visibility: Visibility = Visibility.PLAYER + quest_id: str + objective_id: str + narrative: str | None = None + + +class QuestCompletedEvent(Event): + """A quest is finished, however the ruling was reached. + + `narrative` is the quest's authored completion beat, `None` when unauthored, and + the journal carries the same line. Rewards, when the quest pays any, land as + their own commands and their own events after this one. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.quest.completed"}) + + event_type: Literal["quest_completed"] = "quest_completed" + code: str = "session.quest.completed" + visibility: Visibility = Visibility.PLAYER + quest_id: str + name: str + narrative: str | None = None + + +class AdventureCompletedEvent(Event): + """The adventure is over in triumph: the session is in `victory`. + + Follows the [`QuestCompletedEvent`][osrlib.crawl.events.QuestCompletedEvent] of + the quest that concludes the adventure, and carries the same completion beat. + The transition happens once and only from a session still in play — a party that + finishes the job after it has already fallen completes the quest and gets no + ending event. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.adventure.completed"}) + + event_type: Literal["adventure_completed"] = "adventure_completed" + code: str = "session.adventure.completed" + visibility: Visibility = Visibility.PLAYER + quest_id: str + narrative: str | None = None + + CRAWL_EVENT_CLASSES: tuple[type[Event], ...] = ( PartyMovedEvent, LocationEnteredEvent, @@ -919,6 +1025,11 @@ class NoteRecordedEvent(Event): TriggerFiredEvent, JournalEntryAddedEvent, NoteRecordedEvent, + QuestActivatedEvent, + ObjectiveRevealedEvent, + ObjectiveCompletedEvent, + QuestCompletedEvent, + AdventureCompletedEvent, ) """Every crawl event class, in declaration order.""" diff --git a/src/osrlib/crawl/quests.py b/src/osrlib/crawl/quests.py new file mode 100644 index 0000000..ac248ec --- /dev/null +++ b/src/osrlib/crawl/quests.py @@ -0,0 +1,210 @@ +"""Authored quests: the matching clause, the objective spec, and the quest spec. + +A quest composes the trigger vocabulary rather than introducing one of its own. An +activation, an objective's completion, and a hidden objective's reveal are each a +[`TriggerClause`][osrlib.crawl.quests.TriggerClause]: a +[`TriggerPattern`][osrlib.crawl.triggers.TriggerPattern] naming the observable, plus +the [`ConditionSpec`][osrlib.crawl.gates.ConditionSpec]s that must hold when it +matches — the same edge-triggered patterns and the same live condition evaluation an +authored [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec] uses. Rewards are the +same [`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand] surface under +the same party selectors ([`PARTY_SELECTOR`][osrlib.crawl.triggers.PARTY_SELECTOR] +and [`FIRST_LIVING_SELECTOR`][osrlib.crawl.triggers.FIRST_LIVING_SELECTOR]). + +A quest observes; it does not take. A clause condition with `consumes=True` is +rejected at parse for the reason a trigger's is: the event a clause matches has +already happened, so there is no attempt of the quest's own to charge a toll +against. + +Document order is the order of the +[`Adventure.quests`][osrlib.crawl.adventure.Adventure] tuple, and an objective's +order is its position in +[`QuestSpec.objectives`][osrlib.crawl.quests.QuestSpec] — the order a session's quest +state ([`QuestState`][osrlib.crawl.session.QuestState]) keys its objectives in, so +every walk over either is deterministic. +""" + +from typing import 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 +from osrlib.crawl.triggers import TriggerPattern + +__all__ = [ + "ObjectiveSpec", + "QuestSpec", + "TriggerClause", +] + + +class TriggerClause(BaseModel): + """One matching clause: the observable, and what must hold when it happens. + + `conditions` all have to hold: the tuple is an AND with no combinators, each + condition evaluated live against session state at the moment of the match, + through [`condition_holds`][osrlib.crawl.gates.condition_holds]. The field is + `pattern` rather than `when`, so an objective's completion clause reads + `objective.when.pattern`. + + Examples: + ```python + from osrlib.crawl.gates import HasItemCondition + from osrlib.crawl.quests import TriggerClause + from osrlib.crawl.triggers import TownEnteredPattern + + walked_home_carrying_it = TriggerClause( + pattern=TownEnteredPattern(), + conditions=(HasItemCondition(item_id="holy_water"),), + ) + assert walked_home_carrying_it.pattern.pattern_type == "town_entered" + ``` + """ + + model_config = ConfigDict(frozen=True) + + pattern: TriggerPattern + conditions: tuple[ConditionSpec, ...] = () + + @model_validator(mode="after") + def _conditions_never_consume(self) -> TriggerClause: + """A clause's conditions are tests, never tolls. + + Consumption is an effect of a *successful command*, reported through that + command's events. A clause 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 quest condition cannot consume: a quest observes, it does not take") + return self + + +class ObjectiveSpec(BaseModel): + """One objective: how it completes, whether it starts hidden, and its text. + + Objectives are monotonic — hidden becomes revealed, incomplete becomes complete, + and neither goes back — because the quest vocabulary authors no repeat. + + A hidden objective with no `reveal_when` is a normal shape: it surfaces when it + completes, because completing an objective reveals it. `reveal_when` on an + objective that starts visible is rejected at parse — a reveal clause for + something already on the list is authored dead weight. + + `narrative` carries the objective's own beats: `offer` is the line its reveal + shows and journals, `progress` the line its completion shows and journals. + + Examples: + ```python + from osrlib.crawl.narrative import NarrativeBlock + from osrlib.crawl.quests import ObjectiveSpec, TriggerClause + from osrlib.crawl.triggers import ItemAcquiredPattern + + recover = ObjectiveSpec( + id="recover-idol", + when=TriggerClause(pattern=ItemAcquiredPattern(item_id="holy_water")), + narrative=NarrativeBlock(progress="The flask is yours; the shrine is quiet again."), + ) + assert not recover.hidden and recover.reveal_when is None + ``` + """ + + model_config = ConfigDict(frozen=True) + + id: str = Field(min_length=1) + when: TriggerClause + hidden: bool = False + reveal_when: TriggerClause | None = None + narrative: NarrativeBlock | None = None + + @model_validator(mode="after") + def _only_a_hidden_objective_reveals(self) -> ObjectiveSpec: + """A reveal clause belongs to an objective the party cannot see yet.""" + if self.reveal_when is not None and not self.hidden: + raise ValueError(f"objective {self.id!r} is not hidden, so reveal_when would never be read") + return self + + +class QuestSpec(BaseModel): + """One authored quest: when it starts, what it asks for, and what it pays. + + `activation` absent means the quest is active from session start — a standing + charge the party carries from round 0, with no activation beat to show, because + there is no command channel before the first command. An authored clause makes + activation an event the party crosses. + + `completion` is `"all"` (every objective) or `"any"` (the first one to land). + `objectives` holds at least one: an objective-less quest under the all rule would + be born complete. `concludes_adventure=True` marks the quest whose completion + ends the adventure in `victory` + ([`CompleteQuest`][osrlib.crawl.commands.CompleteQuest]). + + `rewards` are issued after the quest completes, in authored order, with the party + selectors expanded to the members they name; an authored `source` is rejected at + parse, because whoever issues a command stamps it. + + `narrative` carries the quest's own beats: `offer` is the line its activation + shows and journals, `completion` the line its completion shows and journals. + Per-objective beats live on the objectives. + + Examples: + ```python + from osrlib.crawl.commands import AwardXP + from osrlib.crawl.narrative import NarrativeBlock + from osrlib.crawl.quests import ObjectiveSpec, QuestSpec, TriggerClause + from osrlib.crawl.triggers import PARTY_SELECTOR, DungeonEnteredPattern, ItemAcquiredPattern + + recover = ObjectiveSpec(id="recover", when=TriggerClause(pattern=ItemAcquiredPattern(item_id="holy_water"))) + errand = QuestSpec( + id="the-flask", + name="The Stolen Reliquary", + activation=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="barrow")), + objectives=(recover,), + rewards=(AwardXP(character_id=PARTY_SELECTOR, amount=200),), + concludes_adventure=True, + narrative=NarrativeBlock( + offer="Sister Halda wants the reliquary back, and she is not asking twice.", + completion="The flask returns to its niche. The temple bells answer.", + ), + ) + assert errand.completion == "all" + ``` + """ + + model_config = ConfigDict(frozen=True) + + id: str = Field(min_length=1) + name: str = Field(min_length=1) + activation: TriggerClause | None = None + objectives: tuple[ObjectiveSpec, ...] = Field(min_length=1) + rewards: tuple[ConsequenceCommand, ...] = () + completion: Literal["all", "any"] = "all" + concludes_adventure: bool = False + narrative: NarrativeBlock | None = None + + @model_validator(mode="after") + def _objective_ids_unique(self) -> QuestSpec: + """Objective ids are quest-scoped: unique here, free to repeat elsewhere. + + Two quests may both name an objective `"return"`; one quest may not, because + its state keys its objectives by id. + """ + ids = [objective.id for objective in self.objectives] + if len(set(ids)) != len(ids): + raise ValueError(f"quest {self.id!r}: objective ids must be unique within the quest") + return self + + @model_validator(mode="after") + def _rewards_carry_no_source(self) -> QuestSpec: + """The `source` stamp belongs to whoever issues the command, not the document. + + Rewards are issued stamped with the quest'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, reward in enumerate(self.rewards): + if reward.source is not None: + raise ValueError(f"reward {position} carries a source; the issuing quest stamps it") + return self diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index 2c2c4a1..7f1345b 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -11,7 +11,8 @@ [`EffectsLedger`][osrlib.core.effects.EffectsLedger], the [`GameClock`][osrlib.core.clock.GameClock], the entity registry (characters and live monster instances), the flag store, the trigger fired-marks, the journal, the -listener-state store, the command and event logs, the mode, and the crawl state. +quest state, the listener-state store, the command and event logs, the mode, and +the crawl state. `execute(command)` runs a pure validation pre-phase: a rejected command consumes no RNG draws, no clock time, mutates nothing, and is excluded from the command @@ -30,7 +31,7 @@ # are handled at the bottom of this module. from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Literal, Protocol from pydantic import BaseModel, ConfigDict, Field @@ -54,17 +55,21 @@ from osrlib.core.validation import Rejection from osrlib.crawl.adventure import Adventure, _effective_equipment, _effective_monsters, validate_adventure from osrlib.crawl.commands import ( + ActivateQuest, AddJournalEntry, AdvanceTime, AwardXP, Command, CommandResult, + CompleteObjective, + CompleteQuest, GrantCoins, GrantItem, IdentifyItem, MarkTriggerFired, PlaceParty, RecordNote, + RevealObjective, RollDice, SessionMode, SetDoorState, @@ -74,6 +79,7 @@ ) from osrlib.crawl.dungeon import DungeonState, edge_ref from osrlib.crawl.events import ( + AdventureCompletedEvent, CharacterLeveledUpEvent, DiceRolledEvent, DoorEvent, @@ -85,11 +91,16 @@ LocationEnteredEvent, MonstersSpawnedEvent, NoteRecordedEvent, + ObjectiveCompletedEvent, + ObjectiveRevealedEvent, + QuestActivatedEvent, + QuestCompletedEvent, TimeAdvancedEvent, TriggerFiredEvent, XpAwardedEvent, ) from osrlib.crawl.party import Party +from osrlib.crawl.quests import ObjectiveSpec, QuestSpec from osrlib.data import load_equipment, load_monsters from osrlib.errors import ContentValidationError from osrlib.versioning import SCHEMA_VERSION, engine_version @@ -112,6 +123,8 @@ "LIGHT_EFFECT_KINDS", "Listener", "MONSTER_ACTION_STREAM", + "ObjectiveState", + "QuestState", "WANDERING_STREAM", ] @@ -168,6 +181,40 @@ class JournalEntry(BaseModel): rounds: int = Field(ge=0) +class ObjectiveState(BaseModel): + """One objective's live state: whether the party can see it, and whether it is done. + + Both flags are monotonic — hidden becomes revealed and incomplete becomes + complete, never the other way — because the quest vocabulary authors no repeat. + Completing an objective also reveals it: an objective the party finished before + anyone announced it is a thing they can now be told about. + """ + + model_config = ConfigDict(validate_assignment=True) + + revealed: bool + complete: bool + + +class QuestState(BaseModel): + """One quest's live state: its status, and the state of each of its objectives. + + `status` runs `inactive` → `active` → `completed` and never backwards. A quest + with no authored activation is seeded `active` at session construction — it is a + standing charge, and there is no command channel before the first command — while + the rest wait for [`ActivateQuest`][osrlib.crawl.commands.ActivateQuest]. + + `objectives` is keyed by objective id in the order + [`QuestSpec.objectives`][osrlib.crawl.quests.QuestSpec] authored them, so every + walk over the block is deterministic. + """ + + model_config = ConfigDict(validate_assignment=True) + + status: Literal["inactive", "active", "completed"] + objectives: dict[str, ObjectiveState] + + class DefeatedMonsterRecord(BaseModel): """One defeated monster — the XP award's input.""" @@ -269,12 +316,13 @@ class GameSession: command on `command_log`, and `save_game`/`load_game` round-trip the whole session deterministically: same seed, same commands, same game. - The trigger fired-marks (`fired_triggers`, in first-fired order) and the - `journal` are engine-owned session state beside the flag store: the lifecycle - commands [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired] and - [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry] are their only - writers, so a replay — which runs with no listeners registered — rebuilds both - by re-executing the command log. + The trigger fired-marks (`fired_triggers`, in first-fired order), the `journal`, + and the quest block (`quests`) are engine-owned session state beside the flag + store: the lifecycle commands + [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], + [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry], and the four quest + commands are their only writers, so a replay — which runs with no listeners + registered — rebuilds all three by re-executing the command log. """ def __init__( @@ -320,6 +368,21 @@ def __init__( self.flags: dict[str, str | int | bool] = {} self.fired_triggers: list[str] = [] self.journal: list[JournalEntry] = [] + # One state block per authored quest, in document order, seeded here so that + # every path which builds a session — new, load, replay — starts from the + # same block. A quest with no activation clause is a standing charge, active + # from round 0 because there is no command channel before the first command; + # the rest wait to be activated. Objectives start visible unless hidden. + self.quests: dict[str, QuestState] = { + quest.id: QuestState( + status="active" if quest.activation is None else "inactive", + objectives={ + objective.id: ObjectiveState(revealed=not objective.hidden, complete=False) + for objective in quest.objectives + }, + ) + for quest in adventure.quests + } self.listener_state: dict[str, dict] = {} self.listeners: list[Listener] = [] self.command_log: list[Command] = [] @@ -1027,6 +1090,149 @@ def _handle_record_note(session: GameSession, command: RecordNote) -> tuple[list return [], [NoteRecordedEvent(text=command.text)] +# ---------------------------------------------------------------------- quest handlers +# +# Pure bookkeeping, all four: no draw, no clock, no interaction with the wipe check. +# Ids resolve against the adventure's quest specs and the state block seeded from +# them, and every guard is a rejection — so the accepted log holds a state-consistent +# sequence and a replay never meets a refusal. + + +def _quest_pair(session: GameSession, quest_id: str) -> tuple[QuestSpec, QuestState] | None: + """The quest's authored spec and its live state, or `None` when the id names neither.""" + try: + spec = session.adventure.quest(quest_id) + except ValueError: + return None + state = session.quests.get(quest_id) + return None if state is None else (spec, state) + + +def _objective_pair( + spec: QuestSpec, state: QuestState, objective_id: str +) -> tuple[ObjectiveSpec, ObjectiveState] | None: + """The objective's authored spec and its live state, or `None` when the quest has none.""" + objective = next((entry for entry in spec.objectives if entry.id == objective_id), None) + objective_state = state.objectives.get(objective_id) + return None if objective is None or objective_state is None else (objective, objective_state) + + +def _unknown_quest(quest_id: str) -> tuple[list[Rejection], list[Event]]: + """The closed domain's answer to an id no quest of the adventure holds.""" + return [Rejection(code="session.command.unknown_quest", params={"quest": quest_id})], [] + + +def _unknown_objective(quest_id: str, objective_id: str) -> tuple[list[Rejection], list[Event]]: + """The same answer one level down: the quest is real, this objective of it is not.""" + return [ + Rejection(code="session.command.unknown_objective", params={"quest": quest_id, "objective": objective_id}) + ], [] + + +def _quest_state_refused(params: dict[str, int | str | tuple[int | str, ...]]) -> tuple[list[Rejection], list[Event]]: + """A lifecycle command that contradicts the state it found, which names it.""" + return [Rejection(code="session.command.quest_state", params=params)], [] + + +def _append_quest_beat(session: GameSession, text: str) -> None: + """Append a quest beat to the journal, stamped with the clock it landed at. + + The same [`JournalEntry`][osrlib.crawl.session.JournalEntry] construction + [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry] makes, and no + [`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] behind it: + the quest's own lifecycle event *is* this beat's event, and emitting both would + show the table one line twice. An unauthored beat appends nothing. + """ + if text: + session.journal.append(JournalEntry(text=text, rounds=session.clock.rounds)) + + +def _handle_activate_quest(session: GameSession, command: ActivateQuest) -> tuple[list[Rejection], list[Event]]: + pair = _quest_pair(session, command.quest_id) + if pair is None: + return _unknown_quest(command.quest_id) + spec, state = pair + if state.status != "inactive": + return _quest_state_refused({"quest": command.quest_id, "state": state.status}) + state.status = "active" + beat = spec.narrative.offer if spec.narrative is not None else "" + _append_quest_beat(session, beat) + return [], [QuestActivatedEvent(quest_id=spec.id, name=spec.name, narrative=beat or None)] + + +def _handle_reveal_objective(session: GameSession, command: RevealObjective) -> tuple[list[Rejection], list[Event]]: + pair = _quest_pair(session, command.quest_id) + if pair is None: + return _unknown_quest(command.quest_id) + spec, state = pair + found = _objective_pair(spec, state, command.objective_id) + if found is None: + return _unknown_objective(command.quest_id, command.objective_id) + objective, objective_state = found + if state.status != "active": + return _quest_state_refused({"quest": command.quest_id, "state": state.status}) + # A completed objective is a revealed one, so the more specific state answers first. + if objective_state.complete or objective_state.revealed: + return _quest_state_refused( + { + "quest": command.quest_id, + "objective": command.objective_id, + "state": "complete" if objective_state.complete else "revealed", + } + ) + objective_state.revealed = True + beat = objective.narrative.offer if objective.narrative is not None else "" + _append_quest_beat(session, beat) + return [], [ObjectiveRevealedEvent(quest_id=spec.id, objective_id=objective.id, narrative=beat or None)] + + +def _handle_complete_objective(session: GameSession, command: CompleteObjective) -> tuple[list[Rejection], list[Event]]: + pair = _quest_pair(session, command.quest_id) + if pair is None: + return _unknown_quest(command.quest_id) + spec, state = pair + found = _objective_pair(spec, state, command.objective_id) + if found is None: + return _unknown_objective(command.quest_id, command.objective_id) + objective, objective_state = found + if state.status != "active": + return _quest_state_refused({"quest": command.quest_id, "state": state.status}) + if objective_state.complete: + return _quest_state_refused({"quest": command.quest_id, "objective": command.objective_id, "state": "complete"}) + objective_state.complete = True + # Completing surfaces a hidden objective: no separate reveal, and the player view + # never has to explain a quest that finished something it never mentioned. + objective_state.revealed = True + beat = objective.narrative.progress if objective.narrative is not None else "" + _append_quest_beat(session, beat) + return [], [ObjectiveCompletedEvent(quest_id=spec.id, objective_id=objective.id, narrative=beat or None)] + + +def _handle_complete_quest(session: GameSession, command: CompleteQuest) -> tuple[list[Rejection], list[Event]]: + pair = _quest_pair(session, command.quest_id) + if pair is None: + return _unknown_quest(command.quest_id) + spec, state = pair + # The quest must be active, and that is the whole test: the completion rule is + # the issuer's discipline, not the handler's, because ruling a quest done is the + # referee's call. + if state.status != "active": + return _quest_state_refused({"quest": command.quest_id, "state": state.status}) + state.status = "completed" + beat = spec.narrative.completion if spec.narrative is not None else "" + _append_quest_beat(session, beat) + events: list[Event] = [QuestCompletedEvent(quest_id=spec.id, name=spec.name, narrative=beat or None)] + if spec.concludes_adventure and not session.mode.terminal: + # The one entrance to victory. A concluded session holds no live play state, + # the same rule a party wipe applies; a session that has already ended + # advances the quest and transitions nothing. + session.encounter = None + session.battle = None + session.mode = SessionMode.VICTORY + events.append(AdventureCompletedEvent(quest_id=spec.id, narrative=beat or None)) + return [], events + + def _handle_spawn_monsters(session: GameSession, command: SpawnMonsters) -> tuple[list[Rejection], list[Event]]: from osrlib.core.dice import roll from osrlib.crawl import encounter as encounter_module @@ -1212,6 +1418,10 @@ def _handle_roll_dice(session: GameSession, command: RollDice) -> tuple[list[Rej PlaceParty: _handle_place_party, AdvanceTime: _handle_advance_time, RollDice: _handle_roll_dice, + ActivateQuest: _handle_activate_quest, + RevealObjective: _handle_reveal_objective, + CompleteObjective: _handle_complete_objective, + CompleteQuest: _handle_complete_quest, } _HANDLERS_CACHE: dict | None = None diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index 0138caf..982e2d9 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -350,6 +350,13 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: "session.trigger.fired": lambda event: f"Trigger {event.trigger_id} fired.", "session.journal.entry_added": lambda event: f"Journal: {event.text}", "session.note.recorded": lambda event: f"Referee note: {event.text}", + "session.quest.activated": lambda event: f"A new quest: {event.name}.", + "session.quest.objective_revealed": lambda event: f"Quest {event.quest_id}: a new objective, {event.objective_id}.", + "session.quest.objective_completed": lambda event: ( + f"Quest {event.quest_id}: objective {event.objective_id} is done." + ), + "session.quest.completed": lambda event: f"Quest complete: {event.name}.", + "session.adventure.completed": lambda event: f"The adventure is over: {event.quest_id} is finished.", } diff --git a/src/osrlib/persistence.py b/src/osrlib/persistence.py index 390d21b..9c57300 100644 --- a/src/osrlib/persistence.py +++ b/src/osrlib/persistence.py @@ -4,11 +4,11 @@ [`stamp_document`][osrlib.versioning.stamp_document] envelope of kind `"save"` carrying the full session state: party, the adventure's own content (a save is self-contained and needs no other files to load), dungeon state, clock, ledger, -allocator, registry monsters, flags, trigger fired-marks, the journal, listener -state, mode, crawl counters, exported RNG stream states, the master seed, the -accepted-command log always, and the event log optionally. A session restores from -that state alone — the command and event logs are records of what happened, not -dependencies of the restore. +allocator, registry monsters, flags, trigger fired-marks, the journal, quest state, +listener state, mode, crawl counters, exported RNG stream states, the master seed, +the accepted-command log always, and the event log optionally. A session restores +from that state alone — the command and event logs are records of what happened, +not dependencies of the restore. [`load_game`][osrlib.persistence.load_game] rebuilds a session directly from a save's state, migrating older schema versions on the way in. @@ -36,7 +36,14 @@ from osrlib.crawl.encounter import EncounterState from osrlib.crawl.events import parse_any_event from osrlib.crawl.party import Party -from osrlib.crawl.session import DeathRecord, DefeatedMonsterRecord, DeprivationState, GameSession, JournalEntry +from osrlib.crawl.session import ( + DeathRecord, + DefeatedMonsterRecord, + DeprivationState, + GameSession, + JournalEntry, + QuestState, +) from osrlib.errors import ContentValidationError, ReplayVersionError from osrlib.versioning import SCHEMA_VERSION, check_document, engine_version, stamp_document @@ -113,6 +120,7 @@ def session_state(session: GameSession, *, include_event_log: bool = True) -> di "flags": dict(session.flags), "fired_triggers": list(session.fired_triggers), "journal": [entry.model_dump(mode="json") for entry in session.journal], + "quests": {quest_id: state.model_dump(mode="json") for quest_id, state in session.quests.items()}, "listener_state": {key: dict(value) for key, value in session.listener_state.items()}, "death_records": {key: record.model_dump(mode="json") for key, record in session.death_records.items()}, "defeated_monsters": [record.model_dump(mode="json") for record in session.defeated_monsters], @@ -260,6 +268,11 @@ def load_game(document: Mapping[str, object]) -> GameSession: # an older save restores unchanged and starts remembering from there. session.fired_triggers = [str(entry) for entry in payload.get("fired_triggers", [])] session.journal = [JournalEntry.model_validate(entry) for entry in payload.get("journal", [])] + if "quests" in payload: + # A payload without the block keeps the seed the constructor built from + # the save's own adventure — exactly right for a save written before the + # adventure could carry a quest, whose seed is the empty block anyway. + session.quests = {key: QuestState.model_validate(value) for key, value in payload["quests"].items()} session.listener_state = {key: dict(value) for key, value in payload["listener_state"].items()} session.death_records = { key: DeathRecord.model_validate(value) for key, value in payload["death_records"].items() diff --git a/tests/test_commands.py b/tests/test_commands.py index c851fee..bd4ebfa 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -35,6 +35,10 @@ "mark_trigger_fired", "add_journal_entry", "record_note", + "activate_quest", + "reveal_objective", + "complete_objective", + "complete_quest", } # The referee commands a terminal session withholds, each because it would resume @@ -102,6 +106,10 @@ def sample_command(command_class): "MarkTriggerFired": dict(trigger_id="lever-east"), "AddJournalEntry": dict(text="The lever grinds; somewhere below, a portcullis rises."), "RecordNote": dict(text="The east lever is the only one that answers."), + "ActivateQuest": dict(quest_id="the-idol"), + "RevealObjective": dict(quest_id="the-idol", objective_id="return-home"), + "CompleteObjective": dict(quest_id="the-idol", objective_id="recover-idol"), + "CompleteQuest": dict(quest_id="the-idol"), } return command_class(**samples[command_class.__name__]) @@ -170,11 +178,15 @@ def test_every_consequence_is_a_referee_command(self): 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. + # The interpreter's own vocabulary: it marks, journals, annotates, and + # advances quest state on the author's behalf. "mark_trigger_fired", "add_journal_entry", "record_note", + "activate_quest", + "reveal_objective", + "complete_objective", + "complete_quest", # Session-scoped instance ids no document can know. "identify_item", # A draw no authored construct reads. diff --git a/tests/test_crawl_properties.py b/tests/test_crawl_properties.py index 7968256..d6b8cdd 100644 --- a/tests/test_crawl_properties.py +++ b/tests/test_crawl_properties.py @@ -180,6 +180,13 @@ def command_strategy(): # "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 == "quest_id": + # Quest ids are a closed domain, so the fuzz drives the authored id + # of the quest fixture (see test_quests.py) and an id no document + # holds: the guards must reject both kinds without raising. + fields[field_name] = st.sampled_from(["the-idol", "the-idol", "no-such-quest"]) + elif field_name == "objective_id": + fields[field_name] = st.sampled_from(["recover-idol", "return-home", "no-such-objective"]) 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. diff --git a/tests/test_events_kernel.py b/tests/test_events_kernel.py index a9c3601..43b80a1 100644 --- a/tests/test_events_kernel.py +++ b/tests/test_events_kernel.py @@ -152,6 +152,13 @@ def sample_event(event_class, code): "TriggerFiredEvent": dict(trigger_id="lever-east"), "JournalEntryAddedEvent": dict(text="The lever grinds; somewhere below, a portcullis rises.", rounds=12), "NoteRecordedEvent": dict(text="The east lever is the only one that answers."), + "QuestActivatedEvent": dict(quest_id="the-idol", name="The Jade Idol", narrative="Sister Halda wants it back."), + "ObjectiveRevealedEvent": dict(quest_id="the-idol", objective_id="return-home"), + "ObjectiveCompletedEvent": dict( + quest_id="the-idol", objective_id="recover-idol", narrative="The idol is lighter than it looks." + ), + "QuestCompletedEvent": dict(quest_id="the-idol", name="The Jade Idol", narrative="The bells answer."), + "AdventureCompletedEvent": dict(quest_id="the-idol"), } return event_class(code=code, **samples[event_class.__name__]) diff --git a/tests/test_quests.py b/tests/test_quests.py new file mode 100644 index 0000000..c4338f9 --- /dev/null +++ b/tests/test_quests.py @@ -0,0 +1,643 @@ +"""The quest document surface, the lifecycle commands, and the state they advance. + +`TestTriggerClause`, `TestObjectiveSpec`, and `TestQuestSpec` pin what an authored +document may say — the clause reusing the trigger vocabulary, and the four things a +quest may never carry (a consuming condition, a reveal clause on a visible objective, +no objectives at all, a hand-written `source` on a reward). `TestSeeding` pins the +block a session is born with. The lifecycle classes walk the four commands: every +guard and its rejection, the journal beats they append, the events they emit, the +victory transition and its stickiness, and the persistence of the whole block. +""" + +import json + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from pydantic import ValidationError + +from crawl_fixtures import build_adventure, build_party +from osrlib.core.events import Visibility +from osrlib.crawl.adventure import Adventure +from osrlib.crawl.commands import ( + ActivateQuest, + AddJournalEntry, + AdvanceTime, + AwardXP, + Command, + CompleteObjective, + CompleteQuest, + EnterDungeon, + RevealObjective, + SessionMode, + SpawnMonsters, +) +from osrlib.crawl.events import ( + AdventureCompletedEvent, + JournalEntryAddedEvent, + ObjectiveCompletedEvent, + ObjectiveRevealedEvent, + QuestActivatedEvent, + QuestCompletedEvent, +) +from osrlib.crawl.gates import HasItemCondition +from osrlib.crawl.narrative import NarrativeBlock +from osrlib.crawl.quests import ObjectiveSpec, QuestSpec, TriggerClause +from osrlib.crawl.session import GameSession, JournalEntry, ObjectiveState, QuestState +from osrlib.crawl.triggers import ( + PARTY_SELECTOR, + AreaEnteredPattern, + DungeonEnteredPattern, + ItemAcquiredPattern, + TownEnteredPattern, +) +from osrlib.persistence import load_game, save_game, session_state +from test_crawl_properties import command_strategy + +QUEST_ID = "the-idol" +RECOVER = "recover-idol" +RETURN = "return-home" + +OFFER = "Sister Halda wants the idol back before the new moon." +COMPLETION = "The idol sits on the altar where it began." +RECOVER_PROGRESS = "The idol is lighter than it looks." +RETURN_OFFER = "And then there is the matter of walking out alive." +RETURN_PROGRESS = "Threshold's gate closes behind you, the idol in the pack." + +TERMINAL_MODES = (SessionMode.GAME_OVER, SessionMode.VICTORY) + + +def build_quest(**overrides) -> QuestSpec: + """The fetch quest the lifecycle tests drive: one visible objective, one hidden.""" + quest = QuestSpec( + id=QUEST_ID, + name="The Jade Idol", + activation=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="delve")), + objectives=( + ObjectiveSpec( + id=RECOVER, + when=TriggerClause(pattern=ItemAcquiredPattern(item_id="holy_water")), + narrative=NarrativeBlock(progress=RECOVER_PROGRESS), + ), + ObjectiveSpec( + id=RETURN, + when=TriggerClause( + pattern=TownEnteredPattern(), + conditions=(HasItemCondition(item_id="holy_water"),), + ), + hidden=True, + reveal_when=TriggerClause( + pattern=AreaEnteredPattern(dungeon_id="delve", level_number=1, area_id="room_a") + ), + narrative=NarrativeBlock(offer=RETURN_OFFER, progress=RETURN_PROGRESS), + ), + ), + rewards=(AwardXP(character_id=PARTY_SELECTOR, amount=200),), + narrative=NarrativeBlock(offer=OFFER, completion=COMPLETION), + ) + return quest.model_copy(update=overrides) if overrides else quest + + +def with_quests(*quests: QuestSpec) -> Adventure: + """The shared test delve with authored quests bolted on.""" + return build_adventure(wandering_chance=0).model_copy(update={"quests": quests}) + + +def make_session(*quests: QuestSpec, seed: int = 19) -> GameSession: + return GameSession.new(build_party(), with_quests(*quests), seed=seed) + + +def active_session(quest: QuestSpec | None = None, *, seed: int = 19) -> GameSession: + """A session whose quest is active, activated the ordinary way.""" + session = make_session(quest if quest is not None else build_quest(), seed=seed) + assert session.execute(ActivateQuest(quest_id=QUEST_ID)).accepted + return session + + +def run(session: GameSession, *commands: Command) -> GameSession: + for command in commands: + result = session.execute(command) + assert result.accepted, [rejection.code for rejection in result.rejections] + return session + + +class TestTriggerClause: + def test_a_clause_round_trips_with_its_pattern_and_conditions(self): + clause = TriggerClause( + pattern=TownEnteredPattern(), + conditions=(HasItemCondition(item_id="holy_water"),), + ) + assert TriggerClause.model_validate(clause.model_dump(mode="json")) == clause + + def test_conditions_default_to_the_bare_pattern(self): + assert TriggerClause(pattern=TownEnteredPattern()).conditions == () + + def test_a_consuming_condition_is_rejected_at_parse(self): + with pytest.raises(ValidationError, match="cannot consume"): + TriggerClause( + pattern=TownEnteredPattern(), + conditions=(HasItemCondition(item_id="toll_token", consumes=True),), + ) + + def test_the_clause_is_frozen(self): + clause = TriggerClause(pattern=TownEnteredPattern()) + with pytest.raises(ValidationError): + clause.conditions = () + + +class TestObjectiveSpec: + def test_an_objective_round_trips(self): + objective = build_quest().objectives[1] + assert ObjectiveSpec.model_validate(objective.model_dump(mode="json")) == objective + + def test_defaults_are_the_visible_shape(self): + objective = ObjectiveSpec(id="recover", when=TriggerClause(pattern=TownEnteredPattern())) + assert not objective.hidden + assert objective.reveal_when is None + assert objective.narrative is None + + def test_a_hidden_objective_needs_no_reveal_clause(self): + objective = ObjectiveSpec(id="secret", when=TriggerClause(pattern=TownEnteredPattern()), hidden=True) + assert objective.hidden and objective.reveal_when is None + + def test_a_reveal_clause_on_a_visible_objective_is_rejected_at_parse(self): + with pytest.raises(ValidationError, match="not hidden"): + ObjectiveSpec( + id="recover", + when=TriggerClause(pattern=TownEnteredPattern()), + reveal_when=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="delve")), + ) + + def test_an_empty_id_is_rejected(self): + with pytest.raises(ValidationError): + ObjectiveSpec(id="", when=TriggerClause(pattern=TownEnteredPattern())) + + +class TestQuestSpec: + def test_a_full_spec_round_trips(self): + quest = build_quest() + assert QuestSpec.model_validate(quest.model_dump(mode="json")) == quest + + def test_defaults_are_the_common_shape(self): + quest = QuestSpec( + id="errand", + name="An Errand", + objectives=(ObjectiveSpec(id="do-it", when=TriggerClause(pattern=TownEnteredPattern())),), + ) + assert quest.activation is None + assert quest.rewards == () + assert quest.completion == "all" + assert not quest.concludes_adventure + assert quest.narrative is None + + def test_a_quest_with_no_objectives_is_rejected_at_parse(self): + with pytest.raises(ValidationError): + QuestSpec(id="empty", name="Empty", objectives=()) + + def test_duplicate_objective_ids_within_a_quest_are_rejected_at_parse(self): + with pytest.raises(ValidationError, match="objective ids must be unique"): + QuestSpec( + id="twice", + name="Twice", + objectives=( + ObjectiveSpec(id="return", when=TriggerClause(pattern=TownEnteredPattern())), + ObjectiveSpec(id="return", when=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="delve"))), + ), + ) + + def test_objective_ids_are_quest_scoped_so_two_quests_may_share_one(self): + objective = ObjectiveSpec(id="return", when=TriggerClause(pattern=TownEnteredPattern())) + first = QuestSpec(id="one", name="One", objectives=(objective,)) + second = QuestSpec(id="two", name="Two", objectives=(objective,)) + assert first.objectives[0].id == second.objectives[0].id == "return" + + def test_an_authored_source_on_a_reward_is_rejected_at_parse(self): + with pytest.raises(ValidationError, match="carries a source"): + QuestSpec( + id="paid", + name="Paid", + objectives=(ObjectiveSpec(id="do-it", when=TriggerClause(pattern=TownEnteredPattern())),), + rewards=(AwardXP(character_id=PARTY_SELECTOR, amount=10, source="quest:someone-else"),), + ) + + @pytest.mark.parametrize( + "payload", + [ + AddJournalEntry(text="The idol is lighter than it looks.").model_dump(mode="json"), + ActivateQuest(quest_id="the-idol").model_dump(mode="json"), + {"command_type": "cast_wish"}, + ], + ids=["journal", "lifecycle", "unknown"], + ) + def test_a_reward_outside_the_consequence_surface_does_not_parse(self, payload): + with pytest.raises(ValidationError): + QuestSpec.model_validate( + { + "id": "paid", + "name": "Paid", + "objectives": [{"id": "do-it", "when": {"pattern": {"pattern_type": "town_entered"}}}], + "rewards": [payload], + } + ) + + def test_the_spec_is_frozen(self): + quest = build_quest() + with pytest.raises(ValidationError): + quest.concludes_adventure = True + + def test_a_document_written_before_quests_parses_with_none(self): + payload = build_adventure(wandering_chance=0).model_dump(mode="json") + payload.pop("quests") + assert Adventure.model_validate(payload).quests == () + + +class TestSeeding: + def test_an_adventure_that_authors_nothing_seeds_an_empty_block(self): + session = make_session() + assert session.quests == {} + + def test_a_quest_with_an_activation_clause_starts_inactive(self): + session = make_session(build_quest()) + assert session.quests[QUEST_ID].status == "inactive" + + def test_a_quest_with_no_activation_clause_is_active_from_round_zero(self): + session = make_session(build_quest(activation=None)) + assert session.quests[QUEST_ID].status == "active" + assert session.clock.rounds == 0 + assert session.journal == [], "a standing charge has no activation beat to journal" + assert not session.event_log + + def test_objectives_seed_visible_unless_authored_hidden_in_document_order(self): + session = make_session(build_quest()) + assert list(session.quests[QUEST_ID].objectives) == [RECOVER, RETURN] + assert session.quests[QUEST_ID].objectives[RECOVER] == ObjectiveState(revealed=True, complete=False) + assert session.quests[QUEST_ID].objectives[RETURN] == ObjectiveState(revealed=False, complete=False) + + def test_quests_seed_in_document_order(self): + first = build_quest(id="first") + second = build_quest(id="second") + session = make_session(first, second) + assert list(session.quests) == ["first", "second"] + + def test_the_accessor_resolves_the_closed_domain(self): + adventure = with_quests(build_quest()) + assert adventure.quest(QUEST_ID).name == "The Jade Idol" + with pytest.raises(ValueError, match="unknown quest id"): + adventure.quest("no-such-quest") + + +class TestActivationGuards: + def test_an_unknown_quest_is_rejected_and_never_logged(self): + session = make_session(build_quest()) + result = session.execute(ActivateQuest(quest_id="no-such-quest")) + assert not result.accepted + assert result.rejections[0].code == "session.command.unknown_quest" + assert result.rejections[0].params == {"quest": "no-such-quest"} + assert not session.command_log + + def test_activating_an_active_quest_is_refused_by_its_own_state(self): + session = active_session() + logged = len(session.command_log) + result = session.execute(ActivateQuest(quest_id=QUEST_ID)) + assert not result.accepted + assert result.rejections[0].code == "session.command.quest_state" + assert result.rejections[0].params == {"quest": QUEST_ID, "state": "active"} + assert len(session.command_log) == logged + + def test_activating_a_completed_quest_is_refused_by_its_own_state(self): + session = run(active_session(), CompleteQuest(quest_id=QUEST_ID)) + result = session.execute(ActivateQuest(quest_id=QUEST_ID)) + assert not result.accepted + assert result.rejections[0].params == {"quest": QUEST_ID, "state": "completed"} + + def test_an_activation_draws_nothing_and_spends_no_time(self): + session = make_session(build_quest()) + streams = {key: state.model_dump(mode="json") for key, state in session.streams.export_states().items()} + rounds = session.clock.rounds + assert session.execute(ActivateQuest(quest_id=QUEST_ID)).accepted + assert {key: state.model_dump(mode="json") for key, state in session.streams.export_states().items()} == streams + assert session.clock.rounds == rounds + + +class TestRevealGuards: + def test_an_unknown_objective_is_rejected(self): + session = active_session() + result = session.execute(RevealObjective(quest_id=QUEST_ID, objective_id="no-such-objective")) + assert not result.accepted + assert result.rejections[0].code == "session.command.unknown_objective" + assert result.rejections[0].params == {"quest": QUEST_ID, "objective": "no-such-objective"} + + def test_an_unknown_quest_is_rejected_before_the_objective(self): + session = active_session() + result = session.execute(RevealObjective(quest_id="no-such-quest", objective_id=RETURN)) + assert result.rejections[0].code == "session.command.unknown_quest" + + def test_revealing_on_an_inactive_quest_is_refused(self): + session = make_session(build_quest()) + result = session.execute(RevealObjective(quest_id=QUEST_ID, objective_id=RETURN)) + assert not result.accepted + assert result.rejections[0].code == "session.command.quest_state" + assert result.rejections[0].params == {"quest": QUEST_ID, "state": "inactive"} + + def test_revealing_a_visible_objective_is_refused(self): + session = active_session() + result = session.execute(RevealObjective(quest_id=QUEST_ID, objective_id=RECOVER)) + assert not result.accepted + assert result.rejections[0].code == "session.command.quest_state" + assert result.rejections[0].params == {"quest": QUEST_ID, "objective": RECOVER, "state": "revealed"} + + def test_revealing_a_completed_objective_is_refused_as_complete(self): + session = run(active_session(), CompleteObjective(quest_id=QUEST_ID, objective_id=RETURN)) + result = session.execute(RevealObjective(quest_id=QUEST_ID, objective_id=RETURN)) + assert not result.accepted + assert result.rejections[0].params == {"quest": QUEST_ID, "objective": RETURN, "state": "complete"} + + def test_a_reveal_makes_the_hidden_objective_visible_and_leaves_it_incomplete(self): + session = run(active_session(), RevealObjective(quest_id=QUEST_ID, objective_id=RETURN)) + assert session.quests[QUEST_ID].objectives[RETURN] == ObjectiveState(revealed=True, complete=False) + + +class TestCompletionGuards: + def test_completing_on_an_inactive_quest_is_refused(self): + session = make_session(build_quest()) + result = session.execute(CompleteObjective(quest_id=QUEST_ID, objective_id=RECOVER)) + assert not result.accepted + assert result.rejections[0].params == {"quest": QUEST_ID, "state": "inactive"} + + def test_completing_a_completed_objective_is_refused(self): + session = run(active_session(), CompleteObjective(quest_id=QUEST_ID, objective_id=RECOVER)) + result = session.execute(CompleteObjective(quest_id=QUEST_ID, objective_id=RECOVER)) + assert not result.accepted + assert result.rejections[0].code == "session.command.quest_state" + assert result.rejections[0].params == {"quest": QUEST_ID, "objective": RECOVER, "state": "complete"} + + def test_completing_an_unknown_objective_is_rejected(self): + session = active_session() + result = session.execute(CompleteObjective(quest_id=QUEST_ID, objective_id="no-such-objective")) + assert result.rejections[0].code == "session.command.unknown_objective" + + def test_completing_a_hidden_objective_surfaces_it_with_no_separate_reveal(self): + session = active_session() + result = session.execute(CompleteObjective(quest_id=QUEST_ID, objective_id=RETURN)) + assert result.accepted + assert session.quests[QUEST_ID].objectives[RETURN] == ObjectiveState(revealed=True, complete=True) + assert not [event for event in result.events if isinstance(event, ObjectiveRevealedEvent)] + assert [command.command_type for command in session.command_log] == ["activate_quest", "complete_objective"] + + def test_completing_the_last_objective_does_not_complete_the_quest(self): + session = run( + active_session(), + CompleteObjective(quest_id=QUEST_ID, objective_id=RECOVER), + CompleteObjective(quest_id=QUEST_ID, objective_id=RETURN), + ) + assert session.quests[QUEST_ID].status == "active" + + def test_completing_a_quest_needs_it_active(self): + session = make_session(build_quest()) + result = session.execute(CompleteQuest(quest_id=QUEST_ID)) + assert not result.accepted + assert result.rejections[0].params == {"quest": QUEST_ID, "state": "inactive"} + assert session.execute(ActivateQuest(quest_id=QUEST_ID)).accepted + assert session.execute(CompleteQuest(quest_id=QUEST_ID)).accepted + again = session.execute(CompleteQuest(quest_id=QUEST_ID)) + assert not again.accepted + assert again.rejections[0].params == {"quest": QUEST_ID, "state": "completed"} + + def test_the_referee_may_complete_a_quest_whose_rule_is_unsatisfied(self): + # Ruling a quest done is the referee's call: the handler checks that the + # quest is active and nothing else, under either completion rule. + session = active_session() + assert session.quests[QUEST_ID].objectives[RECOVER].complete is False + result = session.execute(CompleteQuest(quest_id=QUEST_ID)) + assert result.accepted + assert session.quests[QUEST_ID].status == "completed" + assert not any(state.complete for state in session.quests[QUEST_ID].objectives.values()) + + +class TestJournalBeats: + def test_each_beat_appends_the_mapped_text_stamped_with_the_clock(self): + session = make_session(build_quest()) + run( + session, + ActivateQuest(quest_id=QUEST_ID), + AdvanceTime(n=1, unit="turn"), + RevealObjective(quest_id=QUEST_ID, objective_id=RETURN), + AdvanceTime(n=1, unit="turn"), + CompleteObjective(quest_id=QUEST_ID, objective_id=RECOVER), + AdvanceTime(n=1, unit="turn"), + CompleteObjective(quest_id=QUEST_ID, objective_id=RETURN), + AdvanceTime(n=1, unit="turn"), + CompleteQuest(quest_id=QUEST_ID), + ) + assert session.journal == [ + JournalEntry(text=OFFER, rounds=0), + JournalEntry(text=RETURN_OFFER, rounds=60), + JournalEntry(text=RECOVER_PROGRESS, rounds=120), + JournalEntry(text=RETURN_PROGRESS, rounds=180), + JournalEntry(text=COMPLETION, rounds=240), + ] + + def test_an_unauthored_beat_appends_nothing_and_rides_the_event_as_absent(self): + quest = build_quest(narrative=None) + session = make_session(quest) + result = session.execute(ActivateQuest(quest_id=QUEST_ID)) + assert result.accepted + assert session.journal == [] + event = next(event for event in result.events if isinstance(event, QuestActivatedEvent)) + assert event.narrative is None + + def test_an_empty_beat_is_not_a_beat(self): + quest = build_quest(narrative=NarrativeBlock(offer="", completion=COMPLETION)) + session = make_session(quest) + result = session.execute(ActivateQuest(quest_id=QUEST_ID)) + assert session.journal == [] + assert next(event for event in result.events if isinstance(event, QuestActivatedEvent)).narrative is None + + def test_a_quest_beat_emits_no_journal_entry_event(self): + # The lifecycle event *is* the beat's event; emitting both would show the + # table one line twice. + session = make_session(build_quest()) + for command in ( + ActivateQuest(quest_id=QUEST_ID), + RevealObjective(quest_id=QUEST_ID, objective_id=RETURN), + CompleteObjective(quest_id=QUEST_ID, objective_id=RECOVER), + CompleteObjective(quest_id=QUEST_ID, objective_id=RETURN), + CompleteQuest(quest_id=QUEST_ID), + ): + result = session.execute(command) + assert result.accepted, command.command_type + assert not [event for event in result.events if isinstance(event, JournalEntryAddedEvent)] + assert len(session.journal) == 5, "the beats landed all the same" + + +class TestEvents: + def test_every_lifecycle_event_is_for_the_table_and_carries_its_beat(self): + session = make_session(build_quest(concludes_adventure=True)) + emitted = [] + for command in ( + ActivateQuest(quest_id=QUEST_ID), + RevealObjective(quest_id=QUEST_ID, objective_id=RETURN), + CompleteObjective(quest_id=QUEST_ID, objective_id=RECOVER), + CompleteQuest(quest_id=QUEST_ID), + ): + result = session.execute(command) + assert result.accepted, command.command_type + emitted.extend(result.events) + assert [type(event) for event in emitted] == [ + QuestActivatedEvent, + ObjectiveRevealedEvent, + ObjectiveCompletedEvent, + QuestCompletedEvent, + AdventureCompletedEvent, + ] + assert {event.visibility for event in emitted} == {Visibility.PLAYER} + assert [event.narrative for event in emitted] == [ + OFFER, + RETURN_OFFER, + RECOVER_PROGRESS, + COMPLETION, + COMPLETION, + ] + assert emitted[0].name == emitted[3].name == "The Jade Idol" + assert emitted[1].objective_id == RETURN and emitted[2].objective_id == RECOVER + + def test_the_beat_rides_the_event_verbatim_through_the_formatter(self): + from osrlib.messages import format_message + + session = active_session() + result = session.execute(CompleteQuest(quest_id=QUEST_ID)) + event = next(event for event in result.events if isinstance(event, QuestCompletedEvent)) + assert format_message(event) == f"Quest complete: The Jade Idol. {COMPLETION}" + + +class TestVictory: + def test_the_concluding_completion_ends_the_adventure(self): + session = active_session(build_quest(concludes_adventure=True)) + run( + session, + EnterDungeon(dungeon_id="delve"), + SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=30), + ) + assert session.encounter is not None or session.battle is not None + result = session.execute(CompleteQuest(quest_id=QUEST_ID)) + assert result.accepted + assert session.mode is SessionMode.VICTORY + assert session.encounter is None and session.battle is None + assert [event.code for event in result.events] == ["session.quest.completed", "session.adventure.completed"] + + def test_a_quest_that_does_not_conclude_the_adventure_transitions_nothing(self): + session = active_session() + result = session.execute(CompleteQuest(quest_id=QUEST_ID)) + assert session.mode is SessionMode.TOWN + assert [event.code for event in result.events] == ["session.quest.completed"] + + @pytest.mark.parametrize("mode", TERMINAL_MODES, ids=lambda mode: mode.value) + def test_a_terminal_session_finishes_the_job_and_never_ends_twice(self, mode): + session = active_session(build_quest(concludes_adventure=True)) + session.mode = mode + result = session.execute(CompleteQuest(quest_id=QUEST_ID)) + assert result.accepted + assert session.quests[QUEST_ID].status == "completed" + assert session.journal == [JournalEntry(text=OFFER, rounds=0), JournalEntry(text=COMPLETION, rounds=0)] + assert [event.code for event in result.events] == ["session.quest.completed"] + assert not [event for event in result.events if isinstance(event, AdventureCompletedEvent)] + assert session.mode is mode + + def test_the_whole_lifecycle_runs_in_a_terminal_mode(self): + session = make_session(build_quest()) + session.mode = SessionMode.GAME_OVER + run( + session, + ActivateQuest(quest_id=QUEST_ID), + RevealObjective(quest_id=QUEST_ID, objective_id=RETURN), + CompleteObjective(quest_id=QUEST_ID, objective_id=RECOVER), + CompleteQuest(quest_id=QUEST_ID), + ) + assert session.mode is SessionMode.GAME_OVER + + +class TestPersistence: + LIFECYCLE: tuple[Command, ...] = ( + ActivateQuest(quest_id=QUEST_ID), + RevealObjective(quest_id=QUEST_ID, objective_id=RETURN), + AdvanceTime(n=1, unit="turn"), + CompleteObjective(quest_id=QUEST_ID, objective_id=RECOVER), + ) + + def played(self) -> GameSession: + return run(make_session(build_quest()), *self.LIFECYCLE) + + def test_the_block_round_trips_through_a_save(self): + session = self.played() + restored = load_game(json.loads(json.dumps(save_game(session)))) + assert restored.quests == session.quests + assert restored.quests[QUEST_ID].objectives[RECOVER] == ObjectiveState(revealed=True, complete=True) + assert session_state(restored) == session_state(session) + + def test_a_save_written_before_the_block_loads_the_constructor_seed(self): + document = json.loads(json.dumps(save_game(self.played()))) + del document["payload"]["quests"] + restored = load_game(document) + assert restored.quests == { + QUEST_ID: QuestState( + status="inactive", + objectives={ + RECOVER: ObjectiveState(revealed=True, complete=False), + RETURN: ObjectiveState(revealed=False, complete=False), + }, + ) + } + assert restored.execute(ActivateQuest(quest_id=QUEST_ID)).accepted + + def test_a_save_written_before_quests_existed_at_all_loads_unchanged(self): + session = run(make_session(), EnterDungeon(dungeon_id="delve")) + document = json.loads(json.dumps(save_game(session))) + del document["payload"]["quests"] + del document["payload"]["adventure"]["quests"] + restored = load_game(document) + assert restored.adventure.quests == () + assert restored.quests == {} + assert session_state(restored) == session_state(session) + + def test_load_equals_replay_with_the_block_populated(self): + from osrlib.core.character import party_to_document + from osrlib.core.ruleset import Ruleset + from osrlib.persistence import replay_game + + session = self.played() + restored = load_game(json.loads(json.dumps(save_game(session)))) + replayed = replay_game( + 19, + party_to_document(build_party().members), + with_quests(build_quest()), + Ruleset(), + [command.model_dump(mode="json") for command in session.command_log], + ) + assert replayed.quests == session.quests + assert session_state(replayed) == session_state(restored) + + def test_the_referee_view_carries_the_block(self): + state = self.played().view(Visibility.REFEREE).state + assert state["quests"][QUEST_ID]["status"] == "active" + assert state["quests"][QUEST_ID]["objectives"][RECOVER] == {"revealed": True, "complete": True} + + +@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_fuzzed_quest_commands_never_raise_and_the_state_machine_stays_monotonic(seed, commands): + """The fuzz contract over the quest guards: they reject, and nothing goes backwards.""" + session = GameSession.new(build_party(), with_quests(build_quest(concludes_adventure=True)), seed=seed) + order = {"inactive": 0, "active": 1, "completed": 2} + previous = session.quests[QUEST_ID].model_copy(deep=True) + for command in commands: + session.execute(command) # must never raise + current = session.quests[QUEST_ID] + assert order[current.status] >= order[previous.status] + for objective_id, state in current.objectives.items(): + was = previous.objectives[objective_id] + assert state.revealed >= was.revealed and state.complete >= was.complete + assert state.revealed or not state.complete, "a complete objective is a revealed one" + previous = current.model_copy(deep=True) diff --git a/tools/docs/rejection_codes.json b/tools/docs/rejection_codes.json index bab5b4a..bd9118f 100644 --- a/tools/docs/rejection_codes.json +++ b/tools/docs/rejection_codes.json @@ -123,10 +123,13 @@ "session.command.no_living_members": "The party tried to take treasure from a cache or pile, but every party member is dead, leaving no one to carry it.", "session.command.not_in_dungeon": "The referee tried to spawn monsters or an NPC party while the party wasn't standing on a dungeon grid cell.", "session.command.out_of_bounds": "The party was placed at a dungeon position that falls outside the mapped area of that level.", + "session.command.quest_state": "A quest lifecycle command contradicts the state the quest is already in: activating a quest that is active or finished, revealing an objective that is already visible or already complete, completing an objective that is already complete, or revealing or completing anything on a quest that isn't active.", "session.command.unknown_item": "The command referenced an item id that doesn't exist in the equipment catalog, or, when identifying an item, a magic item the named member isn't carrying.", "session.command.unknown_location": "The command referenced a dungeon id or level number that doesn't exist in the adventure, or the target dungeon has no level with an entrance defined.", "session.command.unknown_member": "The command referenced a character id that doesn't belong to any member of the current party.", "session.command.unknown_monster": "The referee tried to spawn monsters using a template id that doesn't exist in the monster catalog.", + "session.command.unknown_objective": "A quest lifecycle command referenced an objective id that the named quest doesn't define.", + "session.command.unknown_quest": "A quest lifecycle command referenced a quest id that the adventure doesn't define; quest ids resolve against the adventure's own quest specs.", "session.command.wrong_mode": "The command isn't legal in the session's current mode, such as sending a battle-only command while the session isn't in battle.", "town.sell.no_fixed_value": "The party tried to sell a magic item, which carries no fixed market value a shop will pay for." } From 26824b646e48f8889d36f8c00f5592cd32dc6f6a Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 6 Aug 2026 22:45:35 -0700 Subject: [PATCH 2/9] A magic instance named twice is not carried twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing defect the widened command fuzz surfaced: GiveItems naming one magic instance id twice raised ValueError from _apply_give, and the DropItems twin half-ran — both breaking the schema-valid-commands-reject- never-throw contract. The shared validation pre-phase now rejects the second naming with exploration.item.not_carried: the whole instance leaves on the first naming, so the second has nothing behind it. Claude-Session: https://claude.ai/code/session_01NQ83sqNnXVeXEd55NK1xR7 --- src/osrlib/crawl/exploration.py | 8 ++++++++ tests/test_exploration.py | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index 5af0f9b..1d2c658 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -2332,14 +2332,22 @@ def _validate_carried(member, item_ids: tuple[str, ...], coins: Coins) -> list[R The member must carry every named item and coin, and no revealed cursed item may leave its bearer. + + A magic instance id names one instance, so naming it twice in one command asks + for something the member does not carry: the whole instance leaves on the first + naming, and the second has nothing behind it. """ counts: dict[str, int] = {} + named_magic: set[str] = set() for item_id in item_ids: magic = member.inventory.magic_item(item_id) if magic is not None: if magic.cursed_revealed: # A revealed cursed item pins to its bearer until *remove curse*. return [Rejection(code="items.curse.stuck", params={"item": item_id})] + if item_id in named_magic: + return [Rejection(code="exploration.item.not_carried", params={"item": item_id})] + named_magic.add(item_id) continue if any(valuable.instance_id == item_id for valuable in member.inventory.valuables): continue diff --git a/tests/test_exploration.py b/tests/test_exploration.py index 67c6737..752eca6 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -1387,6 +1387,42 @@ def test_give_is_allowed_in_town(self): ) assert result.accepted + def test_naming_one_magic_instance_twice_is_not_carrying_it_twice(self): + # A magic instance id names one instance, and the whole instance leaves on the + # first naming: the second has nothing behind it, so the command rejects in the + # validation pre-phase rather than half-running. + from osrlib.core.items import MagicItemInstance + + session = quiet_session() + entered(session) + giver = session.member("character-0001") + giver.inventory.items.append(MagicItemInstance(instance_id="magic-item-7001", template_id="potion_of_healing")) + result = session.execute( + GiveItems( + character_id="character-0001", + recipient_id="character-0002", + item_ids=("magic-item-7001", "magic-item-7001"), + ) + ) + assert not result.accepted + assert result.rejections[0].code == "exploration.item.not_carried" + assert giver.inventory.magic_item("magic-item-7001") is not None, "a rejected command mutates nothing" + assert session.member("character-0002").inventory.magic_item("magic-item-7001") is None + + def test_dropping_one_magic_instance_twice_rejects_the_same_way(self): + from osrlib.core.items import MagicItemInstance + + session = quiet_session() + entered(session) + member = session.member("character-0001") + member.inventory.items.append(MagicItemInstance(instance_id="magic-item-7002", template_id="potion_of_healing")) + result = session.execute( + DropItems(character_id="character-0001", item_ids=("magic-item-7002", "magic-item-7002")) + ) + assert not result.accepted + assert result.rejections[0].code == "exploration.item.not_carried" + assert member.inventory.magic_item("magic-item-7002") is not None + class TestLocationEffects: def test_burning_oil_pool_damages_passers_through(self): From d2a73674af2440a254c79c0d60809bc79c2b8c49 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 6 Aug 2026 22:45:35 -0700 Subject: [PATCH 3/9] Quests on the document, every reference resolved, and the view that shows them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work items 7 and 8: validate_adventure walks quests through the same two bodies triggers use — _validate_trigger's body split into _validate_clause and _validate_consequence, its error lines byte-identical — so the two authoring surfaces cannot drift; quest ids unique across the adventure, every clause and reward reference resolved. PlayerView.quests ships active quests and revealed objectives only, offer and speaker as flat strings, and the leak pins extend to the quest wiring: no clauses, no rewards, no hidden objectives, no inactive quests, no narrative-block field ever a key in the projection. Claude-Session: https://claude.ai/code/session_01NQ83sqNnXVeXEd55NK1xR7 --- src/osrlib/crawl/adventure.py | 190 ++++++++++++++----- src/osrlib/crawl/views.py | 79 +++++++- tests/crawl_fixtures.py | 77 +++++++- tests/test_crawl_properties.py | 71 +++++++- tests/test_quests.py | 324 ++++++++++++++++++++++++++++----- tests/test_session.py | 14 +- 6 files changed, 656 insertions(+), 99 deletions(-) diff --git a/src/osrlib/crawl/adventure.py b/src/osrlib/crawl/adventure.py index 9e51e8a..c78941f 100644 --- a/src/osrlib/crawl/adventure.py +++ b/src/osrlib/crawl/adventure.py @@ -25,7 +25,15 @@ WeaponTemplate, ) from osrlib.core.monsters import MonsterCatalog, MonsterTemplate -from osrlib.crawl.commands import AwardXP, GrantCoins, GrantItem, PlaceParty, SetDoorState, SpawnMonsters +from osrlib.crawl.commands import ( + AwardXP, + ConsequenceCommand, + 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.quests import QuestSpec @@ -37,6 +45,7 @@ ItemAcquiredPattern, LevelEnteredPattern, MonsterDefeatedPattern, + TriggerPattern, TriggerSpec, ) from osrlib.data import load_magic_items @@ -295,21 +304,27 @@ def _resolve_level(adventure: Adventure, dungeon_id: str, level_number: int) -> return None -def _validate_trigger( - trigger: TriggerSpec, +def _validate_clause( + pattern: TriggerPattern, + conditions: tuple[ConditionSpec, ...], + owner: str, adventure: Adventure, monsters: MonsterCatalog, equipment: EquipmentCatalog, magic: MagicItemCatalog, errors: list[str], ) -> None: - """Resolve one trigger's pattern, condition, and consequence references. + """Resolve one matching clause's pattern and condition references. + + The one body behind every clause in a document — a trigger's `when` and + `conditions`, a quest's activation, an objective's completion, a hidden + objective's reveal — so the two authoring surfaces can never drift apart on what + resolves. `owner` is the subject the error lines name (`trigger 'lever-east'`, + `quest 'the-idol' objective 'return-home'`). 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: @@ -332,49 +347,120 @@ def _validate_trigger( monsters.get(pattern.template_id) except ValueError: errors.append(f"{owner}: pattern references unknown monster {pattern.template_id!r}") - for condition in trigger.conditions: + for condition in conditions: dangling = _dangling_condition_item(condition, equipment, magic) if dangling is not None: errors.append(f"{owner}: condition references unknown item {dangling!r}") + + +def _validate_consequence( + consequence: ConsequenceCommand, + site: str, + adventure: Adventure, + monsters: MonsterCatalog, + equipment: EquipmentCatalog, + magic: MagicItemCatalog, + errors: list[str], +) -> None: + """Resolve one authored consequence's references and its character addressing. + + The one body behind every consequence in a document — a trigger's consequences + and a quest's rewards alike. `site` is the subject the error lines name + (`trigger 'reward': consequence 0`, `quest 'the-idol': reward 0`). + """ + 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: + return + 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_trigger( + trigger: TriggerSpec, + adventure: Adventure, + monsters: MonsterCatalog, + equipment: EquipmentCatalog, + magic: MagicItemCatalog, + errors: list[str], +) -> None: + """Resolve one trigger's pattern, condition, and consequence references.""" + owner = f"trigger {trigger.id!r}" + _validate_clause(trigger.when, trigger.conditions, owner, adventure, monsters, equipment, magic, errors) 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}") + _validate_consequence( + consequence, f"{owner}: consequence {position}", adventure, monsters, equipment, magic, errors + ) + + +def _validate_quest( + quest: QuestSpec, + adventure: Adventure, + monsters: MonsterCatalog, + equipment: EquipmentCatalog, + magic: MagicItemCatalog, + errors: list[str], +) -> None: + """Resolve one quest's clause and reward references, clause by clause. + + Every clause a quest carries walks the shared clause check: the activation, each + objective's completion, and each hidden objective's reveal — the reveal named + apart from the completion so an error line says which of the two dangles. Rewards + walk the shared consequence check, so a quest's reward and a trigger's consequence + are held to the same references and the same party-selector rule. + """ + owner = f"quest {quest.id!r}" + if quest.activation is not None: + _validate_clause( + quest.activation.pattern, quest.activation.conditions, owner, adventure, monsters, equipment, magic, errors + ) + for objective in quest.objectives: + site = f"{owner} objective {objective.id!r}" + _validate_clause( + objective.when.pattern, objective.when.conditions, site, adventure, monsters, equipment, magic, errors + ) + if objective.reveal_when is not None: + _validate_clause( + objective.reveal_when.pattern, + objective.reveal_when.conditions, + f"{site} reveal", + adventure, + monsters, + equipment, + magic, + errors, + ) + for position, reward in enumerate(quest.rewards): + _validate_consequence(reward, f"{owner}: reward {position}", adventure, monsters, equipment, magic, errors) def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment: EquipmentCatalog) -> None: @@ -402,6 +488,14 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment party selector — a session allocates character ids, so a document naming one is naming something that cannot exist when it is read. + Then, per quest: ids unique across the adventure (quest ids and trigger ids are + separate namespaces, and nothing here cross-checks them); per clause — the + activation, each objective's completion, each hidden objective's reveal — the + same pattern and condition references a trigger's clause resolves; and per + reward, the same references and the same party-selector rule a consequence gets. + The two surfaces share one clause check and one consequence check, so neither can + grow a reference the other fails to resolve. + Args: adventure: The adventure to validate. monsters: The *base* monster catalog — validation unions it internally @@ -503,5 +597,11 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment 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) + seen_quests: set[str] = set() + for quest in adventure.quests: + if quest.id in seen_quests: + errors.append(f"quest {quest.id!r}: id is not unique") + seen_quests.add(quest.id) + _validate_quest(quest, adventure, effective, effective_items, magic, errors) if errors: raise ContentValidationError("adventure validation failed:\n" + "\n".join(errors)) diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 0feacc2..593c9ce 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -10,14 +10,16 @@ doors only if discovered — an undiscovered secret door renders as wall), known piles and emptied caches in explored space, active effects on party members with remaining durations, the elapsed clock, the mode, the journal (the appended -beats verbatim, each with the clock position it landed at), the current +beats verbatim, each with the clock position it landed at), the active quests with +their revealed objectives, the current encounter/battle public state (names, counts, distances, visible conditions — never HP), fatigue/exhaustion/deprivation status, and the adventure's public prose. It never carries unexplored geometry, undiscovered traps or secret doors, monster HP or stat internals, referee-visibility roll outcomes, session flags, trigger fired-marks, referee -notes, RNG state, or the seed — the seed lives only in the save, and neither view -carries it. +notes, quest wiring (activation clauses, patterns, conditions, rewards, hidden +objectives, inactive quests), RNG state, or the seed — the seed lives only in the +save, and neither view carries it. The referee view carries everything else the save does, minus RNG internals and the seed, for LLM referees and tests. A front end must never trust the client: @@ -40,8 +42,10 @@ "ExploredLevelView", "MemberEffectView", "MemberView", + "ObjectiveView", "PileView", "PlayerView", + "QuestView", "RefereeView", "build_player_view", "build_referee_view", @@ -134,6 +138,41 @@ class EncounterView(BaseModel): pursuit_gap_feet: int | None = None +class ObjectiveView(BaseModel): + """One revealed objective as the players know it: what it is called, and whether it is done. + + Hidden objectives have no view at all — an objective nobody has been told about + is absent from the list, not listed as unknown — so `state` needs only the two + values a visible objective can be in. + """ + + model_config = ConfigDict(frozen=True) + + id: str + state: str + """`"incomplete"` or `"complete"`.""" + + +class QuestView(BaseModel): + """One active quest as the players know it: the charge, who gave it, and where it stands. + + `narrative` is the quest's authored offer beat and `speaker` its attribution, + both empty when unauthored: a wire client holds no adventure document to resolve + either from, so the projection carries the words themselves. The wiring that + starts a quest, checks it off, and pays it — clauses, patterns, conditions, + rewards — never appears; that is the game's secret exactly as a trigger's is. + """ + + model_config = ConfigDict(frozen=True) + + id: str + name: str + narrative: str + speaker: str + objectives: tuple[ObjectiveView, ...] + """The revealed objectives, in the order the quest authored them.""" + + class PlayerView(BaseModel): """The safe projection: an enumerated whitelist of exactly the fields a player may see.""" @@ -159,6 +198,10 @@ class PlayerView(BaseModel): """The session journal, shipped as written: the players' own record of the adventure, in order of discovery, each beat carrying the clock position it landed at. The wiring behind the beats — trigger fired-marks, referee notes — stays out.""" + quests: tuple[QuestView, ...] + """The quests in play, in the order the adventure authored them: active ones only. + A quest nobody has taken on yet is not the party's business, and a finished one + leaves the list — its record is the journal, which keeps every beat it wrote.""" encounter: EncounterView | None = None @@ -328,10 +371,40 @@ def build_player_view(session) -> PlayerView: exhausted=exhausted, deprivation=deprivation, journal=tuple(session.journal), + quests=tuple(_quest_views(session)), encounter=_encounter_view(session), ) +def _quest_views(session): + """The active quests, in document order, each with its revealed objectives. + + Walks the authored specs rather than the state block, so the order the view + ships is the order the adventure wrote — and a quest the block does not know is + simply absent, the same way an unresolvable level is. + """ + for quest in session.adventure.quests: + state = session.quests.get(quest.id) + if state is None or state.status != "active": + continue + objectives = [] + for objective in quest.objectives: + objective_state = state.objectives.get(objective.id) + if objective_state is None or not objective_state.revealed: + continue + objectives.append( + ObjectiveView(id=objective.id, state="complete" if objective_state.complete else "incomplete") + ) + narrative = quest.narrative + yield QuestView( + id=quest.id, + name=quest.name, + narrative=narrative.offer if narrative is not None else "", + speaker=narrative.speaker if narrative is not None else "", + objectives=tuple(objectives), + ) + + def _visible_cell_refs(session) -> set[str]: refs: set[str] = set() for key, cells in session.dungeon_state.explored.items(): diff --git a/tests/crawl_fixtures.py b/tests/crawl_fixtures.py index 38261f3..016fa46 100644 --- a/tests/crawl_fixtures.py +++ b/tests/crawl_fixtures.py @@ -23,7 +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.commands import AwardXP, SetDoorState, SpawnMonsters from osrlib.crawl.dungeon import ( AreaSpec, AreaTreasureSpec, @@ -46,7 +46,16 @@ 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.crawl.quests import ObjectiveSpec, QuestSpec, TriggerClause +from osrlib.crawl.triggers import ( + PARTY_SELECTOR, + AreaEnteredPattern, + DungeonEnteredPattern, + FlagSetPattern, + ItemAcquiredPattern, + TownEnteredPattern, + TriggerSpec, +) from osrlib.data import load_classes __all__ = [ @@ -58,11 +67,22 @@ "PORTCULLIS_CRANK", "PORTCULLIS_FIRED", "PORTCULLIS_JOURNAL", + "QUEST_COMPLETION", + "QUEST_ID", + "QUEST_NAME", + "QUEST_OFFER", + "QUEST_RECOVER", + "QUEST_RECOVER_PROGRESS", + "QUEST_RETURN", + "QUEST_RETURN_OFFER", + "QUEST_RETURN_PROGRESS", + "QUEST_SPEAKER", "STOCK_ROSTER", "build_adventure", "build_blade_adventure", "build_chute_adventure", "build_double_trap_adventure", + "build_fetch_quest", "build_gas_trap_adventure", "build_gated_adventure", "build_lethal_coffer_adventure", @@ -73,6 +93,25 @@ ] +QUEST_ID = "the-idol" +"""The fetch quest's id — the one the fuzzer's `quest_id` strategy samples.""" + +QUEST_NAME = "The Jade Idol" + +QUEST_RECOVER = "recover-idol" +"""The visible objective: the idol comes out of the delve.""" + +QUEST_RETURN = "return-home" +"""The hidden objective: revealed in room_a, completed by walking home carrying it.""" + +QUEST_OFFER = "Sister Halda wants the idol back before the new moon." +QUEST_SPEAKER = "Sister Halda" +QUEST_COMPLETION = "The idol sits on the altar where it began." +QUEST_RECOVER_PROGRESS = "The idol is lighter than it looks." +QUEST_RETURN_OFFER = "And then there is the matter of walking out alive." +QUEST_RETURN_PROGRESS = "Threshold's gate closes behind you, the idol in the pack." + + def _open(edges: dict, position, direction) -> None: edges[edge_key(position, direction)] = Edge(kind=EdgeKind.OPEN) @@ -190,6 +229,40 @@ def build_adventure(wandering_chance: int = 1) -> Adventure: ) +def build_fetch_quest(**overrides) -> QuestSpec: + """The shared fetch quest over the delve: one visible objective, one hidden. + + Every reference resolves against `build_adventure`'s delve, so it can be bolted + onto that adventure with `model_copy(update={"quests": (build_fetch_quest(),)})` + — the way a test gives a session quests without touching the shared fixture the + goldens embed. Its ids are the ones the fuzzer's quest strategies sample. + """ + quest = QuestSpec( + id=QUEST_ID, + name=QUEST_NAME, + activation=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="delve")), + objectives=( + ObjectiveSpec( + id=QUEST_RECOVER, + when=TriggerClause(pattern=ItemAcquiredPattern(item_id="holy_water")), + narrative=NarrativeBlock(progress=QUEST_RECOVER_PROGRESS), + ), + ObjectiveSpec( + id=QUEST_RETURN, + when=TriggerClause(pattern=TownEnteredPattern(), conditions=(HasItemCondition(item_id="holy_water"),)), + hidden=True, + reveal_when=TriggerClause( + pattern=AreaEnteredPattern(dungeon_id="delve", level_number=1, area_id="room_a") + ), + narrative=NarrativeBlock(offer=QUEST_RETURN_OFFER, progress=QUEST_RETURN_PROGRESS), + ), + ), + rewards=(AwardXP(character_id=PARTY_SELECTOR, amount=200),), + narrative=NarrativeBlock(offer=QUEST_OFFER, completion=QUEST_COMPLETION, speaker=QUEST_SPEAKER), + ) + return quest.model_copy(update=overrides) if overrides else quest + + def build_blade_adventure() -> Adventure: """Build the one-level door-trap dungeon: three trapped rooms behind three doors. diff --git a/tests/test_crawl_properties.py b/tests/test_crawl_properties.py index d6b8cdd..a467c11 100644 --- a/tests/test_crawl_properties.py +++ b/tests/test_crawl_properties.py @@ -5,11 +5,21 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -from crawl_fixtures import build_adventure, build_gated_adventure, build_party, build_portcullis_adventure +from crawl_fixtures import ( + QUEST_COMPLETION, + QUEST_ID, + QUEST_OFFER, + build_adventure, + build_fetch_quest, + 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 ( ALL_COMMAND_CLASSES, + ActivateQuest, BattleDeclaration, EnterDungeon, GrantItem, @@ -18,7 +28,10 @@ SetFlag, ) from osrlib.crawl.dungeon import Direction, PartyLocation +from osrlib.crawl.narrative import NarrativeBlock +from osrlib.crawl.quests import ObjectiveSpec, QuestSpec, TriggerClause from osrlib.crawl.session import GameSession +from osrlib.crawl.triggers import TownEnteredPattern CHARACTER_IDS = ["character-0001", "character-0002", "character-0003", "character-0004", "character-0099"] ITEM_IDS = [ @@ -40,6 +53,29 @@ ] DIRECTIONS = list(Direction) +VIGIL_ID = "the-vigil" +VIGIL_NAME = "A Vigil Nobody Asked For" +QUEST_GUIDANCE = "Steer the table toward the barrow road." + + +def leaky_quest_adventure(): + """The shared delve with two quests: the fetch quest, and one the fuzz cannot reach. + + The fuzz's `quest_id` strategy samples the fetch quest's id alone, so the vigil + can never leave `inactive` — which is what makes "an inactive quest never reaches + the player view" a pin rather than a coincidence. + """ + fetch = build_fetch_quest( + narrative=NarrativeBlock(offer=QUEST_OFFER, completion=QUEST_COMPLETION, guidance=QUEST_GUIDANCE) + ) + vigil = QuestSpec( + id=VIGIL_ID, + name=VIGIL_NAME, + activation=TriggerClause(pattern=TownEnteredPattern()), + objectives=(ObjectiveSpec(id="keep-watch", when=TriggerClause(pattern=TownEnteredPattern())),), + ) + return build_adventure().model_copy(update={"quests": (fetch, vigil)}) + def plant_magic_items(session) -> None: """Give the first member unidentified magic items the fuzz can reach.""" @@ -329,9 +365,13 @@ def test_randomly_driven_sessions_save_and_load_round_trip(seed, commands): ) def test_the_player_view_never_leaks(seed, commands): """The leak property test — fuzzed sessions, not one fixture (pinned).""" - session = GameSession.new(build_party(), build_adventure(), seed=seed) + session = GameSession.new(build_party(), leaky_quest_adventure(), seed=seed) session.execute(GrantItem(character_id="character-0001", item_id="torch", quantity=6)) session.execute(GrantItem(character_id="character-0001", item_id="tinder_box")) + # The fetch quest is activated up front so a quest projection is certainly in the + # blob below, whatever the fuzz then does to it; the vigil is authored and never + # activated, because the fuzz's quest ids reach only the fetch quest. + session.execute(ActivateQuest(quest_id=QUEST_ID)) # Unidentified magic items ride along under ids the fuzz never uses (the # 8000s — ITEM_IDS carries only the 9000s), so these must stay masked # whatever the command sequence does. @@ -390,8 +430,33 @@ def test_the_player_view_never_leaks(seed, commands): # Gate wiring is content wiring, and content wiring is the game's secret: the # edge projection carries kind and door state only, never the condition that # guards the door, its narrative block, or the guidance inside it. - for wiring in ("requires", "condition_type", "has_item", "guidance", "refusal", "narrative"): + for wiring in ("requires", "condition_type", "has_item", "guidance", "refusal"): assert wiring not in blob + # The view carries chosen beats as flat strings — a quest's offer under + # `narrative`, nothing else — and never a narrative block: no field name of one + # is ever a key here. + for block_field in ('"refusal"', '"success"', '"fired"', '"offer"', '"progress"', '"completion"', '"guidance"'): + assert block_field not in blob + # Quest wiring is the game's secret exactly as trigger wiring is: no clause, no + # pattern, no reward, and no quest the party has not been given. (A clause dump + # always carries `pattern_type`, and a condition dump `condition_type` above, so + # the bare `conditions` key — which a member's own sheet owns — needs no pin.) + for wiring in ("pattern_type", "reveal_when", "activation", "rewards", "concludes_adventure", "award_xp"): + assert wiring not in blob + assert VIGIL_ID not in blob and VIGIL_NAME not in blob, "an inactive quest is not the party's business" + assert QUEST_COMPLETION not in json.dumps(parsed["quests"]), ( + "the quest list carries the offer beat alone; the completion beat's home is the journal" + ) + assert QUEST_GUIDANCE not in blob, "steering for a narrator is never shown to the table" + # Only active quests, only revealed objectives — whatever the fuzz did to the block. + assert {entry["id"] for entry in parsed["quests"]} == { + quest_id for quest_id, state in session.quests.items() if state.status == "active" + } + for quest_view in parsed["quests"]: + state = session.quests[quest_view["id"]] + assert [entry["id"] for entry in quest_view["objectives"]] == [ + objective_id for objective_id, objective in state.objectives.items() if objective.revealed + ] def test_odometer_never_drifts_from_the_closed_form(): diff --git a/tests/test_quests.py b/tests/test_quests.py index c4338f9..2e7c463 100644 --- a/tests/test_quests.py +++ b/tests/test_quests.py @@ -3,10 +3,13 @@ `TestTriggerClause`, `TestObjectiveSpec`, and `TestQuestSpec` pin what an authored document may say — the clause reusing the trigger vocabulary, and the four things a quest may never carry (a consuming condition, a reveal clause on a visible objective, -no objectives at all, a hand-written `source` on a reward). `TestSeeding` pins the -block a session is born with. The lifecycle classes walk the four commands: every -guard and its rejection, the journal beats they append, the events they emit, the -victory transition and its stickiness, and the persistence of the whole block. +no objectives at all, a hand-written `source` on a reward). `TestQuestValidation` +walks `validate_adventure`'s quest checks, reference class by reference class. +`TestSeeding` pins the block a session is born with. The lifecycle classes walk the +four commands: every guard and its rejection, the journal beats they append, the +events they emit, the victory transition and its stickiness, and the persistence of +the whole block. `TestPlayerViewQuests` pins what the table is shown — active quests +and revealed objectives — and everything behind them that it is not. """ import json @@ -16,7 +19,21 @@ from hypothesis import strategies as st from pydantic import ValidationError -from crawl_fixtures import build_adventure, build_party +from crawl_fixtures import ( + QUEST_COMPLETION, + QUEST_ID, + QUEST_NAME, + QUEST_OFFER, + QUEST_RECOVER, + QUEST_RECOVER_PROGRESS, + QUEST_RETURN, + QUEST_RETURN_OFFER, + QUEST_RETURN_PROGRESS, + QUEST_SPEAKER, + build_adventure, + build_fetch_quest, + build_party, +) from osrlib.core.events import Visibility from osrlib.crawl.adventure import Adventure from osrlib.crawl.commands import ( @@ -54,48 +71,18 @@ from osrlib.persistence import load_game, save_game, session_state from test_crawl_properties import command_strategy -QUEST_ID = "the-idol" -RECOVER = "recover-idol" -RETURN = "return-home" - -OFFER = "Sister Halda wants the idol back before the new moon." -COMPLETION = "The idol sits on the altar where it began." -RECOVER_PROGRESS = "The idol is lighter than it looks." -RETURN_OFFER = "And then there is the matter of walking out alive." -RETURN_PROGRESS = "Threshold's gate closes behind you, the idol in the pack." +# The shared fetch quest's ids and beats, under the short names this module reads by. +RECOVER = QUEST_RECOVER +RETURN = QUEST_RETURN +OFFER = QUEST_OFFER +COMPLETION = QUEST_COMPLETION +RECOVER_PROGRESS = QUEST_RECOVER_PROGRESS +RETURN_OFFER = QUEST_RETURN_OFFER +RETURN_PROGRESS = QUEST_RETURN_PROGRESS TERMINAL_MODES = (SessionMode.GAME_OVER, SessionMode.VICTORY) - -def build_quest(**overrides) -> QuestSpec: - """The fetch quest the lifecycle tests drive: one visible objective, one hidden.""" - quest = QuestSpec( - id=QUEST_ID, - name="The Jade Idol", - activation=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="delve")), - objectives=( - ObjectiveSpec( - id=RECOVER, - when=TriggerClause(pattern=ItemAcquiredPattern(item_id="holy_water")), - narrative=NarrativeBlock(progress=RECOVER_PROGRESS), - ), - ObjectiveSpec( - id=RETURN, - when=TriggerClause( - pattern=TownEnteredPattern(), - conditions=(HasItemCondition(item_id="holy_water"),), - ), - hidden=True, - reveal_when=TriggerClause( - pattern=AreaEnteredPattern(dungeon_id="delve", level_number=1, area_id="room_a") - ), - narrative=NarrativeBlock(offer=RETURN_OFFER, progress=RETURN_PROGRESS), - ), - ), - rewards=(AwardXP(character_id=PARTY_SELECTOR, amount=200),), - narrative=NarrativeBlock(offer=OFFER, completion=COMPLETION), - ) - return quest.model_copy(update=overrides) if overrides else quest +build_quest = build_fetch_quest def with_quests(*quests: QuestSpec) -> Adventure: @@ -251,6 +238,173 @@ def test_a_document_written_before_quests_parses_with_none(self): assert Adventure.model_validate(payload).quests == () +def quest_with(**fields) -> QuestSpec: + """A minimal parse-valid quest over the delve, with the named fields replaced.""" + base = { + "id": "errand", + "name": "An Errand", + "objectives": (ObjectiveSpec(id="do-it", when=TriggerClause(pattern=TownEnteredPattern())),), + } + return QuestSpec(**(base | fields)) + + +def clause(pattern, *conditions) -> TriggerClause: + return TriggerClause(pattern=pattern, conditions=tuple(conditions)) + + +class TestQuestValidation: + """`validate_adventure`'s quest walk, reference class by reference class.""" + + 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 accept(self, adventure: Adventure) -> None: + from osrlib.crawl.adventure import validate_adventure + from osrlib.data import load_equipment, load_monsters + + validate_adventure(adventure, load_monsters(), load_equipment()) + + def test_a_clean_quest_document_validates(self): + # Every clause the fetch quest carries — activation, both completions, the + # reveal — and its reward resolve against the delve. + self.accept(with_quests(build_quest())) + + def test_duplicate_quest_ids_are_caught(self): + message = self.validate(with_quests(quest_with(id="twice"), quest_with(id="twice"))) + assert "quest 'twice': id is not unique" in message + + def test_a_quest_and_a_trigger_may_share_an_id(self): + from osrlib.crawl.triggers import TriggerSpec + + adventure = with_quests(quest_with(id="homecoming")).model_copy( + update={"triggers": (TriggerSpec(id="homecoming", when=TownEnteredPattern()),)} + ) + self.accept(adventure) + + def test_a_dangling_activation_dungeon_is_caught(self): + adventure = with_quests(quest_with(activation=clause(DungeonEnteredPattern(dungeon_id="atlantis")))) + assert "quest 'errand': pattern references unknown dungeon 'atlantis'" in self.validate(adventure) + + def test_a_dangling_activation_area_is_caught(self): + activation = clause(AreaEnteredPattern(dungeon_id="delve", level_number=1, area_id="nowhere")) + message = self.validate(with_quests(quest_with(activation=activation))) + assert "quest 'errand': pattern references unknown area 'nowhere'" in message + + def test_a_dangling_activation_level_is_caught(self): + from osrlib.crawl.triggers import LevelEnteredPattern + + activation = clause(LevelEnteredPattern(dungeon_id="delve", level_number=9)) + assert "quest 'errand': pattern references unknown 'delve' level 9" in self.validate( + with_quests(quest_with(activation=activation)) + ) + + def test_a_dangling_activation_item_is_caught(self): + activation = clause(ItemAcquiredPattern(item_id="jade_idol")) + assert "quest 'errand': pattern references unknown item 'jade_idol'" in self.validate( + with_quests(quest_with(activation=activation)) + ) + + def test_a_dangling_activation_monster_is_caught(self): + from osrlib.crawl.triggers import MonsterDefeatedPattern + + activation = clause(MonsterDefeatedPattern(template_id="tarrasque")) + assert "quest 'errand': pattern references unknown monster 'tarrasque'" in self.validate( + with_quests(quest_with(activation=activation)) + ) + + def test_a_dangling_activation_condition_item_is_caught(self): + activation = clause(TownEnteredPattern(), HasItemCondition(item_id="jade_idol")) + assert "quest 'errand': condition references unknown item 'jade_idol'" in self.validate( + with_quests(quest_with(activation=activation)) + ) + + def test_a_dangling_objective_pattern_is_caught(self): + objective = ObjectiveSpec(id="recover", when=clause(ItemAcquiredPattern(item_id="jade_idol"))) + message = self.validate(with_quests(quest_with(objectives=(objective,)))) + assert "quest 'errand' objective 'recover': pattern references unknown item 'jade_idol'" in message + + def test_a_dangling_objective_condition_item_is_caught(self): + objective = ObjectiveSpec( + id="recover", when=clause(TownEnteredPattern(), HasItemCondition(item_id="jade_idol")) + ) + message = self.validate(with_quests(quest_with(objectives=(objective,)))) + assert "quest 'errand' objective 'recover': condition references unknown item 'jade_idol'" in message + + def test_a_dangling_reveal_clause_is_named_apart_from_the_completion(self): + objective = ObjectiveSpec( + id="recover", + when=clause(TownEnteredPattern()), + hidden=True, + reveal_when=clause(DungeonEnteredPattern(dungeon_id="atlantis")), + ) + message = self.validate(with_quests(quest_with(objectives=(objective,)))) + assert "quest 'errand' objective 'recover' reveal: pattern references unknown dungeon 'atlantis'" in message + + def test_a_dangling_reward_item_is_caught(self): + from osrlib.crawl.commands import GrantItem + + rewards = (GrantItem(character_id=PARTY_SELECTOR, item_id="jade_idol"),) + assert "quest 'errand': reward 0 references unknown item 'jade_idol'" in self.validate( + with_quests(quest_with(rewards=rewards)) + ) + + def test_a_dangling_reward_monster_is_caught(self): + rewards = (SpawnMonsters(template_id="tarrasque", count_fixed=1, distance_feet=30),) + assert "quest 'errand': reward 0 references unknown monster 'tarrasque'" in self.validate( + with_quests(quest_with(rewards=rewards)) + ) + + def test_a_reward_door_write_must_name_a_real_door(self): + from osrlib.crawl.commands import SetDoorState + from osrlib.crawl.dungeon import Direction + + rewards = (SetDoorState(dungeon_id="delve", level_number=1, x=0, y=0, direction=Direction.NORTH, open=True),) + assert "quest 'errand': reward 0 names no door at (0, 0) north" in self.validate( + with_quests(quest_with(rewards=rewards)) + ) + + def test_a_literal_character_id_in_a_reward_is_an_error(self): + rewards = (AwardXP(character_id="character-0001", amount=100),) + message = self.validate(with_quests(quest_with(rewards=rewards))) + assert "quest 'errand': reward 0 names character 'character-0001'" in message + assert PARTY_SELECTOR in message + + def test_the_selectors_are_accepted_on_a_reward(self): + from osrlib.crawl.commands import GrantCoins, GrantItem + from osrlib.crawl.triggers import FIRST_LIVING_SELECTOR + + rewards = ( + 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), + ) + self.accept(with_quests(quest_with(rewards=rewards))) + + def test_every_clause_of_one_quest_reports_its_own_dangling_reference(self): + # One walk, every clause: the activation, the completion, and the reveal each + # name themselves, so an author fixes all three from one run. + objective = ObjectiveSpec( + id="recover", + when=clause(DungeonEnteredPattern(dungeon_id="brigadoon")), + hidden=True, + reveal_when=clause(DungeonEnteredPattern(dungeon_id="carcosa")), + ) + message = self.validate( + with_quests( + quest_with(activation=clause(DungeonEnteredPattern(dungeon_id="atlantis")), objectives=(objective,)) + ) + ) + assert "quest 'errand': pattern references unknown dungeon 'atlantis'" in message + assert "quest 'errand' objective 'recover': pattern references unknown dungeon 'brigadoon'" in message + assert "quest 'errand' objective 'recover' reveal: pattern references unknown dungeon 'carcosa'" in message + + class TestSeeding: def test_an_adventure_that_authors_nothing_seeds_an_empty_block(self): session = make_session() @@ -622,6 +776,88 @@ def test_the_referee_view_carries_the_block(self): assert state["quests"][QUEST_ID]["objectives"][RECOVER] == {"revealed": True, "complete": True} +GUIDANCE = "Steer the table toward the barrow road." + + +class TestPlayerViewQuests: + """What the table is shown of a quest: the charge, its speaker, and where it stands.""" + + def quests(self, session: GameSession): + return session.view(Visibility.PLAYER).quests + + def test_an_inactive_quest_is_not_the_partys_business(self): + session = make_session(build_quest()) + assert self.quests(session) == () + + def test_an_active_quest_ships_its_name_offer_and_speaker(self): + view = self.quests(active_session())[0] + assert (view.id, view.name) == (QUEST_ID, QUEST_NAME) + assert view.narrative == OFFER + assert view.speaker == QUEST_SPEAKER + + def test_unauthored_prose_ships_as_empty_strings_never_none(self): + view = self.quests(active_session(build_quest(narrative=None)))[0] + assert view.narrative == "" and view.speaker == "" + + def test_only_revealed_objectives_appear_in_authored_order(self): + session = active_session() + assert [objective.id for objective in self.quests(session)[0].objectives] == [RECOVER] + assert session.execute(RevealObjective(quest_id=QUEST_ID, objective_id=RETURN)).accepted + assert [objective.id for objective in self.quests(session)[0].objectives] == [RECOVER, RETURN] + + def test_an_objectives_state_is_incomplete_until_it_is_done(self): + session = active_session() + assert self.quests(session)[0].objectives[0].state == "incomplete" + assert session.execute(CompleteObjective(quest_id=QUEST_ID, objective_id=RECOVER)).accepted + assert self.quests(session)[0].objectives[0].state == "complete" + + def test_a_completed_hidden_objective_appears_because_completing_reveals_it(self): + session = active_session() + assert session.execute(CompleteObjective(quest_id=QUEST_ID, objective_id=RETURN)).accepted + shown = {objective.id: objective.state for objective in self.quests(session)[0].objectives} + assert shown == {RECOVER: "incomplete", RETURN: "complete"} + + def test_a_completed_quest_leaves_the_list_and_its_record_stays_in_the_journal(self): + session = active_session() + assert session.execute(CompleteQuest(quest_id=QUEST_ID)).accepted + view = session.view(Visibility.PLAYER) + assert view.quests == () + assert [entry.text for entry in view.journal] == [OFFER, COMPLETION] + + def test_quests_ship_in_document_order(self): + first = build_quest(id="first", activation=None) + second = build_quest(id="second", activation=None) + session = make_session(first, second) + assert [quest.id for quest in self.quests(session)] == ["first", "second"] + + def test_the_view_carries_no_quest_wiring(self): + vigil = build_quest(id="the-vigil", name="A Vigil Nobody Asked For") + session = active_session( + build_quest(narrative=NarrativeBlock(offer=OFFER, completion=COMPLETION, guidance=GUIDANCE)) + ) + assert not session.quests[QUEST_ID].objectives[RETURN].revealed, "the hidden objective is still hidden" + blob = session.view(Visibility.PLAYER).model_dump_json() + for wiring in ( + "pattern_type", + "reveal_when", + "activation", + "rewards", + "concludes_adventure", + "hidden", + "award_xp", + ): + assert wiring not in blob, wiring + assert RETURN not in blob, "a hidden objective the party has not been told about has no view" + assert GUIDANCE not in blob, "steering for a narrator is never shown to the table" + assert COMPLETION not in blob, "the completion beat lands when the quest does, in the journal" + assert OFFER in blob, "the offer is the whole point of shipping the quest" + # And the inactive quest beside it leaks neither id nor name. + with_vigil = make_session(build_quest(), vigil) + assert with_vigil.execute(ActivateQuest(quest_id=QUEST_ID)).accepted + blob = with_vigil.view(Visibility.PLAYER).model_dump_json() + assert "the-vigil" not in blob and "A Vigil Nobody Asked For" 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), diff --git a/tests/test_session.py b/tests/test_session.py index 3d55662..5a32dc1 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -643,9 +643,17 @@ def test_damage_death_records_a_non_poison_cause(self): class TestViews: def test_player_view_carries_the_whitelist(self): - session = make_session() + from crawl_fixtures import QUEST_ID, QUEST_NAME, QUEST_RECOVER, build_fetch_quest + from osrlib.crawl.commands import ActivateQuest + + session = GameSession.new( + build_party(), + build_adventure(wandering_chance=0).model_copy(update={"quests": (build_fetch_quest(),)}), + seed=11, + ) outfit(session) session.execute(EnterDungeon(dungeon_id="delve")) + session.execute(ActivateQuest(quest_id=QUEST_ID)) session.execute(AddJournalEntry(text="Down the stair, into the dark.")) view = session.view(Visibility.PLAYER) assert view.party[0].current_hp == 6 @@ -653,7 +661,9 @@ def test_player_view_carries_the_whitelist(self): assert view.location.position == (0, 0) level_view = view.explored[0] assert (0, 0) in level_view.cells - assert [entry.text for entry in view.journal] == ["Down the stair, into the dark."] + assert [entry.text for entry in view.journal][-1] == "Down the stair, into the dark." + assert [(quest.id, quest.name) for quest in view.quests] == [(QUEST_ID, QUEST_NAME)] + assert [objective.id for objective in view.quests[0].objectives] == [QUEST_RECOVER] def test_player_view_never_leaks_the_basics(self): session = make_session(seed=99) From 169ca43ff820ff7399221407672278d709c53088 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 6 Aug 2026 23:00:24 -0700 Subject: [PATCH 4/9] The interpreter reads the quest: the walk, the rewards, and the golden that wins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work item 6 and the phase golden: per event, triggers in document order then quests in document order — activation, reveals, completions, the completion rule checked the moment a completion the walk itself issued lands, then CompleteQuest and the rewards in authored order. Clause matching reuses _matches and condition_holds verbatim; every issued command carries source="quest:{id}" via the shared _Owner value that keeps the stamp and the note label from ever drifting apart; suppressed advancements past the depth bound note instead of issuing, edge gone. The phase15_quest golden runs the barrow errand to victory with the spawn reward dropping in the ended session, and replays byte-equal with no listeners registered. Claude-Session: https://claude.ai/code/session_01NQ83sqNnXVeXEd55NK1xR7 --- src/osrlib/crawl/interpreter.py | 265 ++++++++++++++---- tests/crawl_fixtures.py | 129 ++++++++- tests/generate_phase15_goldens.py | 197 +++++++++++++ tests/goldens/phase15_quest.json | 449 ++++++++++++++++++++++++++++++ tests/test_interpreter.py | 298 +++++++++++++++++++- tests/test_phase15_goldens.py | 247 ++++++++++++++++ 6 files changed, 1525 insertions(+), 60 deletions(-) create mode 100644 tests/generate_phase15_goldens.py create mode 100644 tests/goldens/phase15_quest.json create mode 100644 tests/test_phase15_goldens.py diff --git a/src/osrlib/crawl/interpreter.py b/src/osrlib/crawl/interpreter.py index 69e8bb9..706e524 100644 --- a/src/osrlib/crawl/interpreter.py +++ b/src/osrlib/crawl/interpreter.py @@ -1,36 +1,43 @@ -"""The trigger interpreter: the listener that plays an adventure's authored triggers. +"""The interpreter: the listener that plays an adventure's authored triggers and quests. [`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. +[`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]s and +[`QuestSpec`][osrlib.crawl.quests.QuestSpec]s, and acts the only way anything outside +the engine may act: by executing ordinary referee commands, each stamped with the +trigger or quest it acted for. -That discipline is what keeps a triggered game replayable. The interpreter emits no +That discipline is what keeps an authored 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. +effect a trigger or a quest has is a command in that log, and every one of those +commands says whose idea it was. """ from collections.abc import Sequence +from typing import NamedTuple from osrlib.core.events import Event from osrlib.crawl.commands import ( + ActivateQuest, AddJournalEntry, AwardXP, Command, CommandResult, + CompleteObjective, + CompleteQuest, GrantCoins, GrantItem, MarkTriggerFired, RecordNote, + RevealObjective, ) 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.gates import ConditionSpec, condition_holds, flag_values_equal +from osrlib.crawl.quests import QuestSpec, TriggerClause +from osrlib.crawl.session import GameSession, QuestState from osrlib.crawl.triggers import ( FIRST_LIVING_SELECTOR, PARTY_SELECTOR, @@ -50,8 +57,32 @@ ] _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.""" +"""The deepest events a trigger or a quest clause still matches. The events of a +player's command are depth 0, and what a firing or a quest advancement issues is one +deeper than the event that caused it.""" + + +class _Owner(NamedTuple): + """Whoever the interpreter is acting for: what it signs with, and what it says. + + One definition of both forms, shared by triggers and quests, so the stamp on a + command and the subject of a note can never disagree about who acted. + """ + + kind: str + """`"trigger"` or `"quest"`.""" + + id: str + + @property + def stamp(self) -> str: + """The `source` every command this owner causes carries: `trigger:{id}`, `quest:{id}`.""" + return f"{self.kind}:{self.id}" + + @property + def label(self) -> str: + """How a note names the owner: `trigger lever-east`, `quest the-idol`.""" + return f"{self.kind} {self.id}" def _matches_area_entered(pattern: AreaEnteredPattern, event: Event) -> bool: @@ -156,7 +187,7 @@ def _matches(pattern: TriggerPattern, event: Event, session: GameSession) -> boo class Interpreter: - """Plays an adventure's authored triggers by issuing referee commands. + """Plays an adventure's authored triggers and quests by issuing referee commands. Register one, once, on a session that has already been built: @@ -170,7 +201,8 @@ class Interpreter: `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 + happened and, per event, the adventure's triggers in document order and then its + quests in document order — one rule, and the only order there is. 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 @@ -188,21 +220,59 @@ class Interpreter: narrative carries a journal form, last, so the beat is stamped with the clock the consequences left behind. + **What a quest walk issues**, all of it stamped `source="quest:{id}"`. A quest + clause ([`TriggerClause`][osrlib.crawl.quests.TriggerClause]) is matched exactly + the way a trigger is — the same patterns, the same live conditions — and the walk + goes: + + 1. An inactive quest whose activation clause matches gets + [`ActivateQuest`][osrlib.crawl.commands.ActivateQuest], and the walk carries on + into the objectives of the quest it just activated: the same event that starts + a quest can finish something in it. + 2. An active quest's objectives walk in authored order. A hidden, unrevealed, + incomplete objective whose `reveal_when` matches gets + [`RevealObjective`][osrlib.crawl.commands.RevealObjective]; an incomplete + objective whose `when` matches gets + [`CompleteObjective`][osrlib.crawl.commands.CompleteObjective]. An objective + that completes without ever being revealed needs no reveal — completing shows + it. + 3. The moment a completion lands, the quest's completion rule is checked against + live state (`all` or `any`), and a satisfied rule gets + [`CompleteQuest`][osrlib.crawl.commands.CompleteQuest] followed by the rewards + in authored order, selectors expanded exactly as a trigger's consequences are. + On a quest that concludes the adventure the session is in `victory` before the + first reward is issued, which is why a reward that would resume play there + drops with a note. + + Everything is evaluated as the walk goes: a flag an earlier firing wrote satisfies + a later clause's condition in the same batch, and a quest completed earlier in the + walk is completed for everything after it. + + **Where the interpreter's discipline stops and the referee's ruling begins.** The + completion rule is checked only after a completion the interpreter itself issued, + and no pattern matches the quest events, so a referee who completes the last + objective by hand completes the quest by hand too. For the same reason a + hand-driven [`CompleteQuest`][osrlib.crawl.commands.CompleteQuest] grants no + rewards: rewards are this listener reading the quest, and what a replay re-executes + is the reward commands themselves. + **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. + rejected consequence or reward 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 or quest, the + slot's position and type, and the rejection. If a wipe mid-cascade ends the + session, the remaining commands land or drop by the ordinary rules of a terminal + mode. And a cascade is bounded: what a firing or a quest advancement issues is one + deeper than the event that caused it, matching stops below depth five, and every + advancement the bound suppresses is recorded as a note instead of being issued — + no state moves, so a once-only trigger cut short here is still fireable later, and + a suppressed quest advancement waits for its clause to match again. Clauses are + edge-triggered on both surfaces: the suppressed edge is gone. **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. + commands. Read what a trigger or a quest did from the command log, the journal, + `session.fired_triggers`, and `session.quests`, all of which a replay rebuilds. """ key = "osrlib.interpreter" @@ -213,15 +283,16 @@ 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. + session: The session; its adventure's triggers and quests are read once + here, being frozen content. """ self._session = session self._triggers = session.adventure.triggers + self._quests = session.adventure.quests 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. + """Match one command's events and act on what they crossed. Args: events: The command's accumulated events, in the order they happened. @@ -239,17 +310,11 @@ def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dic 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, - ) + self._truncated(_Owner("trigger", trigger.id), "not fired", depth) continue self._fire(trigger, depth) + for quest in self._quests: + self._advance(quest, event, depth) return [], {} def _would_fire(self, trigger: TriggerSpec, event: Event) -> bool: @@ -258,6 +323,19 @@ def _would_fire(self, trigger: TriggerSpec, event: Event) -> bool: return False if trigger.id in self._session.fired_triggers and not trigger.repeatable: return False + return self._conditions_hold(trigger.conditions) + + def _clause_holds(self, clause: TriggerClause, event: Event) -> bool: + """Whether one quest clause fits this event and holds against state right now. + + The same two questions a trigger answers, asked through the same matcher and + the same evaluation, so quests and triggers can never disagree about what an + event means or when a condition is true. + """ + return _matches(clause.pattern, event, self._session) and self._conditions_hold(clause.conditions) + + def _conditions_hold(self, conditions: Sequence[ConditionSpec]) -> bool: + """Whether every condition holds against live session state, right now.""" return all( condition_holds( condition, @@ -265,31 +343,106 @@ def _would_fire(self, trigger: TriggerSpec, event: Event) -> bool: flags=self._session.flags, ledger=self._session.ledger, ) - for condition in trigger.conditions + for condition in conditions ) def _fire(self, trigger: TriggerSpec, depth: int) -> None: """Issue one firing's whole batch, one level deeper than the event that fired it.""" + owner = _Owner("trigger", trigger.id) 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, + owner, ) - 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) + self._issue_authored(trigger.consequences, owner, "consequence") if narrative is not None and narrative.journal: - self._issue(AddJournalEntry(text=narrative.journal), trigger.id) + self._issue(AddJournalEntry(text=narrative.journal), owner) 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. + # ------------------------------------------------------------------ quests + + def _advance(self, quest: QuestSpec, event: Event, depth: int) -> None: + """Walk one quest against one event: activation, reveals, completions, the rule. + + Everything the walk issues runs one level deeper than the event that caused it, + exactly as a firing does; past the bound the walk still evaluates every clause + and records what it would have issued instead of issuing it. + """ + owner = _Owner("quest", quest.id) + state = self._session.quests.get(quest.id) + if state is None: + return + previous = self._depth + self._depth = depth + 1 + try: + if state.status == "inactive": + if quest.activation is None or not self._clause_holds(quest.activation, event): + return + if depth > _MAX_MATCH_DEPTH: + self._truncated(owner, "not activated", depth) + return + self._issue(ActivateQuest(quest_id=quest.id), owner) + if state.status != "active": + return + for objective in quest.objectives: + objective_state = state.objectives.get(objective.id) + if objective_state is None or objective_state.complete: + continue + if ( + objective.hidden + and not objective_state.revealed + and objective.reveal_when is not None + and self._clause_holds(objective.reveal_when, event) + ): + if depth > _MAX_MATCH_DEPTH: + self._truncated(owner, f"objective {objective.id} not revealed", depth) + else: + self._issue(RevealObjective(quest_id=quest.id, objective_id=objective.id), owner) + if not self._clause_holds(objective.when, event): + continue + if depth > _MAX_MATCH_DEPTH: + self._truncated(owner, f"objective {objective.id} not completed", depth) + continue + self._issue(CompleteObjective(quest_id=quest.id, objective_id=objective.id), owner) + # The rule is checked the moment a completion lands, and only after one + # this walk issued: a completion by hand is a ruling by hand. + if state.status == "active" and self._rule_satisfied(quest, state): + self._issue(CompleteQuest(quest_id=quest.id), owner) + self._issue_authored(quest.rewards, owner, "reward") + return + finally: + self._depth = previous + + @staticmethod + def _rule_satisfied(quest: QuestSpec, state: QuestState) -> bool: + """Whether the quest's completion rule holds against live objective state.""" + done = [] + for objective in quest.objectives: + objective_state = state.objectives.get(objective.id) + done.append(objective_state is not None and objective_state.complete) + return any(done) if quest.completion == "any" else all(done) + + # ------------------------------------------------------------------ issuing + + def _issue_authored(self, authored: Sequence[Command], owner: _Owner, slot: str) -> None: + """Issue an authored sequence — a trigger's consequences, a quest's rewards. + + In authored order, selectors expanded to the members they name, each command + standing or dropping on its own so one rejection never stops the rest. + """ + for position, command in enumerate(authored): + site = f"{slot} {position}" + for expanded in self._expand(command, owner, site): + result = self._issue(expanded, owner) + if not result.accepted: + self._note_drop(owner, site, expanded, result.rejections[0].code) + + def _expand(self, consequence: Command, owner: _Owner, site: str) -> list[Command]: + """Resolve an authored command'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 @@ -302,24 +455,28 @@ def _expand(self, consequence: Command, trigger: TriggerSpec, position: int) -> 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}") + self._note_drop(owner, site, 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.""" + def _note_drop(self, owner: _Owner, site: str, command: Command, reason: str) -> None: + """Record an authored command that did not land, from the facts alone.""" + self._issue(RecordNote(text=f"{owner.label}: {site} ({command.command_type}) dropped ({reason})"), owner) + + def _truncated(self, owner: _Owner, what: str, depth: int) -> None: + """Record what the cascade bound suppressed; nothing moved, so it can happen again.""" self._issue( RecordNote( - text=f"trigger {trigger.id}: consequence {position} ({command.command_type}) dropped ({reason})" + text=(f"{owner.label}: {what}, the cascade reached depth {depth} past the limit of {_MAX_MATCH_DEPTH}") ), - trigger.id, + owner, ) - def _issue(self, command: Command, trigger_id: str) -> CommandResult: - """Execute one command on the trigger's behalf, stamped with its id. + def _issue(self, command: Command, owner: _Owner) -> CommandResult: + """Execute one command on the trigger's or quest'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}"})) + return self._session.execute(command.model_copy(update={"source": owner.stamp})) diff --git a/tests/crawl_fixtures.py b/tests/crawl_fixtures.py index 016fa46..6b3c24c 100644 --- a/tests/crawl_fixtures.py +++ b/tests/crawl_fixtures.py @@ -23,7 +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 AwardXP, SetDoorState, SpawnMonsters +from osrlib.crawl.commands import AwardXP, GrantCoins, SetDoorState, SetFlag, SpawnMonsters from osrlib.crawl.dungeon import ( AreaSpec, AreaTreasureSpec, @@ -48,6 +48,7 @@ from osrlib.crawl.party import Party from osrlib.crawl.quests import ObjectiveSpec, QuestSpec, TriggerClause from osrlib.crawl.triggers import ( + FIRST_LIVING_SELECTOR, PARTY_SELECTOR, AreaEnteredPattern, DungeonEnteredPattern, @@ -59,6 +60,9 @@ from osrlib.data import load_classes __all__ = [ + "BARROW_IDOL", + "BARROW_MOUTH_FIRED", + "BARROW_MOUTH_JOURNAL", "GATE_KEY", "GATE_SEAL", "GATE_SIGIL", @@ -77,8 +81,10 @@ "QUEST_RETURN_OFFER", "QUEST_RETURN_PROGRESS", "QUEST_SPEAKER", + "RITE_KEY", "STOCK_ROSTER", "build_adventure", + "build_barrow_adventure", "build_blade_adventure", "build_chute_adventure", "build_double_trap_adventure", @@ -531,6 +537,127 @@ def build_portcullis_adventure() -> Adventure: ) +BARROW_IDOL = GearTemplate(id="votive_idol", name="Votive idol of jade", cost_gp=0, weight_coins=40) +"""The barrow's MacGuffin, bundled by the adventure so taking it reports a catalog id +the quest's objective can match.""" + +RITE_KEY = "barrow.rite" +"""The flag the party's rite writes — the hidden objective's completion clause.""" + +BARROW_MOUTH_FIRED = "The barrow-mouth exhales cold air over the threshold." +BARROW_MOUTH_JOURNAL = "The barrow takes you in, and the daylight stops at the lintel." + + +def build_barrow_adventure() -> Adventure: + """Build the one-level barrow the authored quest runs end to end in. + + Level 1 (4 × 1), entrance (0,0): + + ```text + x0 x1 x2 x3 + y0 ENT————corr———[shrine]———[crypt] + ``` + + - `barrow-mouth` is an ordinary trigger on the same crossing that activates the + quest, so the order the two surfaces run in is visible in the log. + - The shrine at (2,0) keeps the `reliquary` cache, and the cache holds the + bundled idol: taking it reports the catalog id the first objective watches. + - The crypt at (3,0) reveals the hidden second objective, which completes when + the game writes the rite flag. + - The quest concludes the adventure, and pays after the transition: the spawn + reward is illegal in `victory` and drops with a note, and the three after it + land. + """ + edges: dict[str, Edge] = {} + _open(edges, (0, 0), Direction.EAST) + _open(edges, (1, 0), Direction.EAST) + _open(edges, (2, 0), Direction.EAST) + level = LevelSpec( + number=1, + width=4, + height=1, + edges=edges, + areas=( + AreaSpec( + id="shrine", + name="Shrine of the Nine", + description="Nine niches, eight of them empty.", + cells=((2, 0),), + features=( + FeatureSpec( + id="reliquary", + kind="treasure_cache", + description="A reliquary of blackened silver.", + cell=(2, 0), + item_ids=(BARROW_IDOL.id,), + ), + ), + ), + AreaSpec( + id="crypt", + name="Crypt of the Ninth", + description="A slab, a name worn smooth, and room enough to kneel.", + cells=((3, 0),), + ), + ), + entrance=(0, 0), + wandering=WanderingSpec(chance_in_six=0), + ) + quest = QuestSpec( + id="the-idol", + name="The Votive Idol", + activation=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="barrow")), + objectives=( + ObjectiveSpec( + id="recover-idol", + when=TriggerClause(pattern=ItemAcquiredPattern(item_id=BARROW_IDOL.id)), + narrative=NarrativeBlock(progress="The idol comes out of its niche as if it were waiting."), + ), + ObjectiveSpec( + id="speak-the-rite", + when=TriggerClause(pattern=FlagSetPattern(key=RITE_KEY, value="spoken")), + hidden=True, + reveal_when=TriggerClause( + pattern=AreaEnteredPattern(dungeon_id="barrow", level_number=1, area_id="crypt") + ), + narrative=NarrativeBlock( + offer="The slab wants words said over it before the idol may leave.", + progress="The rite is spoken, and the cold goes out of the air.", + ), + ), + ), + rewards=( + # Illegal once the adventure has concluded: it drops with a note, and the + # three rewards after it still land. + SpawnMonsters(template_id="goblin", count_fixed=1, distance_feet=30), + GrantCoins(character_id=FIRST_LIVING_SELECTOR, coins=Coins(gp=100)), + AwardXP(character_id=PARTY_SELECTOR, amount=100), + SetFlag(key="quest.idol", value="recovered"), + ), + concludes_adventure=True, + narrative=NarrativeBlock( + offer="Sister Halda wants the ninth idol back in the temple, rite and all.", + completion="The barrow is quiet. The idol is yours to carry home.", + speaker="Sister Halda", + ), + ) + return Adventure( + name="The Barrow of the Ninth", + description="A shrine, a crypt, and one errand that ends the adventure.", + town=TownSpec(name="Threshold", travel_turns={"barrow": 1}), + dungeons=(DungeonSpec(id="barrow", name="The Barrow", levels=(level,)),), + items=(BARROW_IDOL,), + triggers=( + TriggerSpec( + id="barrow-mouth", + when=DungeonEnteredPattern(dungeon_id="barrow"), + narrative=NarrativeBlock(fired=BARROW_MOUTH_FIRED, journal=BARROW_MOUTH_JOURNAL), + ), + ), + quests=(quest,), + ) + + def build_open_door_adventure() -> Adventure: """Build the one-level pair of cells joined by an authored-open door. diff --git a/tests/generate_phase15_goldens.py b/tests/generate_phase15_goldens.py new file mode 100644 index 0000000..bfa9351 --- /dev/null +++ b/tests/generate_phase15_goldens.py @@ -0,0 +1,197 @@ +"""Generate the phase 15 golden: the barrow errand, run and won as authored data. + +One golden file, `phase15_quest.json` — a scripted delve into a barrow whose quest is +authored content and whose only actor besides the player is the registered +interpreter: + +- crossing the threshold fires an ordinary trigger and then activates the quest, in + that order, and both write their beat to the party's journal; +- the shrine's reliquary holds the bundled idol, and taking it completes the first + objective on the catalog id the acquisition reported; +- the crypt reveals the hidden second objective, which the game completes by writing + the rite flag; +- that completion satisfies the quest's `all` rule, so the quest completes, the + adventure concludes into `victory`, and the rewards pay afterwards: the authored + spawn is illegal in a session that has ended and drops with a note, while the coins, + the XP, and the flag all land; +- a play command after the ending is refused `wrong_mode` and leaves no trace, and a + referee's grant is still accepted over the closed adventure. + +The milestone the file records: the quest 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_phase15_goldens.py` and explain any golden change in +the commit message. +""" + +import json +from pathlib import Path + +from crawl_fixtures import RITE_KEY, build_barrow_adventure, build_party +from osrlib.core.events import Event +from osrlib.crawl.commands import ( + Command, + EnterDungeon, + GrantItem, + MoveParty, + SetFlag, + TakeTreasure, + 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" / "phase15_quest.json" +SEED = 20_260_815 + +QUEST = "quest:the-idol" +MOUTH = "trigger:barrow-mouth" + +# 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 move after the ending: play is over, +# and it leaves no trace in either log. +SCRIPT: tuple[tuple[Command, str | None], ...] = ( + # The threshold: the trigger fires, then the quest activates. + (EnterDungeon(dungeon_id="barrow"), None), + (MoveParty(direction=Direction.EAST), None), + # Into the shrine, and the idol out of the reliquary. + (MoveParty(direction=Direction.EAST), None), + (TakeTreasure(feature_id="reliquary"), None), + # Into the crypt, which reveals what the slab wants. + (MoveParty(direction=Direction.EAST), None), + # The rite: a flag the game writes, and the objective that watches it. The quest's + # rule is satisfied the moment it lands, so the adventure ends here. + (SetFlag(key=RITE_KEY, value="spoken"), None), + # Play is over: the next step is refused and changes nothing. + (MoveParty(direction=Direction.WEST), "session.command.wrong_mode"), + # The referee's hand still reaches the closed adventure. + (GrantItem(character_id="character-0001", item_id="torch", quantity=6), 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_barrow_adventure(), seed=seed) + if listening: + session.register_listener(Interpreter(session)) + return session + + +def snapshot(session: GameSession) -> dict: + """The end state: draws, time, mode, and the blocks the quest layer writes.""" + 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"), + "flags": dict(session.flags), + "fired_triggers": list(session.fired_triggers), + "quests": {quest_id: state.model_dump(mode="json") for quest_id, state in session.quests.items()}, + "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 refused step. + + 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), + } + ) + state = session.quests["the-idol"] + if state.status != "completed": + raise RuntimeError(f"the quest did not complete: {state.status}") + if not all(objective.complete for objective in state.objectives.values()): + raise RuntimeError(f"the all rule completed with an objective outstanding: {state.objectives}") + if session.mode.value != "victory": + raise RuntimeError(f"the concluding quest left the session in {session.mode.value}") + if session.fired_triggers != ["barrow-mouth"]: + raise RuntimeError(f"the threshold trigger must fire once: {session.fired_triggers}") + # The trigger's beat, then the quest's five: offer, the idol, the slab, the rite, + # and the closing line. + if len(session.journal) != 6: + raise RuntimeError(f"the run wrote {len(session.journal)} journal entries, not 6") + if session.listener_state != {Interpreter.key: {}}: + raise RuntimeError(f"the interpreter kept state: {session.listener_state}") + if any(command.command_type == "spawn_monsters" for command in session.command_log): + raise RuntimeError("the reward spawn was accepted after the ending; it must be dropped") + if not any(command.command_type == "record_note" for command in session.command_log): + raise RuntimeError("the dropped reward recorded no note") + if session.flags.get("quest.idol") != "recovered": + raise RuntimeError("the flag reward after the dropped one did not land") + 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 = len(golden["command_log"]) + beats = len(golden["final_state"]["journal"]) + print(f"wrote {GOLDEN_PATH} from seed {SEED} ({commands} commands, {beats} journal beats)") + + +if __name__ == "__main__": + main() diff --git a/tests/goldens/phase15_quest.json b/tests/goldens/phase15_quest.json new file mode 100644 index 0000000..61a6e6e --- /dev/null +++ b/tests/goldens/phase15_quest.json @@ -0,0 +1,449 @@ +{ + "command_log": [ + { + "command_type": "enter_dungeon", + "dungeon_id": "barrow", + "source": null + }, + { + "command_type": "mark_trigger_fired", + "narrative": "The barrow-mouth exhales cold air over the threshold.", + "source": "trigger:barrow-mouth", + "trigger_id": "barrow-mouth" + }, + { + "command_type": "add_journal_entry", + "source": "trigger:barrow-mouth", + "text": "The barrow takes you in, and the daylight stops at the lintel." + }, + { + "command_type": "activate_quest", + "quest_id": "the-idol", + "source": "quest:the-idol" + }, + { + "command_type": "move_party", + "direction": "east", + "source": null + }, + { + "command_type": "move_party", + "direction": "east", + "source": null + }, + { + "command_type": "take_treasure", + "feature_id": "reliquary", + "recipient_id": null, + "source": null + }, + { + "command_type": "complete_objective", + "objective_id": "recover-idol", + "quest_id": "the-idol", + "source": "quest:the-idol" + }, + { + "command_type": "move_party", + "direction": "east", + "source": null + }, + { + "command_type": "reveal_objective", + "objective_id": "speak-the-rite", + "quest_id": "the-idol", + "source": "quest:the-idol" + }, + { + "command_type": "set_flag", + "key": "barrow.rite", + "source": null, + "value": "spoken" + }, + { + "command_type": "complete_objective", + "objective_id": "speak-the-rite", + "quest_id": "the-idol", + "source": "quest:the-idol" + }, + { + "command_type": "complete_quest", + "quest_id": "the-idol", + "source": "quest:the-idol" + }, + { + "command_type": "record_note", + "source": "quest:the-idol", + "text": "quest the-idol: reward 0 (spawn_monsters) dropped (session.command.wrong_mode)" + }, + { + "character_id": "character-0001", + "coins": { + "cp": 0, + "ep": 0, + "gp": 100, + "pp": 0, + "sp": 0 + }, + "command_type": "grant_coins", + "source": "quest:the-idol" + }, + { + "amount": 100, + "character_id": "character-0001", + "command_type": "award_xp", + "source": "quest:the-idol" + }, + { + "amount": 100, + "character_id": "character-0002", + "command_type": "award_xp", + "source": "quest:the-idol" + }, + { + "amount": 100, + "character_id": "character-0003", + "command_type": "award_xp", + "source": "quest:the-idol" + }, + { + "amount": 100, + "character_id": "character-0004", + "command_type": "award_xp", + "source": "quest:the-idol" + }, + { + "command_type": "set_flag", + "key": "quest.idol", + "source": "quest:the-idol", + "value": "recovered" + }, + { + "character_id": "character-0001", + "command_type": "grant_item", + "item_id": "torch", + "quantity": 6, + "source": null + } + ], + "event_log": [ + { + "code": "exploration.location.entered", + "dungeon_id": null, + "event_type": "location_entered", + "level_number": 1, + "location_id": "barrow", + "location_kind": "dungeon", + "narrative": null, + "visibility": "player" + }, + { + "code": "session.trigger.fired", + "event_type": "trigger_fired", + "narrative": "The barrow-mouth exhales cold air over the threshold.", + "trigger_id": "barrow-mouth", + "visibility": "referee" + }, + { + "code": "session.journal.entry_added", + "event_type": "journal_entry_added", + "rounds": 60, + "text": "The barrow takes you in, and the daylight stops at the lintel.", + "visibility": "player" + }, + { + "code": "session.quest.activated", + "event_type": "quest_activated", + "name": "The Votive Idol", + "narrative": "Sister Halda wants the ninth idol back in the temple, rite and all.", + "quest_id": "the-idol", + "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": "exploration.location.entered", + "dungeon_id": "barrow", + "event_type": "location_entered", + "level_number": 1, + "location_id": "shrine", + "location_kind": "area", + "narrative": null, + "visibility": "player" + }, + { + "character_id": "character-0001", + "code": "exploration.item.acquired", + "coins_gp_value": 0, + "event_type": "item_acquired", + "item_ids": [ + "votive_idol" + ], + "visibility": "player" + }, + { + "code": "session.quest.objective_completed", + "event_type": "objective_completed", + "narrative": "The idol comes out of its niche as if it were waiting.", + "objective_id": "recover-idol", + "quest_id": "the-idol", + "visibility": "player" + }, + { + "code": "exploration.party.moved", + "event_type": "party_moved", + "facing": "east", + "visibility": "player", + "x": 3, + "y": 0 + }, + { + "code": "exploration.location.entered", + "dungeon_id": "barrow", + "event_type": "location_entered", + "level_number": 1, + "location_id": "crypt", + "location_kind": "area", + "narrative": null, + "visibility": "player" + }, + { + "code": "session.quest.objective_revealed", + "event_type": "objective_revealed", + "narrative": "The slab wants words said over it before the idol may leave.", + "objective_id": "speak-the-rite", + "quest_id": "the-idol", + "visibility": "player" + }, + { + "code": "session.flag.set", + "event_type": "flag_set", + "key": "barrow.rite", + "value": "spoken", + "visibility": "referee" + }, + { + "code": "session.quest.objective_completed", + "event_type": "objective_completed", + "narrative": "The rite is spoken, and the cold goes out of the air.", + "objective_id": "speak-the-rite", + "quest_id": "the-idol", + "visibility": "player" + }, + { + "code": "session.quest.completed", + "event_type": "quest_completed", + "name": "The Votive Idol", + "narrative": "The barrow is quiet. The idol is yours to carry home.", + "quest_id": "the-idol", + "visibility": "player" + }, + { + "code": "session.adventure.completed", + "event_type": "adventure_completed", + "narrative": "The barrow is quiet. The idol is yours to carry home.", + "quest_id": "the-idol", + "visibility": "player" + }, + { + "code": "session.note.recorded", + "event_type": "note_recorded", + "text": "quest the-idol: reward 0 (spawn_monsters) dropped (session.command.wrong_mode)", + "visibility": "referee" + }, + { + "character_id": "character-0001", + "code": "exploration.item.acquired", + "coins_gp_value": 100, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "award": 100, + "character_id": "character-0001", + "code": "session.xp.awarded", + "event_type": "xp_awarded", + "level_after": 1, + "modified_award": 100, + "visibility": "player" + }, + { + "award": 100, + "character_id": "character-0002", + "code": "session.xp.awarded", + "event_type": "xp_awarded", + "level_after": 1, + "modified_award": 100, + "visibility": "player" + }, + { + "award": 100, + "character_id": "character-0003", + "code": "session.xp.awarded", + "event_type": "xp_awarded", + "level_after": 1, + "modified_award": 105, + "visibility": "player" + }, + { + "award": 100, + "character_id": "character-0004", + "code": "session.xp.awarded", + "event_type": "xp_awarded", + "level_after": 1, + "modified_award": 100, + "visibility": "player" + }, + { + "code": "session.flag.set", + "event_type": "flag_set", + "key": "quest.idol", + "value": "recovered", + "visibility": "referee" + }, + { + "character_id": "character-0001", + "code": "exploration.item.acquired", + "coins_gp_value": 0, + "event_type": "item_acquired", + "item_ids": [ + "torch", + "torch", + "torch", + "torch", + "torch", + "torch" + ], + "visibility": "player" + } + ], + "final_state": { + "clock_rounds": 120, + "fired_triggers": [ + "barrow-mouth" + ], + "flags": { + "barrow.rite": "spoken", + "quest.idol": "recovered" + }, + "journal": [ + { + "rounds": 60, + "text": "The barrow takes you in, and the daylight stops at the lintel." + }, + { + "rounds": 60, + "text": "Sister Halda wants the ninth idol back in the temple, rite and all." + }, + { + "rounds": 120, + "text": "The idol comes out of its niche as if it were waiting." + }, + { + "rounds": 120, + "text": "The slab wants words said over it before the idol may leave." + }, + { + "rounds": 120, + "text": "The rite is spoken, and the cold goes out of the air." + }, + { + "rounds": 120, + "text": "The barrow is quiet. The idol is yours to carry home." + } + ], + "location": { + "dungeon_id": "barrow", + "facing": "east", + "kind": "dungeon", + "level_number": 1, + "position": [ + 3, + 0 + ] + }, + "mode": "victory", + "quests": { + "the-idol": { + "objectives": { + "recover-idol": { + "complete": true, + "revealed": true + }, + "speak-the-rite": { + "complete": true, + "revealed": true + } + }, + "status": "completed" + } + }, + "streams": { + "advancement": { + "inc": 81519380686667632471505458847724587839, + "state": 37413501398006455255140525940021900694 + }, + "effects": { + "inc": 67667495646694457942346934119804494363, + "state": 209558149184381732606578434468736245994 + } + } + }, + "master_seed": 20260815, + "refusals": [ + { + "after_commands": 20, + "code": "session.command.wrong_mode", + "command": { + "command_type": "move_party", + "direction": "west", + "source": null + }, + "params": { + "command": "move_party", + "mode": "victory" + } + } + ], + "transcript": [ + "The party enters dungeon barrow (level 1).", + "Trigger barrow-mouth fired. The barrow-mouth exhales cold air over the threshold.", + "Journal: The barrow takes you in, and the daylight stops at the lintel.", + "A new quest: The Votive Idol. Sister Halda wants the ninth idol back in the temple, rite and all.", + "The party moves to (1, 0), facing east.", + "The party moves to (2, 0), facing east.", + "The party enters area shrine (level 1).", + "character-0001 acquires votive_idol.", + "Quest the-idol: objective recover-idol is done. The idol comes out of its niche as if it were waiting.", + "The party moves to (3, 0), facing east.", + "The party enters area crypt (level 1).", + "Quest the-idol: a new objective, speak-the-rite. The slab wants words said over it before the idol may leave.", + "Flag barrow.rite = 'spoken'.", + "Quest the-idol: objective speak-the-rite is done. The rite is spoken, and the cold goes out of the air.", + "Quest complete: The Votive Idol. The barrow is quiet. The idol is yours to carry home.", + "The adventure is over: the-idol is finished. The barrow is quiet. The idol is yours to carry home.", + "Referee note: quest the-idol: reward 0 (spawn_monsters) dropped (session.command.wrong_mode)", + "character-0001 acquires 100 gp in coin.", + "character-0001 gains 100 XP (base 100), now level 1.", + "character-0002 gains 100 XP (base 100), now level 1.", + "character-0003 gains 105 XP (base 100), now level 1.", + "character-0004 gains 100 XP (base 100), now level 1.", + "Flag quest.idol = 'recovered'.", + "character-0001 acquires torch, torch, torch, torch, torch, torch." + ] +} diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py index 92f0ad3..a4fe8ab 100644 --- a/tests/test_interpreter.py +++ b/tests/test_interpreter.py @@ -1,12 +1,15 @@ -"""The interpreter: matching, firing, selectors, drops, and the cascade bound. +"""The interpreter: matching, firing, quests, 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. +what happens when part of it cannot land. `TestQuestWalk`, `TestQuestRewards`, and +`TestQuestFiat` do the same for the quest surface: the order the walk goes in, what a +completion pays, and where the interpreter's discipline stops and a referee's ruling +begins. `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 @@ -20,7 +23,10 @@ from osrlib.core.items import Coins, MagicItemInstance from osrlib.crawl.adventure import Adventure, TownSpec from osrlib.crawl.commands import ( + ActivateQuest, AwardXP, + CompleteObjective, + CompleteQuest, EnterDungeon, GrantCoins, GrantItem, @@ -51,7 +57,8 @@ 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.quests import ObjectiveSpec, QuestSpec, TriggerClause +from osrlib.crawl.session import GameSession, ObjectiveState from osrlib.crawl.triggers import ( FIRST_LIVING_SELECTOR, PARTY_SELECTOR, @@ -496,6 +503,287 @@ def test_the_truncation_note_is_stamped_like_everything_else(self): note = next(command for command in session.command_log if command.command_type == "record_note") assert note.source == "trigger:chain-6" + def test_a_quest_advancement_past_the_bound_is_recorded_and_not_issued(self): + late = errand(id="late", activation=flag_clause("step6")) + session = played(with_quests(late, adventure=with_triggers(*self.chain()))) + session.execute(SetFlag(key="step1", value=True)) + assert session.quests["late"].status == "inactive", "no state moves past the bound" + assert notes(session) == [ + "trigger chain-6: not fired, the cascade reached depth 5 past the limit of 4", + "quest late: not activated, the cascade reached depth 5 past the limit of 4", + ] + assert [command for command in session.command_log if command.command_type == "activate_quest"] == [] + + def test_a_suppressed_quest_advancement_waits_for_its_clause_to_match_again(self): + late = errand(id="late", activation=flag_clause("step6")) + session = played(with_quests(late, adventure=with_triggers(*self.chain()))) + session.execute(SetFlag(key="step1", value=True)) + # The edge is gone; the quest is not spent, so the next write activates it. + session.execute(SetFlag(key="step6", value=True)) + assert session.quests["late"].status == "active" + + def test_the_quest_truncation_note_carries_the_quests_stamp(self): + late = errand(id="late", activation=flag_clause("step6")) + session = played(with_quests(late, adventure=with_triggers(*self.chain()))) + session.execute(SetFlag(key="step1", value=True)) + notes_logged = [command for command in session.command_log if command.command_type == "record_note"] + assert [command.source for command in notes_logged] == ["trigger:chain-6", "quest:late"] + + +def with_quests(*quests: QuestSpec, adventure: Adventure | None = None) -> Adventure: + """The shared delve (or another adventure) with authored quests bolted on.""" + base = adventure if adventure is not None else build_adventure(wandering_chance=0) + return base.model_copy(update={"quests": quests}) + + +def flag_clause(key: str, value=None) -> TriggerClause: + """The clause the quest tests drive: a flag write the game can make on demand.""" + return TriggerClause(pattern=FlagSetPattern(key=key, value=value)) + + +def errand(**overrides) -> QuestSpec: + """A two-objective quest: one visible, one hidden behind its own reveal clause.""" + quest = QuestSpec( + id="errand", + name="An Errand", + activation=flag_clause("start"), + objectives=( + ObjectiveSpec(id="visible", when=flag_clause("done-1")), + ObjectiveSpec(id="secret", when=flag_clause("done-2"), hidden=True, reveal_when=flag_clause("hint")), + ), + ) + return quest.model_copy(update=overrides) if overrides else quest + + +def issued(session: GameSession, source: str) -> list[str]: + """The command types one owner issued, in log order.""" + return [command.command_type for command in session.command_log if command.source == source] + + +class TestQuestWalk: + """Activation, reveals, completions, and the order the walk goes in.""" + + def test_the_walk_activates_reveals_completes_and_then_completes_the_quest(self): + session = played(with_quests(errand())) + session.execute(SetFlag(key="start", value=True)) + assert session.quests["errand"].status == "active" + session.execute(SetFlag(key="hint", value=True)) + assert session.quests["errand"].objectives["secret"].revealed + session.execute(SetFlag(key="done-1", value=True)) + assert session.quests["errand"].status == "active", "the all rule still wants the second objective" + session.execute(SetFlag(key="done-2", value=True)) + assert session.quests["errand"].status == "completed" + assert issued(session, "quest:errand") == [ + "activate_quest", + "reveal_objective", + "complete_objective", + "complete_objective", + "complete_quest", + ] + + def test_triggers_go_first_and_then_quests_in_document_order(self): + trigger = TriggerSpec(id="watcher", when=FlagSetPattern(key="start")) + session = played(with_quests(errand(id="second"), errand(id="first"), adventure=with_triggers(trigger))) + session.execute(SetFlag(key="start", value=True)) + stamps = [command.source for command in session.command_log if command.source is not None] + assert stamps == ["trigger:watcher", "quest:second", "quest:first"] + + def test_one_event_activates_a_quest_and_completes_an_objective_of_it(self): + quest = errand( + activation=flag_clause("start"), + objectives=(ObjectiveSpec(id="visible", when=flag_clause("start")),), + ) + session = played(with_quests(quest)) + session.execute(SetFlag(key="start", value=True)) + assert issued(session, "quest:errand") == ["activate_quest", "complete_objective", "complete_quest"] + assert session.quests["errand"].status == "completed" + + def test_a_hidden_objective_that_simply_lands_needs_no_reveal(self): + quest = errand(objectives=(ObjectiveSpec(id="secret", when=flag_clause("done-2"), hidden=True),)) + session = played(with_quests(quest)) + session.execute(SetFlag(key="start", value=True)) + session.execute(SetFlag(key="done-2", value=True)) + assert issued(session, "quest:errand") == ["activate_quest", "complete_objective", "complete_quest"] + assert session.quests["errand"].objectives["secret"] == ObjectiveState(revealed=True, complete=True) + + def test_the_any_rule_completes_early_and_leaves_the_rest_incomplete(self): + session = played(with_quests(errand(completion="any"))) + session.execute(SetFlag(key="start", value=True)) + session.execute(SetFlag(key="done-1", value=True)) + assert session.quests["errand"].status == "completed" + assert session.quests["errand"].objectives["secret"] == ObjectiveState(revealed=False, complete=False) + assert issued(session, "quest:errand") == ["activate_quest", "complete_objective", "complete_quest"] + + def test_an_inactive_quest_ignores_its_objectives_clauses(self): + session = played(with_quests(errand())) + session.execute(SetFlag(key="done-1", value=True)) + assert session.quests["errand"].status == "inactive" + assert issued(session, "quest:errand") == [], "an unoffered quest watches nothing" + + def test_a_quest_clause_reads_its_conditions_live(self): + quest = errand( + activation=TriggerClause( + pattern=FlagSetPattern(key="start"), conditions=(HasItemCondition(item_id="holy_water"),) + ) + ) + session = played(with_quests(quest)) + session.execute(SetFlag(key="start", value=True)) + assert session.quests["errand"].status == "inactive", "the condition did not hold" + session.execute(GrantItem(character_id="character-0001", item_id="holy_water")) + session.execute(SetFlag(key="start", value=True)) + assert session.quests["errand"].status == "active" + + def test_an_earlier_firing_satisfies_a_later_quests_condition_in_the_same_batch(self): + opener = TriggerSpec( + id="opener", when=FlagSetPattern(key="start"), consequences=(SetFlag(key="power", value="on"),) + ) + quest = errand( + activation=TriggerClause( + pattern=FlagSetPattern(key="start"), conditions=(FlagEqualsCondition(key="power", value="on"),) + ) + ) + session = played(with_quests(quest, adventure=with_triggers(opener))) + session.execute(SetFlag(key="start", value=True)) + assert session.quests["errand"].status == "active", "the trigger ran first and its flag was already written" + + def test_the_quest_events_match_no_pattern_so_nothing_watches_them(self): + # The lifecycle events are the table's news, not an observable: an author who + # wants one quest to start another writes a flag reward and a flag clause. + watcher = TriggerSpec(id="watcher", when=FlagSetPattern(key="quest.errand"), repeatable=True) + session = played(with_quests(errand(), adventure=with_triggers(watcher))) + session.execute(SetFlag(key="start", value=True)) + session.execute(SetFlag(key="done-1", value=True)) + assert session.fired_triggers == [] + + +class TestQuestRewards: + """What a completion pays, in what order, and what happens when a reward cannot land.""" + + def paying(self, *rewards, **overrides) -> QuestSpec: + return errand( + objectives=(ObjectiveSpec(id="visible", when=flag_clause("done-1")),), rewards=rewards, **overrides + ) + + def test_rewards_land_after_the_completion_in_authored_order_with_selectors_expanded(self): + quest = self.paying( + AwardXP(character_id=PARTY_SELECTOR, amount=50), + GrantCoins(character_id=FIRST_LIVING_SELECTOR, coins=Coins(gp=25)), + SetFlag(key="quest.errand", value="done"), + ) + session = played(with_quests(quest)) + kill(session.member("character-0002")) + session.execute(SetFlag(key="start", value=True)) + session.execute(SetFlag(key="done-1", value=True)) + assert issued(session, "quest:errand") == [ + "activate_quest", + "complete_objective", + "complete_quest", + "award_xp", + "award_xp", + "award_xp", + "grant_coins", + "set_flag", + ] + 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"] + coins = next(command for command in session.command_log if command.command_type == "grant_coins") + assert coins.character_id == "character-0001", "@first is the lead survivor" + assert session.flags["quest.errand"] == "done" + + def test_every_command_a_quest_issues_carries_its_stamp(self): + quest = self.paying(AwardXP(character_id=PARTY_SELECTOR, amount=50)) + session = played(with_quests(quest)) + session.execute(SetFlag(key="start", value=True)) + session.execute(SetFlag(key="done-1", value=True)) + machine_issued = [command for command in session.command_log if command.source is not None] + assert machine_issued, "the walk issued something" + assert {command.source for command in machine_issued} == {"quest:errand"} + + def test_the_authored_reward_is_never_mutated_by_the_stamp_or_the_selector(self): + quest = self.paying(AwardXP(character_id=PARTY_SELECTOR, amount=50)) + session = played(with_quests(quest)) + session.execute(SetFlag(key="start", value=True)) + session.execute(SetFlag(key="done-1", value=True)) + authored = session.adventure.quests[0].rewards[0] + assert authored.source is None and authored.character_id == PARTY_SELECTOR + + def test_a_reward_that_would_resume_play_drops_in_victory_with_its_note(self): + quest = self.paying( + SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=30), + AwardXP(character_id=PARTY_SELECTOR, amount=50), + concludes_adventure=True, + ) + session = played(with_quests(quest)) + session.execute(EnterDungeon(dungeon_id="delve")) + session.execute(SetFlag(key="start", value=True)) + session.execute(SetFlag(key="done-1", value=True)) + assert session.mode.value == "victory", "the concluding completion ended the adventure first" + assert notes(session) == ["quest errand: reward 0 (spawn_monsters) dropped (session.command.wrong_mode)"] + assert [command for command in session.command_log if command.command_type == "spawn_monsters"] == [] + assert [command.command_type for command in session.command_log][-4:] == [ + "award_xp", + "award_xp", + "award_xp", + "award_xp", + ], "the reward after the dropped one still landed, on every living member" + + def test_first_with_nobody_standing_drops_and_says_so(self): + quest = self.paying(GrantCoins(character_id=FIRST_LIVING_SELECTOR, coins=Coins(gp=25))) + session = played(with_quests(quest)) + for member in session.party.members: + kill(member) + session.execute(SetFlag(key="start", value=True)) + session.execute(SetFlag(key="done-1", value=True)) + assert notes(session) == ["quest errand: reward 0 (grant_coins) dropped (no living member for @first)"] + + def test_a_reward_cascades_into_the_triggers_that_watch_it(self): + bell = TriggerSpec( + id="bell", when=FlagSetPattern(key="quest.errand"), consequences=(SetFlag(key="rung", value=True),) + ) + quest = self.paying(SetFlag(key="quest.errand", value="done")) + session = played(with_quests(quest, adventure=with_triggers(bell))) + session.execute(SetFlag(key="start", value=True)) + session.execute(SetFlag(key="done-1", value=True)) + assert session.fired_triggers == ["bell"] + assert session.flags["rung"] is True + + +class TestQuestFiat: + """Where the interpreter's discipline stops and the referee's ruling begins.""" + + def paying(self) -> QuestSpec: + return errand( + objectives=(ObjectiveSpec(id="visible", when=flag_clause("done-1")),), + rewards=(AwardXP(character_id=PARTY_SELECTOR, amount=50),), + ) + + def test_a_completion_by_hand_is_a_ruling_by_hand(self): + session = played(with_quests(self.paying())) + session.execute(SetFlag(key="start", value=True)) + session.execute(CompleteObjective(quest_id="errand", objective_id="visible")) + assert session.quests["errand"].status == "active", "the interpreter checks the rule only after its own" + assert [command for command in session.command_log if command.command_type == "complete_quest"] == [] + session.execute(CompleteQuest(quest_id="errand")) + assert session.quests["errand"].status == "completed" + assert [command for command in session.command_log if command.command_type == "award_xp"] == [], ( + "rewards are the interpreter reading the quest, not the command's own doing" + ) + + def test_a_quest_with_no_interpreter_registered_advances_only_by_hand_and_grants_nothing(self): + session = GameSession.new(build_party(), with_quests(self.paying()), seed=31) + session.execute(SetFlag(key="start", value=True)) + assert session.quests["errand"].status == "inactive", "quests are inert content until a listener plays them" + session.execute(ActivateQuest(quest_id="errand")) + session.execute(CompleteObjective(quest_id="errand", objective_id="visible")) + session.execute(CompleteQuest(quest_id="errand")) + assert session.quests["errand"].status == "completed" + assert [command.command_type for command in session.command_log] == [ + "set_flag", + "activate_quest", + "complete_objective", + "complete_quest", + ] + class TestTheResultEnvelope: def test_a_player_commands_result_carries_the_whole_cascade_in_log_order(self): diff --git a/tests/test_phase15_goldens.py b/tests/test_phase15_goldens.py new file mode 100644 index 0000000..35ebd62 --- /dev/null +++ b/tests/test_phase15_goldens.py @@ -0,0 +1,247 @@ +"""The phase 15 golden: the barrow errand, played by the interpreter and replayed without it. + +Regenerate with `uv run python tests/generate_phase15_goldens.py` (and explain why in +the commit message). The golden records a scripted delve whose quest is authored data +and whose only actor besides the player is the registered interpreter: a threshold that +fires a trigger and then activates the quest, an idol whose acquisition completes the +first objective, a crypt that reveals the hidden second one, a rite flag that finishes +it and ends the adventure in `victory`, rewards paying after that transition with the +illegal one dropped and noted, a play command refused over the closed adventure, and a +referee's grant still accepted. + +It is where the milestone is checked: the quest 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_phase15_goldens import ( + MOUTH, + QUEST, + 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" / "phase15_quest.json" + +REGENERATE_HINT = ( + "golden mismatch; if the change is intentional, regenerate with " + "`uv run python tests/generate_phase15_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 + + +def codes(golden: dict) -> list[str]: + return [event.get("code") for event in golden["event_log"]] + + +@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_threshold_fires_the_trigger_and_then_activates_the_quest(self, golden): + stamped = [entry["source"] for entry in golden["command_log"] if entry["source"] is not None] + assert stamped[:3] == [MOUTH, MOUTH, QUEST], "triggers first, then quests, on the one crossing" + activated = next(event for event in golden["event_log"] if event.get("code") == "session.quest.activated") + assert (activated["quest_id"], activated["name"]) == ("the-idol", "The Votive Idol") + assert activated["narrative"].startswith("Sister Halda wants the ninth idol") + assert activated["visibility"] == "player" + + def test_the_bundled_idol_completes_the_visible_objective_on_its_catalog_id(self, golden): + acquired = next(event for event in golden["event_log"] if event.get("code") == "exploration.item.acquired") + assert acquired["item_ids"] == ["votive_idol"] + completed = next( + event for event in golden["event_log"] if event.get("code") == "session.quest.objective_completed" + ) + assert completed["objective_id"] == "recover-idol" + assert completed["narrative"].startswith("The idol comes out of its niche") + + def test_the_crypt_reveals_the_hidden_objective_and_the_rite_completes_it(self, golden): + revealed = next( + event for event in golden["event_log"] if event.get("code") == "session.quest.objective_revealed" + ) + assert revealed["objective_id"] == "speak-the-rite" + assert revealed["narrative"].startswith("The slab wants words said over it") + # Written by the game, matched by the objective's own clause. + assert "session.flag.set" in codes(golden) + finished = [ + event["objective_id"] + for event in golden["event_log"] + if event.get("code") == "session.quest.objective_completed" + ] + assert finished == ["recover-idol", "speak-the-rite"] + + def test_the_completion_ends_the_adventure_in_victory(self, golden): + emitted = codes(golden) + completed = emitted.index("session.quest.completed") + ended = emitted.index("session.adventure.completed") + assert ended == completed + 1, "the quest finishes, and the adventure closes behind it" + completion, ending = golden["event_log"][completed], golden["event_log"][ended] + assert completion["narrative"] == ending["narrative"] == "The barrow is quiet. The idol is yours to carry home." + assert (completion["visibility"], ending["visibility"]) == ("player", "player") + assert golden["final_state"]["mode"] == "victory" + assert emitted.count("session.adventure.completed") == 1, "victory is entered once and stays entered" + + def test_the_rewards_land_after_the_transition_and_the_illegal_one_drops(self, golden): + issued = [entry["command_type"] for entry in golden["command_log"] if entry["source"] == QUEST] + assert issued == [ + "activate_quest", + "complete_objective", + "reveal_objective", + "complete_objective", + "complete_quest", + "record_note", + "grant_coins", + "award_xp", + "award_xp", + "award_xp", + "award_xp", + "set_flag", + ] + 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"] == "quest the-idol: reward 0 (spawn_monsters) dropped (session.command.wrong_mode)" + assert note["visibility"] == "referee" + assert golden["final_state"]["flags"]["quest.idol"] == "recovered", "the rewards after the drop still landed" + + def test_play_is_over_and_the_referee_is_not(self, golden): + refusal = golden["refusals"][0] + assert refusal["code"] == "session.command.wrong_mode" + assert refusal["params"] == {"command": "move_party", "mode": "victory"} + assert refusal["after_commands"] == len(golden["command_log"]) - 1, "the grant is the only step after it" + assert golden["command_log"][-1]["command_type"] == "grant_item" + assert golden["command_log"][-1]["source"] is None + + def test_the_final_quest_block_and_journal_are_exact(self, golden): + assert golden["final_state"]["quests"] == { + "the-idol": { + "status": "completed", + "objectives": { + "recover-idol": {"revealed": True, "complete": True}, + "speak-the-rite": {"revealed": True, "complete": True}, + }, + } + } + assert golden["final_state"]["journal"] == [ + {"text": "The barrow takes you in, and the daylight stops at the lintel.", "rounds": 60}, + {"text": "Sister Halda wants the ninth idol back in the temple, rite and all.", "rounds": 60}, + {"text": "The idol comes out of its niche as if it were waiting.", "rounds": 120}, + {"text": "The slab wants words said over it before the idol may leave.", "rounds": 120}, + {"text": "The rite is spoken, and the cold goes out of the air.", "rounds": 120}, + {"text": "The barrow is quiet. The idol is yours to carry home.", "rounds": 120}, + ] + + def test_a_quest_beat_reports_itself_once_and_never_twice(self, golden): + # The lifecycle event is the beat's event: the journal grows without a + # `session.journal.entry_added` behind it, and the trigger's beat is the one + # entry that has one. + journal_events = [event for event in golden["event_log"] if event.get("code") == "session.journal.entry_added"] + assert [event["text"] for event in journal_events] == [ + "The barrow takes you in, and the daylight stops at the lintel." + ] + assert len(golden["final_state"]["journal"]) == 6 + + def test_every_machine_issued_command_carries_its_stamp(self, golden): + stamped = [entry for entry in golden["command_log"] if entry["source"] is not None] + assert {entry["source"] for entry in stamped} == {MOUTH, QUEST} + quest_issued = [entry for entry in stamped if entry["source"] == QUEST] + assert len(quest_issued) == 12 + assert all(entry["source"] == "quest:the-idol" for entry in quest_issued) + + +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_mode_and_the_quest_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.quests == session.quests == replayed.quests + assert restored.journal == session.journal == replayed.journal + assert restored.mode is session.mode is replayed.mode + 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 + view = session.view(Visibility.PLAYER) + payload = json.dumps(view.model_dump(mode="json")) + for forbidden in ("pattern_type", "reveal_when", "activation", "rewards", "barrow-mouth", "fired_triggers"): + assert forbidden not in payload, forbidden + assert view.quests == (), "a finished quest leaves the list; its record is the journal" + assert "The barrow is quiet. The idol is yours to carry home." in payload From 60433dcee036b7b3109b2c0b2ebf6fa321a317e5 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 6 Aug 2026 23:24:22 -0700 Subject: [PATCH 5/9] The idol is cargo now: the example authors its quest and the interpreter plays it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work items 9 and 10: LevelSpec.guidance, the per-level ambient steering slot the engine never reads. The example's Jade Idol becomes a bundled GearTemplate placed in the shrine cache by id, so taking it reports the catalog id the authored quest matches; the fetch quest lands as Adventure.quests with pay-on-delivery rewards, and quest.py is deleted — both front ends register the library Interpreter instead, on create and restore alike. The milestone script makes two trips, because the homecoming with the idol ends the adventure on the spot: town business on the first return, the errand on the second, victory closing the transcript. The authored XP retunes 600 -> 1200 to cover the 2,400 gp that left the valuation delta (the mundane idol and the town-paid reward), leaving every member's final XP identical to the old run. The TUI grows a give verb — the sale coin outweighs a marching party, and spreading the purse is the engine's own answer. Claude-Session: https://claude.ai/code/session_01NQ83sqNnXVeXEd55NK1xR7 --- docs/front-ends/tui-crawler.md | 64 +++++++++------ docs/guides/listeners-and-flags.md | 90 ++++++++++++++-------- examples/fastapi_crawler/content.py | 21 ++--- examples/tui_crawler/README.md | 50 ++++++------ examples/tui_crawler/__main__.py | 17 ++-- examples/tui_crawler/content.py | 90 +++++++++++++++++++--- examples/tui_crawler/quest.py | 80 ------------------- examples/tui_crawler/scripts/milestone.txt | 27 ++++++- src/osrlib/crawl/dungeon.py | 8 ++ tests/crawl_fixtures.py | 2 +- tests/test_crawl_properties.py | 10 ++- tests/test_example_crawler.py | 52 ++++++++++--- tests/test_fastapi_crawler.py | 76 +++++++++++++++--- 13 files changed, 381 insertions(+), 206 deletions(-) delete mode 100644 examples/tui_crawler/quest.py diff --git a/docs/front-ends/tui-crawler.md b/docs/front-ends/tui-crawler.md index e262f0b..9430830 100644 --- a/docs/front-ends/tui-crawler.md +++ b/docs/front-ends/tui-crawler.md @@ -124,45 +124,61 @@ magic-user, kitted out from its own starting gold — is what the non-interactiv --8<-- "examples/tui_crawler/create.py:scripted-party-fn" ``` -## The fetch quest: a listener, not a library change +## The fetch quest: authored data, not front-end code -The barrow's hook — "the temple pays 200 gp for the Jade Idol's return" — is tracked -entirely in the example's own code. `quest.py` defines a listener and `__main__.py` -registers it on the session right after creating it, alongside the housekeeping that -lines up the session's RNG streams with the ones character creation already drew -from: +The barrow's hook — "the temple pays 200 gp for the Jade Idol's return" — is part of +the adventure, not part of the crawler. The idol is a bundled +[`GearTemplate`][osrlib.core.items.GearTemplate] the shop never stocks, dropped into +the shrine cache by id, so picking it up reports a catalog id anything can match on: ```{.python .no-run} ---8<-- "examples/tui_crawler/__main__.py:register-quest-listener" +--8<-- "examples/tui_crawler/content.py:bundled-idol" ``` -A registered [`Listener`][osrlib.crawl.session.Listener] runs after every command, -watching the events that command produced. `FetchQuestListener` watches for an -[`ItemAcquiredEvent`][osrlib.crawl.events.ItemAcquiredEvent] naming the idol and a -[`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent] back in town. It -never mutates session state itself — every effect it has goes through the same -commands any front end could issue, which is why replays and saves stay honest: +The quest itself is a [`QuestSpec`][osrlib.crawl.quests.QuestSpec] in the same file — +an activation clause, two objectives, three rewards, and the marker that says +finishing it finishes the adventure: ```{.python .no-run} ---8<-- "examples/tui_crawler/quest.py:fetch-quest-listener" +--8<-- "examples/tui_crawler/content.py:fetch-quest" ``` -The reward is granted the moment the idol is picked up, in the dungeon — not on the -later town-return event — because that ordering lets the end-of-adventure treasure -award count the coin. A town-return grant would land one event too late to be -counted. Watching the transcript, the reward shows up as a second acquisition line -immediately after the idol itself: +Nothing in the crawler tracks any of it. `__main__.py` registers the library's +[`Interpreter`][osrlib.crawl.interpreter.Interpreter] on the session right after +creating it, alongside the housekeeping that lines up the session's RNG streams with +the ones character creation already drew from: + +```{.python .no-run} +--8<-- "examples/tui_crawler/__main__.py:register-interpreter" +``` + +The interpreter is an ordinary [`Listener`][osrlib.crawl.session.Listener]: it runs +after every command, matches the events against the adventure's triggers and quests, +and acts the only way anything outside the engine may — by executing referee +commands, each stamped with the quest it acted for. The transcript shows the beats as +they land, rendered from typed events by the same formatter as everything else: ```text > take idol_shrine - character-0001 acquires valuable-0005 and 50 gp in coin. + character-0001 acquires jade-idol and 50 gp in coin. + Quest the-idol: objective recover-idol is done. The idol comes up out of the hollow, cold as well-water. +> town + The party enters town town. + The adventure ends: 0 XP from monsters and 50 XP from treasure — 12 XP to each of 4 survivor(s). + Quest the-idol: objective return-home is done. Threshold's gate shuts behind you with the idol inside it. + Quest complete: The Jade Idol. The almoner counts out the reward without looking up. The idol is home. + The adventure is over: the-idol is finished. character-0001 acquires 200 gp in coin. ``` -On the walk back to town, the listener sets a flag and awards each survivor bonus -experience — again through ordinary commands, not by reaching into the party -directly. [Listeners and flags](../guides/listeners-and-flags.md) covers the listener -contract and the flag store this pattern relies on in full. +The homecoming objective is a `town_entered` pattern narrowed by a `has_item` +condition, so walking back empty-handed is not a return — which is exactly why the +milestone script makes two trips, doing its selling and healing on the first one. +The second return ends the adventure in `victory`, and the temple pays afterwards: +a concluded session takes referee commands but no play. +[Listeners and flags](../guides/listeners-and-flags.md) covers the listener contract +the interpreter follows, and [Building an adventure](../getting-started/building-an-adventure.md) +covers authoring quests of your own. ## Where next diff --git a/docs/guides/listeners-and-flags.md b/docs/guides/listeners-and-flags.md index ead36f1..a443997 100644 --- a/docs/guides/listeners-and-flags.md +++ b/docs/guides/listeners-and-flags.md @@ -8,8 +8,9 @@ carry it without forking the library: **listeners**, which watch every command's react by executing more commands, and **flags**, a small piece of session state your game reads and writes directly. -This page works through both mechanics as the code implements them, then retells the TUI -crawler's fetch quest — the worked example both mechanisms exist for. [The complete program](#the-complete-program) +This page works through both mechanics as the code implements them, then works a fetch quest as a +game-owned listener — the example both mechanisms exist for, and the shape the library's own +interpreter takes when the quest is adventure data instead. [The complete program](#the-complete-program) at the end is a self-contained, runnable illustration you can read start to finish. ## Listeners: reacting to committed events @@ -126,10 +127,10 @@ 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. The library's own trigger interpreter is built on +log that answers *why* every entry is there. The library's own interpreter is built on exactly this surface, and a game's own listener drives it the same way. -## The trigger interpreter: this pattern, shipped +## The 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 @@ -140,9 +141,10 @@ 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: +[triggers](../getting-started/building-an-adventure.md#wiring-the-dungeon-with-triggers) and its +[`QuestSpec`][osrlib.crawl.quests.QuestSpec]s, and reacts the only way a listener may: by +executing referee commands, each stamped `source="trigger:{id}"` or `source="quest:{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. @@ -157,43 +159,71 @@ reacts the only way a listener may: by executing referee commands, each stamped 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 +## A fetch quest, worked -The TUI crawler (see [the complete front end](../front-ends/tui-crawler.md)) hides a named -valuable, the Jade Idol, in a hand-placed treasure cache and tracks its recovery with a listener -registered once, right after the session is created: +Most fetch quests belong in the adventure document, where +[`QuestSpec`][osrlib.crawl.quests.QuestSpec] says what to fetch and the interpreter above plays +it — the TUI crawler's Jade Idol is authored exactly that way (see +[the complete front end](../front-ends/tui-crawler.md)). But the same errand is a fair worked +example of the game-owned pattern, because everything a quest needs is on this page's surface: a +listener that watches events, keeps its own objective state, and acts through commands. ```{.python .no-run} -session = GameSession.new(party, adventure, seed=arguments.seed, ruleset=ruleset) -session.register_listener(FetchQuestListener(session)) -``` +class FetchQuestListener: + """Recover an item and bring it home — a quest tracker as a listener.""" -Here is the listener in full, from `examples/tui_crawler/quest.py`: + key = "fetch_quest" -```{.python .no-run} ---8<-- "examples/tui_crawler/quest.py:fetch-quest-listener" + def __init__(self, session) -> None: + self._session = session + self._reacting = False + + def _carrier(self): + for member in self._session.party.members: + if member.inventory.carried_item("jade-idol") is not None: + return member + return None + + def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dict]: + if self._reacting: + return [], state + state = dict(state) + acquired = any(isinstance(event, ItemAcquiredEvent) for event in events) + if acquired and not state.get("recovered") and self._carrier() is not None: + state["recovered"] = True + home = any( + isinstance(event, LocationEnteredEvent) and event.location_kind == "town" for event in events + ) + if home and state.get("recovered") and not state.get("completed"): + state["completed"] = True + self._reacting = True + try: + self._session.execute(SetFlag(key="quest.idol", value="recovered")) + for member in self._session.party.living_members(): + self._session.execute(AwardXP(character_id=member.id, amount=1200)) + finally: + self._reacting = False + return [], state ``` A few things worth calling out: -- `state["reward_granted"]` and `state["completed"]` are the quest's own objective tracking, kept +- `state["recovered"]` and `state["completed"]` are the quest's own objective tracking, kept entirely inside `session.listener_state["fetch_quest"]`. The session has no idea this is a quest; it just stores whatever dict `handle` hands back. -- The reward fires the instant an `ItemAcquiredEvent` shows up in `events` — whenever the idol - lands in a party member's pack, not when the party gets back to town. That timing is - deliberate: under the default `on_return` XP timing (see [Ruleset options](ruleset-options.md)), - the adventure's award is the delta between the party's treasure valuation at the moment of - return and at departure. Coin granted while still in the dungeon counts toward that delta; - coin granted at the town-return event would arrive after the award already fired. -- `self._reacting` is the re-entrancy guard from the previous section, earned honestly: - `GrantCoins`'s handler emits its own `ItemAcquiredEvent` (a coin grant is an acquisition too), - which matches this same listener's trigger condition. Without the guard, the nested `execute` - call would run `handle` again while `session.listener_state["fetch_quest"]` still held its - pre-reward value, see an apparently ungranted reward, and call `GrantCoins` again — and again, - without ever returning. +- `self._reacting` is the re-entrancy guard from the previous section, earned honestly: the + commands this listener issues emit events of their own, and `AwardXP` on the last member would + otherwise re-enter `handle` while the state slot still held its pre-completion value. - The `handle` method returns `[], state` unconditionally. Every event this listener causes travels through `self._session.execute(...)`, which already logs it; there is nothing left for the returned-events list to carry. +- Nothing here reaches into party state to *change* it. The flag and the XP both land as ordinary + commands, which is why a save, a load, and a replay all agree about what happened. + +The interpreter does all of this for you when the quest is adventure data instead — the objective +state lives in `session.quests`, the reward commands carry a `source="quest:{id}"` stamp, and the +listener slot stays empty. Reach for a listener like the one above when a game's own systems own +the objective, and for [`QuestSpec`][osrlib.crawl.quests.QuestSpec] when the adventure does. ## The complete program diff --git a/examples/fastapi_crawler/content.py b/examples/fastapi_crawler/content.py index 0eec21e..fdb2781 100644 --- a/examples/fastapi_crawler/content.py +++ b/examples/fastapi_crawler/content.py @@ -1,16 +1,19 @@ """The served content: the TUI crawler's barrow, unchanged, plus the session wiring. The adventure is imported from `examples.tui_crawler.content` verbatim — the same -content behind a terminal and an HTTP API is the spec's presentation-agnostic claim -made concrete. This module owns the game-side wiring both entry paths share: build -or restore a `GameSession` and register the fetch-quest listener (listeners are game -objects, so a restored session re-registers them — the `load_game` contract). +content behind a terminal and an HTTP API is the presentation-agnostic claim made +concrete, and the fetch quest travels with it, because the quest is adventure data +rather than front-end code. This module owns the game-side wiring both entry paths +share: build or restore a `GameSession` and register the +[`Interpreter`][osrlib.crawl.interpreter.Interpreter] that plays the adventure's +triggers and quests (listeners are code, so a restored session re-registers them — +the `load_game` contract). """ from collections.abc import Mapping from examples.tui_crawler.content import build_adventure -from examples.tui_crawler.quest import FetchQuestListener +from osrlib.crawl.interpreter import Interpreter from osrlib.crawl.party import Party from osrlib.crawl.session import GameSession from osrlib.persistence import load_game @@ -19,7 +22,7 @@ def new_session(party: Party, *, seed: int) -> GameSession: - """Create a session serving the barrow, with the fetch quest listening. + """Create a session serving the barrow, with the adventure's quest in play. Args: party: The party, in marching order. @@ -29,12 +32,12 @@ def new_session(party: Party, *, seed: int) -> GameSession: The session, in town, at round 0. """ session = GameSession.new(party, build_adventure(), seed=seed) - session.register_listener(FetchQuestListener(session)) + session.register_listener(Interpreter(session)) return session def restore_session(document: Mapping[str, object]) -> GameSession: - """Restore a session from a save document, re-registering the quest listener. + """Restore a session from a save document, re-registering the interpreter. Args: document: A save document from the server-side store. @@ -47,5 +50,5 @@ def restore_session(document: Mapping[str, object]) -> GameSession: SaveVersionError: If the save's schema version is newer than the engine's. """ session = load_game(document) - session.register_listener(FetchQuestListener(session)) + session.register_listener(Interpreter(session)) return session diff --git a/examples/tui_crawler/README.md b/examples/tui_crawler/README.md index 1a20814..1f6290e 100644 --- a/examples/tui_crawler/README.md +++ b/examples/tui_crawler/README.md @@ -23,6 +23,7 @@ use character-0001 magic-item-0001 # drink, read, or activate a magic item rest turn # rest (turn / night / day) town # return to town from the entrance sell all # sell carried valuables at full value +give character-0001 character-0002 550 # hand coin to a companion (coin weighs!) heal character-0002 cure_light_wounds # buy a temple service status # party summary quit @@ -34,28 +35,31 @@ Non-interactive mode replays a transcript with a fixed party and seed: uv run python -m examples.tui_crawler --seed 5 --script examples/tui_crawler/scripts/milestone.txt ``` -That transcript is the milestone playthrough: the delve, a generated goblin-lair -hoard, a rival adventuring party fought and looted, the Jade Idol recovered, the -return to town, the end-of-adventure XP award, and a character reaching level 2. +That transcript is the milestone playthrough, in two trips: the delve, a generated +goblin-lair hoard, a rival adventuring party fought and looted, the first return +with its XP award and the town business, then back down for the Jade Idol and home +again — which completes the quest, pays the temple's reward, takes a character to +level 2, and ends the adventure in `victory`. `tests/test_example_crawler.py` drives exactly this run as the integration test. -## The quest pattern - -The fetch quest lives entirely in this example's own code, on the library's -listener/flags extension surface — the proof that games don't need library -changes for game-design systems: - -- `quest.py` registers a listener keyed `fetch_quest`. It watches - `ItemAcquiredEvent` for the Jade Idol and `LocationEnteredEvent` for the town - return, and keeps its objective state in the session's listener store (so it - snapshots into saves). -- It reacts by executing ordinary referee commands: `GrantCoins` for the recovery - reward **the moment the idol is acquired, in the dungeon** — where the next - award's valuation delta honors it. A reward granted at the town-return event - would land after the award fired and before the next snapshot, earning nothing; - the timing is part of the pattern. -- On the town return it executes `SetFlag("quest.idol", "recovered")` and an - `AwardXP` quest bonus per member. - -The listener never mutates game state directly; everything it causes goes through -logged commands, so replays and saves stay honest. +## The quest, as adventure data + +The fetch quest is content, not code. `content.py` bundles the Jade Idol as a +`GearTemplate` the shop never stocks, drops it into the shrine cache by id, and +authors a `QuestSpec` beside the dungeons: + +- **Activation** — a `dungeon_entered` clause: crossing the barrow's threshold puts + the errand in play, and its offer beat lands in the party's journal. +- **Objectives** — `recover-idol` matches the acquisition of `jade-idol` by catalog + id; `return-home` matches a `town_entered` crossing narrowed by a `has_item` + condition, so walking back without the idol is not a return. +- **Rewards**, issued after the quest completes: 200 gp to the lead survivor, an XP + award to the whole party, and `SetFlag("quest.idol", "recovered")`. +- **`concludes_adventure=True`** — finishing the quest ends the adventure in + `victory`, which is why the script does its selling and healing on the first trip. + +`__main__.py` registers `Interpreter(session)` and nothing else: the library's own +listener matches the clauses, issues every lifecycle and reward command stamped +`source="quest:the-idol"`, and holds no state of its own. A game that wants its own +quest system instead writes a listener on the same surface — see +[Listeners and flags](https://mmacy.github.io/osrlib-python/guides/listeners-and-flags/). diff --git a/examples/tui_crawler/__main__.py b/examples/tui_crawler/__main__.py index fe926cc..1d57127 100644 --- a/examples/tui_crawler/__main__.py +++ b/examples/tui_crawler/__main__.py @@ -11,12 +11,14 @@ from osrlib.core.character import CHARACTER_CREATION_STREAM from osrlib.core.events import Visibility +from osrlib.core.items import Coins from osrlib.core.ruleset import Ruleset from osrlib.crawl.commands import ( BattleDeclaration, EngageBattle, EnterDungeon, Evade, + GiveItems, MoveParty, Parley, PurchaseEquipment, @@ -30,12 +32,12 @@ UseStairs, Wait, ) +from osrlib.crawl.interpreter import Interpreter from osrlib.crawl.session import GameSession from osrlib.messages import format_message from .content import build_adventure from .create import interactive_party, scripted_party -from .quest import FetchQuestListener _DIRECTIONS = {"n": "north", "s": "south", "e": "east", "w": "west"} @@ -45,7 +47,8 @@ def _run(session, command): """Execute one command and print every player-visible event it logged. Printing the event-log delta (rather than the result's events) shows the - quest listener's reactions too: its nested commands append to the same log. + interpreter's reactions too: the commands it issues for the adventure's + triggers and quests append to the same log. """ mark = len(session.event_log) result = session.execute(command) @@ -185,6 +188,10 @@ def _dispatch(session, line: str) -> bool: command = SellTreasure(item_ids=instance_ids) else: command = SellTreasure(item_ids=tuple(args)) + elif verb == "give" and len(args) >= 3: + # The distribute-the-load move: a purse full of sale coin weighs a coin + # apiece, and the party moves at its slowest member's rate. + command = GiveItems(character_id=args[0], recipient_id=args[1], coins=Coins(gp=int(args[2]))) elif verb == "heal" and len(args) >= 2: command = PurchaseHealing.model_validate({"character_id": args[0], "service": args[1]}) elif verb == "use" and args: @@ -217,11 +224,11 @@ def main(argv: list[str] | None = None) -> int: party = scripted_party(creation_stream, ruleset) else: party = interactive_party(creation_stream, ruleset) - # --8<-- [start:register-quest-listener] + # --8<-- [start:register-interpreter] session = GameSession.new(party, adventure, seed=arguments.seed, ruleset=ruleset) session.streams.restore_states(streams.export_states()) - session.register_listener(FetchQuestListener(session)) - # --8<-- [end:register-quest-listener] + session.register_listener(Interpreter(session)) + # --8<-- [end:register-interpreter] print(f"— {adventure.name} —") print(adventure.description) diff --git a/examples/tui_crawler/content.py b/examples/tui_crawler/content.py index 3082b92..17dac22 100644 --- a/examples/tui_crawler/content.py +++ b/examples/tui_crawler/content.py @@ -1,16 +1,19 @@ -"""The authored mini-adventure: a town, a two-level barrow, and the quest MacGuffin. +"""The authored mini-adventure: a town, a two-level barrow, and the quest that ends it. Everything here is frozen game content built from the library's authoring models: a keyed goblin lair whose treasure ref (`R (C)`) generates a real hoard when the encounter first spawns, an `unguarded: true` vault on level 2, a custom wandering table whose rows field Basic Adventurers (the level-2 halls are picked clean of -monsters — rival parties prowl them instead), and the Jade Idol — a named valuable -in a hand-placed cache — whose recovery the fetch quest in `quest.py` watches. +monsters — rival parties prowl them instead), the Jade Idol — a bundled item in a +hand-placed cache, so taking it reports a catalog id — and the fetch quest that +watches for it, written as adventure data and played by the library's +[`Interpreter`][osrlib.crawl.interpreter.Interpreter]. """ -from osrlib.core.items import Coins +from osrlib.core.items import Coins, GearTemplate from osrlib.core.tables import EncounterTable, EncounterTableRow, NpcPartyEncounterEntry from osrlib.crawl.adventure import Adventure, TownSpec +from osrlib.crawl.commands import AwardXP, GrantCoins, SetFlag from osrlib.crawl.dungeon import ( AreaSpec, AreaTreasureSpec, @@ -23,14 +26,29 @@ KeyedMonster, LevelSpec, TransitionSpec, - ValuableSpec, WanderingSpec, ) +from osrlib.crawl.gates import HasItemCondition +from osrlib.crawl.narrative import NarrativeBlock +from osrlib.crawl.quests import ObjectiveSpec, QuestSpec, TriggerClause +from osrlib.crawl.triggers import ( + FIRST_LIVING_SELECTOR, + PARTY_SELECTOR, + DungeonEnteredPattern, + ItemAcquiredPattern, + TownEnteredPattern, +) +IDOL_ID = "jade-idol" IDOL_NAME = "Jade Idol of the Barrow King" -IDOL_VALUE_GP = 2200 QUEST_REWARD_GP = 200 -QUEST_BONUS_XP = 600 +QUEST_BONUS_XP = 1200 + +# --8<-- [start:bundled-idol] +JADE_IDOL = GearTemplate(id=IDOL_ID, name=IDOL_NAME, cost_gp=0) +"""The MacGuffin as a bundled item: an id the shop never stocks and the temple wants +back. Carrying it is a fact the quest can match on and a condition it can test.""" +# --8<-- [end:bundled-idol] def _open_row(level_y: int, width: int) -> dict[str, Edge]: @@ -59,6 +77,51 @@ def _rival_party_table() -> EncounterTable: # --8<-- [end:wandering-table] +# --8<-- [start:fetch-quest] +def _fetch_quest() -> QuestSpec: + """The temple's errand, authored as data: take the idol, bring it home. + + Both clauses are the trigger vocabulary the library already speaks — an + acquisition matched on the bundled idol's catalog id, and a homecoming narrowed + by a condition that asks whether the party is still carrying it. Walking back + empty-handed is not a return; the second objective simply does not fire. + """ + return QuestSpec( + id="the-idol", + name="The Jade Idol", + activation=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="barrow")), + objectives=( + ObjectiveSpec( + id="recover-idol", + when=TriggerClause(pattern=ItemAcquiredPattern(item_id=IDOL_ID)), + narrative=NarrativeBlock(progress="The idol comes up out of the hollow, cold as well-water."), + ), + ObjectiveSpec( + id="return-home", + when=TriggerClause( + pattern=TownEnteredPattern(), + conditions=(HasItemCondition(item_id=IDOL_ID),), + ), + narrative=NarrativeBlock(progress="Threshold's gate shuts behind you with the idol inside it."), + ), + ), + rewards=( + GrantCoins(character_id=FIRST_LIVING_SELECTOR, coins=Coins(gp=QUEST_REWARD_GP)), + AwardXP(character_id=PARTY_SELECTOR, amount=QUEST_BONUS_XP), + SetFlag(key="quest.idol", value="recovered"), + ), + concludes_adventure=True, + narrative=NarrativeBlock( + offer="The temple wants the Jade Idol off the barrow king's altar and back on its own.", + completion="The almoner counts out the reward without looking up. The idol is home.", + speaker="the temple almoner", + ), + ) + + +# --8<-- [end:fetch-quest] + + def build_adventure() -> Adventure: """Build the example adventure — the milestone's playable minimal crawl.""" level_one = LevelSpec( @@ -88,9 +151,7 @@ def build_adventure() -> Adventure: description="The idol rests in a hollow under the altar stone.", cell=(4, 0), coins=Coins(gp=50), - valuables=( - ValuableSpec(kind="jewellery", name=IDOL_NAME, value_gp=IDOL_VALUE_GP, weight_coins=10), - ), + item_ids=(IDOL_ID,), ), ), ), @@ -107,6 +168,12 @@ def build_adventure() -> Adventure: ), ), wandering=WanderingSpec(chance_in_six=0), + # --8<-- [start:level-guidance] + guidance=( + "Grave goods, not treasure: the barrow king was buried, not hoarded. " + "Keep the goblins squalid and the shrine quiet." + ), + # --8<-- [end:level-guidance] ) level_two = LevelSpec( number=2, @@ -133,6 +200,7 @@ def build_adventure() -> Adventure: ), ), wandering=WanderingSpec(chance_in_six=6, interval_turns=1, table=_rival_party_table()), + guidance="Somebody else is always down here first. Rivals are rude, not evil.", ) return Adventure( name="The Barrow of the Forgotten King", @@ -145,4 +213,6 @@ def build_adventure() -> Adventure: travel_turns={"barrow": 2}, ), dungeons=(DungeonSpec(id="barrow", name="The Barrow", levels=(level_one, level_two)),), + items=(JADE_IDOL,), + quests=(_fetch_quest(),), ) diff --git a/examples/tui_crawler/quest.py b/examples/tui_crawler/quest.py deleted file mode 100644 index e83a885..0000000 --- a/examples/tui_crawler/quest.py +++ /dev/null @@ -1,80 +0,0 @@ -"""The fetch quest — the spec's extension-surface proof, in game code only. - -The listener is keyed `fetch_quest`, watches `ItemAcquiredEvent` for the MacGuffin -and `LocationEnteredEvent` for the town return, keeps its objective state in the -listener store, and reacts by executing ordinary referee commands: - -- `GrantCoins` for the recovery reward the moment the idol is acquired, *in the - dungeon*, where the next award's valuation delta honors it — a reward granted at - the town-return event would land after the award fired and before the next - snapshot, earning nothing. The timing is part of the quest pattern the example - teaches. -- `SetFlag("quest.idol", "recovered")` and an `AwardXP` quest bonus on the town - return. - -No library change: the listener reads session state, never mutates it directly, -and everything it causes goes through logged commands. Because those nested -executes append their own events to the session log, the listener returns no -events of its own — returning the nested results would double-log them. The -returned-events channel is for events a listener *authors* directly. -""" - -from collections.abc import Sequence - -from osrlib.core.events import Event -from osrlib.core.items import Coins -from osrlib.crawl.commands import AwardXP, GrantCoins, SetFlag -from osrlib.crawl.events import ItemAcquiredEvent, LocationEnteredEvent - -from .content import IDOL_NAME, QUEST_BONUS_XP, QUEST_REWARD_GP - - -# --8<-- [start:fetch-quest-listener] -class FetchQuestListener: - """Recover the Jade Idol and bring it home — a quest tracker as a listener.""" - - key = "fetch_quest" - - def __init__(self, session) -> None: - """Bind the listener to the session it issues referee commands through.""" - self._session = session - self._reacting = False - - def _idol_carrier(self): - for member in self._session.party.members: - for valuable in member.inventory.valuables: - if valuable.name == IDOL_NAME: - return member - return None - - def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dict]: - """React to one command's events (see the session listener contract).""" - if self._reacting: - return [], state - state = dict(state) - acquired = any(isinstance(event, ItemAcquiredEvent) for event in events) - if acquired and not state.get("reward_granted"): - carrier = self._idol_carrier() - if carrier is not None: - state["reward_granted"] = True - self._reacting = True - try: - self._session.execute(GrantCoins(character_id=carrier.id, coins=Coins(gp=QUEST_REWARD_GP))) - finally: - self._reacting = False - returned_to_town = any( - isinstance(event, LocationEnteredEvent) and event.location_kind == "town" for event in events - ) - if returned_to_town and state.get("reward_granted") and not state.get("completed"): - state["completed"] = True - self._reacting = True - try: - self._session.execute(SetFlag(key="quest.idol", value="recovered")) - for member in self._session.party.living_members(): - self._session.execute(AwardXP(character_id=member.id, amount=QUEST_BONUS_XP)) - finally: - self._reacting = False - return [], state - - -# --8<-- [end:fetch-quest-listener] diff --git a/examples/tui_crawler/scripts/milestone.txt b/examples/tui_crawler/scripts/milestone.txt index 7d494a0..7d32525 100644 --- a/examples/tui_crawler/scripts/milestone.txt +++ b/examples/tui_crawler/scripts/milestone.txt @@ -1,6 +1,9 @@ # The milestone playthrough: creation and outfitting happen before the loop -# (the scripted party buys its kit from starting gold); this transcript is the -# delve, the quest, the return, and the award. +# (the scripted party buys its kit from starting gold). Two trips, because the +# quest ends the adventure the moment the idol comes home: the first trip is the +# delve and the town business, the second is the errand itself. +# +# Trip one: the goblins, their lair hoard, the rivals in the halls below. enter move e move e @@ -8,7 +11,6 @@ fight take cache move e move e -take idol_shrine stairs move e move e @@ -24,6 +26,25 @@ move w move w move w town +# Town, while there is still an adventure to come back from: sell the haul, buy +# the healing, bank the first award. sell all heal character-0001 cure_light_wounds +# Coin weighs a coin apiece, and the party moves at its slowest member's rate, so +# the seller's purse is spread before anybody walks anywhere. +give character-0001 character-0002 550 +give character-0001 character-0004 550 +status +# Trip two: the idol, and the walk home that ends it. +enter +move e +move e +move e +move e +take idol_shrine +move w +move w +move w +move w +town status diff --git a/src/osrlib/crawl/dungeon.py b/src/osrlib/crawl/dungeon.py index 223c12c..19a6cd2 100644 --- a/src/osrlib/crawl/dungeon.py +++ b/src/osrlib/crawl/dungeon.py @@ -553,6 +553,14 @@ class LevelSpec(BaseModel): transitions: tuple[TransitionSpec, ...] = () wandering: WanderingSpec = WanderingSpec() entrance: Position | None = None + guidance: str = "" + """Ambient steering for a narrating front end while the party is on this level — + the tone of the place, what it wants said, what it never says. + + Inert authored data: the engine reads it nowhere, no event carries it, and no + rule turns on it. A narrator reaches it through the adventure document, which is + referee-side by construction — the player view ships no level internals — so it + is trusted like an area's description prose and shown to nobody verbatim.""" def in_bounds(self, position: Position) -> bool: """Return whether a cell lies on this level's grid. diff --git a/tests/crawl_fixtures.py b/tests/crawl_fixtures.py index 6b3c24c..b998f63 100644 --- a/tests/crawl_fixtures.py +++ b/tests/crawl_fixtures.py @@ -537,7 +537,7 @@ def build_portcullis_adventure() -> Adventure: ) -BARROW_IDOL = GearTemplate(id="votive_idol", name="Votive idol of jade", cost_gp=0, weight_coins=40) +BARROW_IDOL = GearTemplate(id="votive_idol", name="Votive idol of jade", cost_gp=0) """The barrow's MacGuffin, bundled by the adventure so taking it reports a catalog id the quest's objective can match.""" diff --git a/tests/test_crawl_properties.py b/tests/test_crawl_properties.py index a467c11..5b3a995 100644 --- a/tests/test_crawl_properties.py +++ b/tests/test_crawl_properties.py @@ -56,6 +56,7 @@ VIGIL_ID = "the-vigil" VIGIL_NAME = "A Vigil Nobody Asked For" QUEST_GUIDANCE = "Steer the table toward the barrow road." +LEVEL_GUIDANCE = "Play the delve as a place somebody else already stripped." def leaky_quest_adventure(): @@ -74,7 +75,13 @@ def leaky_quest_adventure(): activation=TriggerClause(pattern=TownEnteredPattern()), objectives=(ObjectiveSpec(id="keep-watch", when=TriggerClause(pattern=TownEnteredPattern())),), ) - return build_adventure().model_copy(update={"quests": (fetch, vigil)}) + adventure = build_adventure() + # A level that steers a narrator: authored data the engine reads nowhere and the + # player view must never carry, whatever the fuzz walks over. + delve = adventure.dungeons[0] + levels = (delve.levels[0].model_copy(update={"guidance": LEVEL_GUIDANCE}), *delve.levels[1:]) + dungeons = (delve.model_copy(update={"levels": levels}),) + return adventure.model_copy(update={"quests": (fetch, vigil), "dungeons": dungeons}) def plant_magic_items(session) -> None: @@ -448,6 +455,7 @@ def test_the_player_view_never_leaks(seed, commands): "the quest list carries the offer beat alone; the completion beat's home is the journal" ) assert QUEST_GUIDANCE not in blob, "steering for a narrator is never shown to the table" + assert LEVEL_GUIDANCE not in blob, "a level's ambient guidance is referee-side by construction" # Only active quests, only revealed objectives — whatever the fuzz did to the block. assert {entry["id"] for entry in parsed["quests"]} == { quest_id for quest_id, state in session.quests.items() if state.status == "active" diff --git a/tests/test_example_crawler.py b/tests/test_example_crawler.py index 50e439c..6a1a200 100644 --- a/tests/test_example_crawler.py +++ b/tests/test_example_crawler.py @@ -2,10 +2,13 @@ The scripted playthrough runs through the example's actual terminal loop (a real subprocess of `python -m examples.tui_crawler`): creation, outfitting, the delve -with a generated lair hoard, a rival NPC party fought and looted, the MacGuffin, -the return, the award, a character reaching level 2, and the quest flag set by -the example's own listener. The same milestone runs as a golden through -`GameSession.execute` in `test_phase5_goldens.py`. +with a generated lair hoard, a rival NPC party fought and looted, the first return +with its award and town business, the second trip for the MacGuffin, and the +homecoming that completes the authored quest — the reward paid, the flag set, a +character at level 2, and the adventure closed in victory. Nothing in the example +tracks the quest: it is adventure data, played by the library's interpreter. The +same milestone runs as a golden through `GameSession.execute` in +`test_phase5_goldens.py`. """ import subprocess @@ -39,14 +42,43 @@ def test_scripted_run_reaches_the_milestone(self): # The rival party: a wandering NPC encounter fought and looted. assert "Adventurers" in out assert "The battle is won." in out - # The MacGuffin and the quest reward, granted in the dungeon. - assert "acquires" in out and "200 gp in coin" in out - # The return and the end-of-adventure award. - assert "The adventure ends:" in out - # The level-up, the quest flag, and the town services. + # Two trips, two returns, two awards — the town business happens on the + # first, while there is still an adventure to come back from. + assert out.count("The adventure ends:") == 2 + assert "purchases cure_light_wounds at the temple" in out + assert "sells 1 valuable(s)" in out + # The MacGuffin and the reward, paid on delivery. + assert "acquires jade-idol" in out + assert "acquires 200 gp in coin" in out + # The level-up and the quest flag. assert "Highest level reached: 2" in out assert "quest.idol = 'recovered'" in out - assert "purchases cure_light_wounds at the temple" in out + # The adventure is over, and the closing status says so. + assert "[victory]" in out + + def test_the_quest_runs_as_authored_data(self): + result = run_crawler("--seed", str(MILESTONE_SEED), "--script", str(SCRIPT)) + out = result.stdout + # Every beat is the library's, rendered by the default formatter: the + # example registers an interpreter and authors no quest code at all. + assert "A new quest: The Jade Idol." in out + assert "Quest the-idol: objective recover-idol is done." in out + assert "Quest the-idol: objective return-home is done." in out + assert "Quest complete: The Jade Idol." in out + assert "The adventure is over: the-idol is finished." in out + # The authored beats ride those events verbatim. + assert "The temple wants the Jade Idol off the barrow king's altar" in out + assert "The almoner counts out the reward without looking up." in out + + def test_the_first_return_is_not_the_last(self): + result = run_crawler("--seed", str(MILESTONE_SEED), "--script", str(SCRIPT)) + out = result.stdout + # The idol comes home on the second trip, so the completion lands after the + # town business — the town-only commands would be refused the other way round. + completion = out.index("Quest complete: The Jade Idol.") + assert out.index("sells 1 valuable(s)") < completion + assert out.index("purchases cure_light_wounds at the temple") < completion + assert "(refused:" not in out, "the transcript runs clean end to end" def test_scripted_run_is_deterministic(self): first = run_crawler("--seed", str(MILESTONE_SEED), "--script", str(SCRIPT)) diff --git a/tests/test_fastapi_crawler.py b/tests/test_fastapi_crawler.py index 8fc3e99..edf30db 100644 --- a/tests/test_fastapi_crawler.py +++ b/tests/test_fastapi_crawler.py @@ -14,8 +14,10 @@ from fastapi.testclient import TestClient from osrlib.core.character import CHARACTER_CREATION_STREAM, party_to_document +from osrlib.core.items import ValuableInstance from osrlib.core.rng import RngStreams from osrlib.core.ruleset import Ruleset +from osrlib.crawl.commands import SessionMode from osrlib.versioning import SCHEMA_VERSION, engine_version REPO_ROOT = Path(__file__).resolve().parent.parent @@ -168,10 +170,17 @@ def test_server_draws_the_seed_when_the_client_sends_none(self, client): class TestScriptedPlaythrough: def test_the_shortened_barrow_script(self, client): - """Create, delve, fight the keyed goblins, loot the idol, return, award.""" + """Create, delve, fight the keyed goblins, take the idol, come home to victory.""" session_id = create_session(client) result = run(client, session_id, {"command_type": "enter_dungeon", "dungeon_id": "barrow"}) assert result["accepted"], result + # The threshold activates the adventure's quest — authored data, played by + # the interpreter the server registers, projected in the player's own view. + activated = [event for event in result["events"] if event["code"] == "session.quest.activated"] + assert activated and activated[0]["name"] == "The Jade Idol" + view = get_view(client, session_id) + assert [quest["id"] for quest in view["quests"]] == ["the-idol"] + assert [objective["state"] for objective in view["quests"][0]["objectives"]] == ["incomplete", "incomplete"] # East twice: the guard room's keyed goblins spawn an encounter. run(client, session_id, {"command_type": "move_party", "direction": "east"}) run(client, session_id, {"command_type": "move_party", "direction": "east"}) @@ -190,26 +199,55 @@ def test_the_shortened_barrow_script(self, client): assert result["accepted"], result acquired = [event for event in result["events"] if event["code"] == "exploration.item.acquired"] assert acquired, result["events"] + # The idol is a bundled item, so the acquisition reports a catalog id and the + # quest's first objective completes on it. The cache's 50 gp is all the coin + # that changes hands here: the temple pays on delivery. + completed = [event for event in result["events"] if event["code"] == "session.quest.objective_completed"] + assert [event["objective_id"] for event in completed] == ["recover-idol"] view = get_view(client, session_id) - # The cache's 50 gp plus the fetch quest's 200 gp reward, granted by the - # listener's nested command (visible in the view — nested events log - # server-side rather than riding the outer result envelope). gold_after = sum(member["inventory"]["purse"]["gp"] for member in view["party"]) - assert gold_after == gold_before + 250 - carried = [valuable["name"] for member in view["party"] for valuable in member["inventory"]["valuables"]] - assert "Jade Idol of the Barrow King" in carried - # Home: west to the entrance, then the town travel fires the award. + assert gold_after == gold_before + 50 + carried = [ + instance["template"]["id"] + for member in view["party"] + for instance in member["inventory"]["items"] + if instance.get("template") + ] + assert "jade-idol" in carried + assert [objective["state"] for objective in view["quests"][0]["objectives"]] == ["complete", "incomplete"] + # Home: west to the entrance, then the town travel fires the award — and, + # behind it, the homecoming that completes the quest and ends the adventure. for _ in range(4): run(client, session_id, {"command_type": "move_party", "direction": "west"}) result = run(client, session_id, {"command_type": "travel_to_town"}) assert result["accepted"], result award = [event for event in result["events"] if event["code"] == "session.xp.adventure_award"] assert award and award[0]["treasure_xp"] > 0 - # Town services over the wire: sell the valuables, buy a healing. + codes = [event["code"] for event in result["events"]] + assert codes.index("session.xp.adventure_award") < codes.index("session.quest.completed") + assert "session.adventure.completed" in codes + view = get_view(client, session_id) + assert view["mode"] == "victory" + assert view["quests"] == [], "a finished quest leaves the list; its record is the journal" + assert any("The almoner counts out the reward" in entry["text"] for entry in view["journal"]) + + def test_town_services_over_the_wire(self, client): + """Sell and heal on a session the errand has not ended yet.""" + session_id = create_session(client) + # A valuable the party never had to delve for: the walkthrough above carries + # the idol home instead, and a concluded adventure sells nothing. + session, _lock = _sessions[session_id] + member = session.party.members[0] + member.inventory.valuables.append( + ValuableInstance(instance_id="valuable-7001", kind="gem", value_gp=120, weight_coins=1) + ) view = get_view(client, session_id) instance_ids = [ - valuable["instance_id"] for member in view["party"] for valuable in member["inventory"]["valuables"] + valuable["instance_id"] + for member_view in view["party"] + for valuable in member_view["inventory"]["valuables"] ] + assert instance_ids == ["valuable-7001"] result = run(client, session_id, {"command_type": "sell_treasure", "item_ids": instance_ids}) assert result["accepted"], result result = run( @@ -219,6 +257,20 @@ def test_the_shortened_barrow_script(self, client): ) assert result["accepted"], result + def test_the_town_is_closed_once_the_adventure_is_won(self, client): + """Victory is terminal over the wire too: play refuses, the record stands.""" + session_id = create_session(client) + session, _lock = _sessions[session_id] + session.mode = SessionMode.VICTORY + result = run( + client, + session_id, + {"command_type": "purchase_healing", "character_id": "character-0001", "service": "cure_light_wounds"}, + ) + assert not result["accepted"] + assert [rejection["code"] for rejection in result["rejections"]] == ["session.command.wrong_mode"] + assert get_view(client, session_id)["mode"] == "victory" + def test_save_and_restore_round_trip(self, client): session_id = create_session(client) run(client, session_id, {"command_type": "enter_dungeon", "dungeon_id": "barrow"}) @@ -378,6 +430,10 @@ def test_no_endpoint_ever_leaks(self, client): # Referee-visibility outcomes never appear in command results. assert "exploration.detection.rolled" not in blob assert '"referee"' not in blob + # Authored wiring the adventure carries: the quest's clauses and rewards, and + # the levels' ambient narrator guidance, are the game's side of the screen. + assert "guidance" not in blob and "Grave goods" not in blob + assert "pattern_type" not in blob and "rewards" not in blob # Unexplored geometry and monster HP ride the same guarantee: /view # returns session.view(Visibility.PLAYER) verbatim, whose projection the # leak property test (test_crawl_properties.test_the_player_view_never_leaks) From 87a23ffeae9f5defecf17930a250f793dd1934b7 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 6 Aug 2026 23:24:22 -0700 Subject: [PATCH 6/9] Regenerate phase5_milestone for the example's redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The golden scripts the reworked example, so the run it snapshots is new: two session.xp.adventure_award events instead of one (the two-trip script banks the delve award on the first return and the shrine's 50 gp on the second), extra travel and town turns on the clock, the quest block and victory mode in the final state, and the interpreter's provably empty listener slot where the deleted listener's state used to sit. The post_award checkpoint is now pinned explicitly to the final return and a first_return checkpoint joins it — named captures replacing the old last-town-wins accident. Every member's final XP is byte- identical to the previous golden: the retuned authored award covers exactly what the redesign removed from the valuation delta. Claude-Session: https://claude.ai/code/session_01NQ83sqNnXVeXEd55NK1xR7 --- tests/generate_phase5_goldens.py | 69 ++- tests/goldens/phase5_milestone.json | 758 +++++++++++++++++++++------- tests/test_phase5_goldens.py | 80 ++- 3 files changed, 672 insertions(+), 235 deletions(-) diff --git a/tests/generate_phase5_goldens.py b/tests/generate_phase5_goldens.py index 8693b38..ed5a5d6 100644 --- a/tests/generate_phase5_goldens.py +++ b/tests/generate_phase5_goldens.py @@ -10,10 +10,11 @@ same seed and text script `test_example_crawler.py` drives through the actual binary) resolved through `GameSession.execute`: creation from the session's own streams, the delve with its generated lair hoard, the rival NPC party - fought and looted, the MacGuffin and the quest listener's reactions, the - return, the award, and the level-up — with the accepted-command log, the full - event stream, the formatted transcript, per-stream final states, and - checkpoints mid-delve and post-award. + fought and looted, the first return with its award and town business, the + second trip for the MacGuffin, and the homecoming that completes the authored + quest into victory — with the accepted-command log, the full event stream, the + formatted transcript, per-stream final states, the quest block, and + checkpoints mid-delve, at the first return, and after the final one. Run `uv run python tests/generate_phase5_goldens.py` and explain any golden change in the commit message. @@ -55,16 +56,17 @@ def _example(): """Import the example package lazily — the repo root joins sys.path here. The example is not an installed package; the milestone golden reuses its - content, creation script, dispatcher, and quest listener directly. + content, creation script, and dispatcher directly. The quest is part of the + content now, so there is nothing else of the example's to import: the library's + own interpreter plays it. """ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) from examples.tui_crawler.__main__ import _dispatch from examples.tui_crawler.content import build_adventure from examples.tui_crawler.create import scripted_party - from examples.tui_crawler.quest import FetchQuestListener - return _dispatch, build_adventure, scripted_party, FetchQuestListener + return _dispatch, build_adventure, scripted_party # ------------------------------------------------------------------ hoards @@ -114,7 +116,7 @@ def build_npc_golden() -> dict: # ------------------------------------------------------------------ the milestone -def milestone_session(seed: int, *, listener: bool) -> tuple[GameSession, dict]: +def milestone_session(seed: int, *, interpreting: bool) -> tuple[GameSession, dict]: """Build the session exactly as the example binary does. Creation draws ride the session's own creation stream (the example's @@ -122,22 +124,25 @@ def milestone_session(seed: int, *, listener: bool) -> tuple[GameSession, dict]: Args: seed: The master seed. - listener: Register the example's quest listener. The scripted run wants - it; a command-log replay must not — the listener's commands are in - the log, and a registered listener would re-issue them. + interpreting: Register the library interpreter that plays the adventure's + quest. The scripted run wants it; a command-log replay must not — the + commands it issued are in the log, and a registered interpreter would + issue them a second time. Returns: The fresh session and the starting party's stamped document. """ - _, build_adventure, scripted_party, quest_listener = _example() + from osrlib.crawl.interpreter import Interpreter + + _, build_adventure, scripted_party = _example() ruleset = Ruleset() streams = RngStreams(master_seed=seed) party = scripted_party(streams.get(CHARACTER_CREATION_STREAM), ruleset) party_document = party_to_document(party.members) session = GameSession.new(party, build_adventure(), seed=seed, ruleset=ruleset) session.streams.restore_states(streams.export_states()) - if listener: - session.register_listener(quest_listener(session)) + if interpreting: + session.register_listener(Interpreter(session)) return session, party_document @@ -146,11 +151,14 @@ def run_milestone(seed: int) -> tuple[GameSession, dict, dict[str, int]]: Returns: The finished session, the starting party document, and the checkpoint - indices (accepted-command counts) mid-delve and post-award. + indices (accepted-command counts): mid-delve, at the first return with the + town business still ahead of it, and after the final return that ends the + adventure. """ - dispatch, _, _, _ = _example() - session, party_document = milestone_session(seed, listener=True) + dispatch, _, _ = _example() + session, party_document = milestone_session(seed, interpreting=True) checkpoints: dict[str, int] = {} + returns: list[int] = [] captured = io.StringIO() with contextlib.redirect_stdout(captured): for line in SCRIPT_PATH.read_text(encoding="utf-8").splitlines(): @@ -160,7 +168,13 @@ def run_milestone(seed: int) -> tuple[GameSession, dict, dict[str, int]]: if stripped == "take cache" and "mid_delve" not in checkpoints: checkpoints["mid_delve"] = len(session.command_log) elif stripped == "town": - checkpoints["post_award"] = len(session.command_log) + returns.append(len(session.command_log)) + if len(returns) != 2: + raise RuntimeError(f"the two-trip script must come home twice, not {len(returns)} times") + # Named rather than last-write-wins: the script comes home twice, and the two + # returns are different beats — the first banks an award with the town business + # and the whole second trip still ahead of it, the second ends the adventure. + checkpoints["first_return"], checkpoints["post_award"] = returns for marker in ("(refused:", "(unknown command", "(no cache here)", "(nothing to sell)"): if marker in captured.getvalue(): raise RuntimeError(f"the milestone script hit {marker!r} — the transcript no longer runs clean") @@ -169,7 +183,7 @@ def run_milestone(seed: int) -> tuple[GameSession, dict, dict[str, int]]: def replay_milestone(seed: int, commands) -> GameSession: """Replay an accepted-command log against the example's construction, listener-free.""" - session, _ = milestone_session(seed, listener=False) + session, _ = milestone_session(seed, interpreting=False) for entry in commands: command = entry if isinstance(entry, Command) else parse_command(entry) result = session.execute(command) @@ -193,11 +207,25 @@ def build_milestone_golden(seed: int) -> dict: "session.xp.awarded", "town.treasure.sold", "town.healing.purchased", + "session.quest.activated", + "session.quest.objective_completed", + "session.quest.completed", + "session.adventure.completed", ): if required not in codes: raise RuntimeError(f"milestone beat missing from the run: {required}") + quest = session.quests["the-idol"] + if quest.status != "completed" or not all(objective.complete for objective in quest.objectives.values()): + raise RuntimeError(f"the authored quest did not finish: {quest}") + if session.mode.value != "victory": + raise RuntimeError(f"the concluding quest left the session in {session.mode.value}") if session.flags.get("quest.idol") != "recovered": raise RuntimeError("the quest flag never set") + if session.listener_state != {"osrlib.interpreter": {}}: + raise RuntimeError(f"the interpreter kept state: {session.listener_state}") + awards = [entry for entry in session.event_log if getattr(entry, "code", None) == "session.xp.adventure_award"] + if len(awards) != 2: + raise RuntimeError(f"the two-trip script banks two awards, not {len(awards)}") if max(member.level for member in session.party.members) < 2: raise RuntimeError("nobody levelled up") return { @@ -214,6 +242,9 @@ def build_milestone_golden(seed: int) -> dict: "final_clock_rounds": session.clock.rounds, "defeated_monsters": [record.model_dump(mode="json") for record in session.defeated_monsters], "flags": dict(session.flags), + "mode": session.mode.value, + "quests": {quest_id: state.model_dump(mode="json") for quest_id, state in session.quests.items()}, + "journal": [entry.model_dump(mode="json") for entry in session.journal], "listener_state": {key: dict(value) for key, value in session.listener_state.items()}, "party_summary": [ {"id": member.id, "name": member.name, "class_id": member.class_id, "level": member.level, "xp": member.xp} diff --git a/tests/goldens/phase5_milestone.json b/tests/goldens/phase5_milestone.json index 4069f69..9faf456 100644 --- a/tests/goldens/phase5_milestone.json +++ b/tests/goldens/phase5_milestone.json @@ -1,7 +1,8 @@ { "checkpoints": { - "mid_delve": 11, - "post_award": 43 + "first_return": 37, + "mid_delve": 12, + "post_award": 61 }, "command_log": [ { @@ -9,6 +10,11 @@ "dungeon_id": "barrow", "source": null }, + { + "command_type": "activate_quest", + "quest_id": "the-idol", + "source": "quest:the-idol" + }, { "command_type": "move_party", "direction": "east", @@ -363,24 +369,6 @@ "direction": "east", "source": null }, - { - "command_type": "take_treasure", - "feature_id": "idol_shrine", - "recipient_id": null, - "source": null - }, - { - "character_id": "character-0002", - "coins": { - "cp": 0, - "ep": 0, - "gp": 200, - "pp": 0, - "sp": 0 - }, - "command_type": "grant_coins", - "source": null - }, { "command_type": "use_stairs", "source": null @@ -936,48 +924,159 @@ "source": null }, { - "command_type": "set_flag", - "key": "quest.idol", - "source": null, - "value": "recovered" + "command_type": "sell_treasure", + "item_ids": [ + "valuable-0001" + ], + "source": null }, { - "amount": 600, "character_id": "character-0001", - "command_type": "award_xp", + "command_type": "purchase_healing", + "service": "cure_light_wounds", "source": null }, { - "amount": 600, - "character_id": "character-0002", - "command_type": "award_xp", + "character_id": "character-0001", + "coins": { + "cp": 0, + "ep": 0, + "gp": 550, + "pp": 0, + "sp": 0 + }, + "command_type": "give_items", + "item_ids": [], + "recipient_id": "character-0002", "source": null }, { - "amount": 600, - "character_id": "character-0003", - "command_type": "award_xp", + "character_id": "character-0001", + "coins": { + "cp": 0, + "ep": 0, + "gp": 550, + "pp": 0, + "sp": 0 + }, + "command_type": "give_items", + "item_ids": [], + "recipient_id": "character-0004", "source": null }, { - "amount": 600, - "character_id": "character-0004", - "command_type": "award_xp", + "command_type": "enter_dungeon", + "dungeon_id": "barrow", "source": null }, { - "command_type": "sell_treasure", - "item_ids": [ - "valuable-0001", - "valuable-0002" - ], + "command_type": "move_party", + "direction": "east", "source": null }, { - "character_id": "character-0001", - "command_type": "purchase_healing", - "service": "cure_light_wounds", + "command_type": "move_party", + "direction": "east", + "source": null + }, + { + "command_type": "move_party", + "direction": "east", + "source": null + }, + { + "command_type": "move_party", + "direction": "east", + "source": null + }, + { + "command_type": "take_treasure", + "feature_id": "idol_shrine", + "recipient_id": null, + "source": null + }, + { + "command_type": "complete_objective", + "objective_id": "recover-idol", + "quest_id": "the-idol", + "source": "quest:the-idol" + }, + { + "command_type": "move_party", + "direction": "west", + "source": null + }, + { + "command_type": "move_party", + "direction": "west", + "source": null + }, + { + "command_type": "move_party", + "direction": "west", + "source": null + }, + { + "command_type": "move_party", + "direction": "west", + "source": null + }, + { + "command_type": "travel_to_town", "source": null + }, + { + "command_type": "complete_objective", + "objective_id": "return-home", + "quest_id": "the-idol", + "source": "quest:the-idol" + }, + { + "command_type": "complete_quest", + "quest_id": "the-idol", + "source": "quest:the-idol" + }, + { + "character_id": "character-0001", + "coins": { + "cp": 0, + "ep": 0, + "gp": 200, + "pp": 0, + "sp": 0 + }, + "command_type": "grant_coins", + "source": "quest:the-idol" + }, + { + "amount": 1200, + "character_id": "character-0001", + "command_type": "award_xp", + "source": "quest:the-idol" + }, + { + "amount": 1200, + "character_id": "character-0002", + "command_type": "award_xp", + "source": "quest:the-idol" + }, + { + "amount": 1200, + "character_id": "character-0003", + "command_type": "award_xp", + "source": "quest:the-idol" + }, + { + "amount": 1200, + "character_id": "character-0004", + "command_type": "award_xp", + "source": "quest:the-idol" + }, + { + "command_type": "set_flag", + "key": "quest.idol", + "source": "quest:the-idol", + "value": "recovered" } ], "defeated_monsters": [], @@ -992,6 +1091,14 @@ "narrative": null, "visibility": "player" }, + { + "code": "session.quest.activated", + "event_type": "quest_activated", + "name": "The Jade Idol", + "narrative": "The temple wants the Jade Idol off the barrow king's altar and back on its own.", + "quest_id": "the-idol", + "visibility": "player" + }, { "code": "exploration.party.moved", "event_type": "party_moved", @@ -1695,56 +1802,6 @@ "narrative": null, "visibility": "player" }, - { - "character_id": "character-0001", - "code": "exploration.item.acquired", - "coins_gp_value": 13, - "event_type": "item_acquired", - "item_ids": [], - "visibility": "player" - }, - { - "character_id": "character-0002", - "code": "exploration.item.acquired", - "coins_gp_value": 13, - "event_type": "item_acquired", - "item_ids": [ - "valuable-0002" - ], - "visibility": "player" - }, - { - "character_id": "character-0003", - "code": "exploration.item.acquired", - "coins_gp_value": 12, - "event_type": "item_acquired", - "item_ids": [], - "visibility": "player" - }, - { - "character_id": "character-0004", - "code": "exploration.item.acquired", - "coins_gp_value": 12, - "event_type": "item_acquired", - "item_ids": [], - "visibility": "player" - }, - { - "chance": 0, - "code": "exploration.wandering.checked", - "encounter": false, - "event_type": "wandering_check", - "roll": null, - "visibility": "referee" - }, - { - "character_id": "character-0002", - "code": "exploration.item.acquired", - "coins_gp_value": 200, - "event_type": "item_acquired", - "item_ids": [], - "visibility": "player" - }, { "code": "exploration.location.entered", "dungeon_id": null, @@ -2326,6 +2383,17 @@ "code": "exploration.item.acquired", "coins_gp_value": 0, "event_type": "item_acquired", + "item_ids": [ + "battle_axe", + "dagger" + ], + "visibility": "player" + }, + { + "character_id": "character-0002", + "code": "exploration.item.acquired", + "coins_gp_value": 0, + "event_type": "item_acquired", "item_ids": [ "shield", "rations_standard", @@ -2356,9 +2424,7 @@ "torch", "torch", "torch", - "chainmail", - "battle_axe", - "dagger" + "chainmail" ], "visibility": "player" }, @@ -3057,105 +3123,50 @@ "code": "session.xp.adventure_award", "event_type": "adventure_xp_award", "monster_xp": 110, - "share": 1092, + "share": 480, "survivors": [ "character-0001", "character-0002", "character-0003", "character-0004" ], - "treasure_xp": 4260, + "treasure_xp": 1810, "visibility": "player" }, { - "award": 1092, + "award": 480, "character_id": "character-0001", "code": "session.xp.awarded", "event_type": "xp_awarded", "level_after": 1, - "modified_award": 1146, + "modified_award": 504, "visibility": "player" }, { - "award": 1092, + "award": 480, "character_id": "character-0002", "code": "session.xp.awarded", "event_type": "xp_awarded", "level_after": 1, - "modified_award": 873, - "visibility": "player" - }, - { - "award": 1092, - "character_id": "character-0003", - "code": "session.xp.awarded", - "event_type": "xp_awarded", - "level_after": 2, - "modified_award": 1201, + "modified_award": 384, "visibility": "player" }, { + "award": 480, "character_id": "character-0003", - "code": "session.level.gained", - "con_applied": true, - "event_type": "leveled_up", - "hp_gained": 4, - "hp_roll": 4, - "level_after": 2, - "level_before": 1, - "title": "Footpad", - "visibility": "player" - }, - { - "award": 1092, - "character_id": "character-0004", "code": "session.xp.awarded", "event_type": "xp_awarded", "level_after": 1, - "modified_award": 1201, + "modified_award": 528, "visibility": "player" }, { - "code": "session.flag.set", - "event_type": "flag_set", - "key": "quest.idol", - "value": "recovered", - "visibility": "referee" - }, - { - "award": 600, - "character_id": "character-0001", - "code": "session.xp.awarded", - "event_type": "xp_awarded", - "level_after": 1, - "modified_award": 630, - "visibility": "player" - }, - { - "award": 600, - "character_id": "character-0002", - "code": "session.xp.awarded", - "event_type": "xp_awarded", - "level_after": 1, - "modified_award": 480, - "visibility": "player" - }, - { - "award": 600, - "character_id": "character-0003", - "code": "session.xp.awarded", - "event_type": "xp_awarded", - "level_after": 2, - "modified_award": 660, - "visibility": "player" - }, - { - "award": 600, + "award": 480, "character_id": "character-0004", "code": "session.xp.awarded", "event_type": "xp_awarded", "level_after": 1, - "modified_award": 660, + "modified_award": 528, "visibility": "player" }, { @@ -3168,16 +3179,6 @@ ], "visibility": "player" }, - { - "character_id": "character-0002", - "code": "town.treasure.sold", - "event_type": "treasure_sold", - "gp_value": 2200, - "instance_ids": [ - "valuable-0002" - ], - "visibility": "player" - }, { "character_id": "character-0001", "code": "town.healing.purchased", @@ -3214,9 +3215,319 @@ "max_hp": 6, "target_id": "character-0001", "visibility": "referee" + }, + { + "character_id": "character-0001", + "code": "exploration.item.given", + "coins_gp_value": 550, + "event_type": "items_given", + "item_ids": [], + "recipient_id": "character-0002", + "visibility": "player" + }, + { + "character_id": "character-0001", + "code": "exploration.item.given", + "coins_gp_value": 550, + "event_type": "items_given", + "item_ids": [], + "recipient_id": "character-0004", + "visibility": "player" + }, + { + "code": "exploration.location.entered", + "dungeon_id": null, + "event_type": "location_entered", + "level_number": 1, + "location_id": "barrow", + "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": "exploration.location.entered", + "dungeon_id": "barrow", + "event_type": "location_entered", + "level_number": 1, + "location_id": "guard_room", + "location_kind": "area", + "narrative": null, + "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": "barrow", + "event_type": "location_entered", + "level_number": 1, + "location_id": "shrine", + "location_kind": "area", + "narrative": null, + "visibility": "player" + }, + { + "character_id": "character-0001", + "code": "exploration.item.acquired", + "coins_gp_value": 13, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "character_id": "character-0002", + "code": "exploration.item.acquired", + "coins_gp_value": 13, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "character_id": "character-0003", + "code": "exploration.item.acquired", + "coins_gp_value": 12, + "event_type": "item_acquired", + "item_ids": [ + "jade-idol" + ], + "visibility": "player" + }, + { + "character_id": "character-0004", + "code": "exploration.item.acquired", + "coins_gp_value": 12, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "code": "session.quest.objective_completed", + "event_type": "objective_completed", + "narrative": "The idol comes up out of the hollow, cold as well-water.", + "objective_id": "recover-idol", + "quest_id": "the-idol", + "visibility": "player" + }, + { + "code": "exploration.party.moved", + "event_type": "party_moved", + "facing": "west", + "visibility": "player", + "x": 3, + "y": 0 + }, + { + "code": "exploration.location.entered", + "dungeon_id": "barrow", + "event_type": "location_entered", + "level_number": 1, + "location_id": "guard_room", + "location_kind": "area", + "narrative": null, + "visibility": "player" + }, + { + "code": "exploration.party.moved", + "event_type": "party_moved", + "facing": "west", + "visibility": "player", + "x": 2, + "y": 0 + }, + { + "code": "exploration.party.moved", + "event_type": "party_moved", + "facing": "west", + "visibility": "player", + "x": 1, + "y": 0 + }, + { + "code": "exploration.party.moved", + "event_type": "party_moved", + "facing": "west", + "visibility": "player", + "x": 0, + "y": 0 + }, + { + "code": "exploration.location.entered", + "dungeon_id": null, + "event_type": "location_entered", + "level_number": null, + "location_id": "town", + "location_kind": "town", + "narrative": null, + "visibility": "player" + }, + { + "code": "session.xp.adventure_award", + "event_type": "adventure_xp_award", + "monster_xp": 0, + "share": 12, + "survivors": [ + "character-0001", + "character-0002", + "character-0003", + "character-0004" + ], + "treasure_xp": 50, + "visibility": "player" + }, + { + "award": 12, + "character_id": "character-0001", + "code": "session.xp.awarded", + "event_type": "xp_awarded", + "level_after": 1, + "modified_award": 12, + "visibility": "player" + }, + { + "award": 12, + "character_id": "character-0002", + "code": "session.xp.awarded", + "event_type": "xp_awarded", + "level_after": 1, + "modified_award": 9, + "visibility": "player" + }, + { + "award": 12, + "character_id": "character-0003", + "code": "session.xp.awarded", + "event_type": "xp_awarded", + "level_after": 1, + "modified_award": 13, + "visibility": "player" + }, + { + "award": 12, + "character_id": "character-0004", + "code": "session.xp.awarded", + "event_type": "xp_awarded", + "level_after": 1, + "modified_award": 13, + "visibility": "player" + }, + { + "code": "session.quest.objective_completed", + "event_type": "objective_completed", + "narrative": "Threshold's gate shuts behind you with the idol inside it.", + "objective_id": "return-home", + "quest_id": "the-idol", + "visibility": "player" + }, + { + "code": "session.quest.completed", + "event_type": "quest_completed", + "name": "The Jade Idol", + "narrative": "The almoner counts out the reward without looking up. The idol is home.", + "quest_id": "the-idol", + "visibility": "player" + }, + { + "code": "session.adventure.completed", + "event_type": "adventure_completed", + "narrative": "The almoner counts out the reward without looking up. The idol is home.", + "quest_id": "the-idol", + "visibility": "player" + }, + { + "character_id": "character-0001", + "code": "exploration.item.acquired", + "coins_gp_value": 200, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "award": 1200, + "character_id": "character-0001", + "code": "session.xp.awarded", + "event_type": "xp_awarded", + "level_after": 1, + "modified_award": 1260, + "visibility": "player" + }, + { + "award": 1200, + "character_id": "character-0002", + "code": "session.xp.awarded", + "event_type": "xp_awarded", + "level_after": 1, + "modified_award": 960, + "visibility": "player" + }, + { + "award": 1200, + "character_id": "character-0003", + "code": "session.xp.awarded", + "event_type": "xp_awarded", + "level_after": 2, + "modified_award": 1320, + "visibility": "player" + }, + { + "character_id": "character-0003", + "code": "session.level.gained", + "con_applied": true, + "event_type": "leveled_up", + "hp_gained": 4, + "hp_roll": 4, + "level_after": 2, + "level_before": 1, + "title": "Footpad", + "visibility": "player" + }, + { + "award": 1200, + "character_id": "character-0004", + "code": "session.xp.awarded", + "event_type": "xp_awarded", + "level_after": 1, + "modified_award": 1320, + "visibility": "player" + }, + { + "code": "session.flag.set", + "event_type": "flag_set", + "key": "quest.idol", + "value": "recovered", + "visibility": "referee" } ], - "final_clock_rounds": 660, + "final_clock_rounds": 900, "final_stream_states": { "advancement": { "inc": 43677341557666270100197892308947072293, @@ -3266,13 +3577,29 @@ "flags": { "quest.idol": "recovered" }, - "listener_state": { - "fetch_quest": { - "completed": true, - "reward_granted": true + "journal": [ + { + "rounds": 120, + "text": "The temple wants the Jade Idol off the barrow king's altar and back on its own." + }, + { + "rounds": 780, + "text": "The idol comes up out of the hollow, cold as well-water." + }, + { + "rounds": 900, + "text": "Threshold's gate shuts behind you with the idol inside it." + }, + { + "rounds": 900, + "text": "The almoner counts out the reward without looking up. The idol is home." } + ], + "listener_state": { + "osrlib.interpreter": {} }, "master_seed": 21, + "mode": "victory", "party_document": { "engine_version": "1.4.0", "kind": "party", @@ -3625,8 +3952,24 @@ "xp": 1861 } ], + "quests": { + "the-idol": { + "objectives": { + "recover-idol": { + "complete": true, + "revealed": true + }, + "return-home": { + "complete": true, + "revealed": true + } + }, + "status": "completed" + } + }, "transcript": [ "The party enters dungeon barrow (level 1).", + "A new quest: The Jade Idol. The temple wants the Jade Idol off the barrow king's altar and back on its own.", "The party moves to (1, 0), facing east.", "The party moves to (2, 0), facing east.", "The party enters area guard_room (level 1).", @@ -3688,12 +4031,6 @@ "The party moves to (3, 0), facing east.", "The party moves to (4, 0), facing east.", "The party enters area shrine (level 1).", - "character-0001 acquires 13 gp in coin.", - "character-0002 acquires valuable-0002 and 13 gp in coin.", - "character-0003 acquires 12 gp in coin.", - "character-0004 acquires 12 gp in coin.", - "Wandering check: skipped vs 0-in-6 — nothing comes.", - "character-0002 acquires 200 gp in coin.", "The party enters level barrow (level 2).", "The party moves to (1, 0), facing east.", "The party moves to (2, 0), facing east.", @@ -3746,7 +4083,8 @@ "npc-0001 (npc:dwarf) is slain.", "npc-0002 (npc:magic_user) is slain.", "The encounter ends (victory).", - "character-0001 acquires shield, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, waterskin, waterskin, torch, torch, torch, torch, torch, torch, torch, torch, torch, torch, torch, torch, chainmail, battle_axe, dagger.", + "character-0001 acquires battle_axe, dagger.", + "character-0002 acquires shield, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, rations_standard, waterskin, waterskin, torch, torch, torch, torch, torch, torch, torch, torch, torch, torch, torch, torch, chainmail.", "Wandering check: 2 vs 6-in-6 — an encounter!", "An NPC party (basic, lawful) takes the field: npc-0003 (halfling 3), npc-0004 (fighter 3).", "Surprise (monsters): rolled 6, surprised on 1-2 — not surprised.", @@ -3808,22 +4146,50 @@ "The party moves to (1, 0), facing west.", "The party moves to (0, 0), facing west.", "The party enters town town.", - "The adventure ends: 110 XP from monsters and 4260 XP from treasure — 1092 XP to each of 4 survivor(s).", - "character-0001 gains 1146 XP (base 1092), now level 1.", - "character-0002 gains 873 XP (base 1092), now level 1.", - "character-0003 gains 1201 XP (base 1092), now level 2.", - "character-0003 advances to level 2 (Footpad): +4 hp (rolled 4).", - "character-0004 gains 1201 XP (base 1092), now level 1.", - "Flag quest.idol = 'recovered'.", - "character-0001 gains 630 XP (base 600), now level 1.", - "character-0002 gains 480 XP (base 600), now level 1.", - "character-0003 gains 660 XP (base 600), now level 2.", - "character-0004 gains 660 XP (base 600), now level 1.", + "The adventure ends: 110 XP from monsters and 1810 XP from treasure — 480 XP to each of 4 survivor(s).", + "character-0001 gains 504 XP (base 480), now level 1.", + "character-0002 gains 384 XP (base 480), now level 1.", + "character-0003 gains 528 XP (base 480), now level 1.", + "character-0004 gains 528 XP (base 480), now level 1.", "character-0001 sells 1 valuable(s) for 1600 gp.", - "character-0002 sells 1 valuable(s) for 2200 gp.", "character-0001 purchases cure_light_wounds at the temple for 25 gp.", "town-cleric casts cure_light_wounds [heal] at character-0001.", "character-0001 regains 2 hit points (magical).", - "character-0001 is at 6/6 hit points." + "character-0001 is at 6/6 hit points.", + "character-0001 gives 550 gp in coin to character-0002.", + "character-0001 gives 550 gp in coin to character-0004.", + "The party enters dungeon barrow (level 1).", + "The party moves to (1, 0), facing east.", + "The party moves to (2, 0), facing east.", + "The party enters area guard_room (level 1).", + "The party moves to (3, 0), facing east.", + "The party moves to (4, 0), facing east.", + "The party enters area shrine (level 1).", + "character-0001 acquires 13 gp in coin.", + "character-0002 acquires 13 gp in coin.", + "character-0003 acquires jade-idol and 12 gp in coin.", + "character-0004 acquires 12 gp in coin.", + "Quest the-idol: objective recover-idol is done. The idol comes up out of the hollow, cold as well-water.", + "The party moves to (3, 0), facing west.", + "The party enters area guard_room (level 1).", + "The party moves to (2, 0), facing west.", + "The party moves to (1, 0), facing west.", + "The party moves to (0, 0), facing west.", + "The party enters town town.", + "The adventure ends: 0 XP from monsters and 50 XP from treasure — 12 XP to each of 4 survivor(s).", + "character-0001 gains 12 XP (base 12), now level 1.", + "character-0002 gains 9 XP (base 12), now level 1.", + "character-0003 gains 13 XP (base 12), now level 1.", + "character-0004 gains 13 XP (base 12), now level 1.", + "Quest the-idol: objective return-home is done. Threshold's gate shuts behind you with the idol inside it.", + "Quest complete: The Jade Idol. The almoner counts out the reward without looking up. The idol is home.", + "The adventure is over: the-idol is finished. The almoner counts out the reward without looking up. The idol is home.", + "character-0001 acquires 200 gp in coin.", + "character-0001 gains 1260 XP (base 1200), now level 1.", + "character-0002 gains 960 XP (base 1200), now level 1.", + "character-0003 gains 1320 XP (base 1200), now level 2.", + "character-0003 advances to level 2 (Footpad): +4 hp (rolled 4).", + "character-0004 gains 1320 XP (base 1200), now level 1.", + "Flag quest.idol = 'recovered'." ] } diff --git a/tests/test_phase5_goldens.py b/tests/test_phase5_goldens.py index e923835..db8b700 100644 --- a/tests/test_phase5_goldens.py +++ b/tests/test_phase5_goldens.py @@ -9,13 +9,13 @@ - the milestone golden — the example adventure's scripted playthrough (the same seed and script `test_example_crawler.py` drives through the actual binary) resolved through `GameSession.execute`: creation, the delve with its generated - lair hoard, the rival NPC party fought and looted, the MacGuffin and the quest - listener, the return, the award, the level-up. The full event stream and + lair hoard, the rival NPC party fought and looted, the first return with its + award and town business, the second trip for the MacGuffin, and the homecoming + that completes the authored quest into victory. The full event stream and formatted transcript assert byte-for-byte, final stream states scope per RNG stream, and the checkpoints satisfy `load(save) == state == replay(seed, - commands)` — the replay listener-free, since the listener's commands are in - the log; its private state is the game's, not the kernel's, and is asserted - separately. + commands)` — the replay listener-free, since the commands the interpreter issued + are in the log; its listener slot is empty and asserted separately. """ import json @@ -114,15 +114,30 @@ def test_command_log_and_checkpoints_match(self, golden, scripted): assert canonical(logged) == canonical(golden["command_log"]), REGENERATE_HINT assert checkpoints == golden["checkpoints"], REGENERATE_HINT - def test_listener_state_and_flags_match(self, golden, scripted): + def test_the_interpreter_holds_nothing_and_the_flag_reward_landed(self, golden, scripted): session, _, _ = scripted - assert ( - session.listener_state - == golden["listener_state"] - == {"fetch_quest": {"reward_granted": True, "completed": True}} - ) + assert session.listener_state == golden["listener_state"] == {"osrlib.interpreter": {}} assert session.flags == golden["flags"] - assert session.flags["quest.idol"] == "recovered" + assert session.flags["quest.idol"] == "recovered", "the quest's own flag reward, not the example's code" + + def test_the_authored_quest_finished_and_closed_the_adventure(self, golden, scripted): + session, _, _ = scripted + assert golden["quests"] == { + "the-idol": { + "status": "completed", + "objectives": { + "recover-idol": {"revealed": True, "complete": True}, + "return-home": {"revealed": True, "complete": True}, + }, + } + } + assert {quest_id: state.model_dump(mode="json") for quest_id, state in session.quests.items()} == ( + golden["quests"] + ) + assert session.mode.value == golden["mode"] == "victory" + assert [entry["text"] for entry in golden["journal"]] == [entry.text for entry in session.journal] + assert golden["journal"][0]["text"].startswith("The temple wants the Jade Idol") + assert golden["journal"][-1]["text"].startswith("The almoner counts out the reward") def test_save_load_round_trips_the_listener_run(self, scripted): session, _, _ = scripted @@ -148,7 +163,7 @@ def test_final_stream_states_scoped_per_stream(self, golden, replayed): def test_final_clock_summary_and_records(self, golden, replayed): assert replayed.clock.rounds == golden["final_clock_rounds"] - assert replayed.mode.value == "town" + assert replayed.mode.value == "victory", "the concluding quest ends the adventure on the replay too" defeated = [record.model_dump(mode="json") for record in replayed.defeated_monsters] assert defeated == golden["defeated_monsters"] summary = [ @@ -162,9 +177,9 @@ def test_replay_agrees_with_the_scripted_run_except_listener_state(self, scripte session, _, _ = scripted original = session_state(session) replay = session_state(replayed) - assert original.pop("listener_state") == {"fetch_quest": {"reward_granted": True, "completed": True}} + assert original.pop("listener_state") == {"osrlib.interpreter": {}} assert replay.pop("listener_state") == {} - assert original == replay + assert original == replay, "quest state included: a replay rebuilds it from the log" def test_the_milestone_beats_are_in_the_stream(self, golden): codes = {event.get("code") for event in golden["event_log"]} @@ -174,32 +189,57 @@ def test_the_milestone_beats_are_in_the_stream(self, golden): "encounter.npc_party.spawned", # the rival adventurers (referee-visibility roster) "battle.ended.victory", "battle.monster.defeated", - "session.flag.set", # the quest listener's reaction + "session.flag.set", # the quest's flag reward "session.xp.adventure_award", # the end-of-adventure valuation delta "session.xp.awarded", "town.treasure.sold", "town.healing.purchased", + "session.quest.activated", # the threshold crossing, matched as authored data + "session.quest.objective_completed", + "session.quest.completed", + "session.adventure.completed", # the one entrance to victory ): assert required in codes, f"missing milestone beat {required}" def test_npc_defeats_fed_the_award_as_level_for_hd(self, golden): # The award clears the defeat ledger on return, so the beat lives in the # event stream: NPC adventurers fell under npc: template ids, and their - # level-as-HD XP is inside the award's monster total. + # level-as-HD XP is inside the first award's monster total. defeats = [event for event in golden["event_log"] if event.get("code") == "battle.monster.defeated"] npc_defeats = [event for event in defeats if event["template_id"].startswith("npc:")] assert npc_defeats, "no NPC adventurers fell in the milestone" - award = next(event for event in golden["event_log"] if event.get("code") == "session.xp.adventure_award") - assert award["monster_xp"] == sum(event["xp"] for event in defeats) + awards = [event for event in golden["event_log"] if event.get("code") == "session.xp.adventure_award"] + assert len(awards) == 2, "one award per trip" + assert awards[0]["monster_xp"] == sum(event["xp"] for event in defeats) + assert awards[1]["monster_xp"] == 0, "nothing died on the errand" assert golden["defeated_monsters"] == [] # the ledger reset with the award + def test_the_reward_is_paid_on_delivery_and_earns_no_treasure_xp(self, golden): + # The economy the two-trip script pins: the idol is mundane gear (worth no + # treasure XP by RAW), and the coin reward lands in town after the second + # award has already fired — so the quest's own XP award is what carries the + # temple's thanks. + events = golden["event_log"] + completed = next(index for index, event in enumerate(events) if event.get("code") == "session.quest.completed") + award_indices = [ + index for index, event in enumerate(events) if event.get("code") == "session.xp.adventure_award" + ] + assert all(index < completed for index in award_indices), "both awards precede the completion" + reward = next( + event + for event in events[completed:] + if event.get("code") == "exploration.item.acquired" and event.get("coins_gp_value") == 200 + ) + assert reward["character_id"] == "character-0001", "@first is the lead survivor" + assert events[-1]["code"] != "session.xp.adventure_award", "no third trip, no third award" + def test_the_command_log_round_trips(self, golden): for entry in golden["command_log"]: assert parse_command(entry) is not None class TestCheckpoints: - @pytest.mark.parametrize("name", ("mid_delve", "post_award")) + @pytest.mark.parametrize("name", ("mid_delve", "first_return", "post_award")) def test_load_equals_replay_and_continues_identically(self, golden, replayed, name): index = golden["checkpoints"][name] prefix = replay_milestone(golden["master_seed"], golden["command_log"][:index]) From d1256be8406681dc15e3d025f1c5d79fbb43f6c4 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 6 Aug 2026 23:40:12 -0700 Subject: [PATCH 7/9] The spec says what the code does: amendments, the carrier table, and the guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work item 11's remainder. Four sentence-level spec amendments — the journal's display-text rule, the from-start activation consequence, the validation clause rewritten to the executed seam, and the player view's speaker attribution. narrative.py's carrier table rewritten to the shipped mapping: quest blocks speak offer and completion, objective blocks offer and progress, the quest layer journals the display text it showed and leaves the journal field to carriers whose display beat the players never see. The authoring guide gains the quest section and the guidance slot, the commands guide the four lifecycle commands and their closed id domain, the views guide PlayerView.quests and the journal- growth event surface, and the changelog the phase's three entries. Claude-Session: https://claude.ai/code/session_01NQ83sqNnXVeXEd55NK1xR7 --- CHANGELOG.md | 5 + docs/front-ends/tui-crawler.md | 47 ++++++-- docs/getting-started/building-an-adventure.md | 114 ++++++++++++++++-- docs/guides/sessions-commands-events.md | 51 ++++++-- docs/guides/views-and-visibility.md | 32 ++++- docs/spec.md | 8 +- src/osrlib/crawl/narrative.py | 35 ++++-- 7 files changed, 251 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e1ddb7..f0f92cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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. diff --git a/docs/front-ends/tui-crawler.md b/docs/front-ends/tui-crawler.md index 9430830..629c9fd 100644 --- a/docs/front-ends/tui-crawler.md +++ b/docs/front-ends/tui-crawler.md @@ -38,22 +38,26 @@ the transcript too: Every event carries a [`Visibility`][osrlib.core.events.Visibility]; filtering on `Visibility.PLAYER` here is what keeps referee-only bookkeeping out of the player's -terminal. Running the milestone transcript (`--seed 203 --script +terminal. Running the milestone transcript (`--seed 21 --script examples/tui_crawler/scripts/milestone.txt`) opens like this: ```text > enter The party enters dungeon barrow (level 1). + A new quest: The Jade Idol. The temple wants the Jade Idol off the barrow king's altar and back on its own. > move e The party moves to (1, 0), facing east. > move e The party moves to (2, 0), facing east. The party enters area guard_room (level 1). - Encounter: 2 × Goblin at 50' — the party is surprised. + Encounter: 2 × Goblin at 20' — the party is surprised. The monsters' bearing: uncertain. - The monsters' bearing: hostile. ``` +The second line is already the delta loop earning its keep: crossing the threshold +activated the adventure's quest, and what printed it was a command the interpreter +issued *inside* the player's `enter`. + Every printed line is [`format_message`][osrlib.messages.format_message] rendering a typed event — a different front end could format the same events into JSON, a chat message, or nothing at all (see [the message code reference](../reference/message-codes.md)). @@ -77,11 +81,12 @@ covers what a `PlayerView` includes and how it differs from the referee's. ## The authored adventure -`content.py` builds the game's whole world: a town and a two-level barrow, assembled -from the same authoring models [Building an adventure](../getting-started/building-an-adventure.md) -walks through. A keyed area binds descriptive text, an encounter, and a feature to a -set of cells — here, the shrine room holding the quest's MacGuffin, a named valuable -tucked inside a treasure cache: +`content.py` builds the game's whole world: a town, a two-level barrow, and the errand +that ends it, assembled from the same authoring models +[Building an adventure](../getting-started/building-an-adventure.md) walks through. A +keyed area binds descriptive text, an encounter, and a feature to a set of cells — +here, the shrine room whose cache holds the quest's MacGuffin, named by id so that +taking it is something the quest can match on: ```{.python .no-run} --8<-- "examples/tui_crawler/content.py:idol-shrine-area" @@ -171,11 +176,29 @@ they land, rendered from typed events by the same formatter as everything else: character-0001 acquires 200 gp in coin. ``` +### Why the milestone makes two trips + The homecoming objective is a `town_entered` pattern narrowed by a `has_item` -condition, so walking back empty-handed is not a return — which is exactly why the -milestone script makes two trips, doing its selling and healing on the first one. -The second return ends the adventure in `victory`, and the temple pays afterwards: -a concluded session takes referee commands but no play. +condition, so walking back empty-handed is not a return — the objective simply does +not fire. That one clause is what gives `scripts/milestone.txt` its shape: + +1. **Down**, for the goblins, their lair hoard, and the rival party prowling level 2. +2. **Home without the idol.** The return banks the end-of-adventure award, and the + party sells its haul and buys a temple healing — town commands that are legal here + and nowhere later, because the adventure has not ended yet. Coin weighs a coin + apiece, so the seller spreads the purse with `give` before anybody walks again. +3. **Down again**, for the idol alone. +4. **Home with it**, which completes the second objective, completes the quest, and + — the quest carrying `concludes_adventure=True` — ends the session in `victory`. + The rewards land *after* that transition: the 200 gp, the party's XP, and the + `quest.idol` flag the crawler prints on its way out. + +A concluded session still takes referee commands and refuses play, so the closing +`status` reads `[victory]` and any further `move` would be `wrong_mode`. Two beats of +authoring discipline fall out of that ordering and are worth copying: put the town +business before the concluding return, and put the story's thanks in `AwardXP` rather +than in coin, because the last award has already fired by the time the temple pays. + [Listeners and flags](../guides/listeners-and-flags.md) covers the listener contract the interpreter follows, and [Building an adventure](../getting-started/building-an-adventure.md) covers authoring quests of your own. diff --git a/docs/getting-started/building-an-adventure.md b/docs/getting-started/building-an-adventure.md index 3f7143f..144b983 100644 --- a/docs/getting-started/building-an-adventure.md +++ b/docs/getting-started/building-an-adventure.md @@ -140,6 +140,71 @@ Nothing about a trigger firing is all-or-nothing. A consequence the session reje 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. +## Authoring a quest + +A trigger fires and is done with you. A quest keeps score: it has a state the engine owns, objectives that complete in any order, and an ending. A [`QuestSpec`][osrlib.crawl.quests.QuestSpec] is authored beside the triggers, in the same adventure document, and played by the same [`Interpreter`][osrlib.crawl.interpreter.Interpreter] — you register nothing extra. + +Nothing in the vocabulary is new. Everywhere a quest asks "did this happen?", it asks with a [`TriggerClause`][osrlib.crawl.quests.TriggerClause]: one of the patterns above, plus the conditions that must hold when it matches. The field is `pattern` rather than `when`, so an objective's completion clause reads `objective.when.pattern`. + +Here is a whole quest — the TUI crawler's fetch errand, verbatim from the example: + +```{.python .no-run} +--8<-- "examples/tui_crawler/content.py:fetch-quest" +``` + +### Matching on a thing the party carries + +The idol that quest wants is a bundled item, not a named valuable, and that is deliberate: an acquisition reports mundane items by catalog id, so a bundled id is something [`ItemAcquiredPattern`][osrlib.crawl.triggers.ItemAcquiredPattern] can match and [`HasItemCondition`][osrlib.crawl.gates.HasItemCondition] can test. + +```{.python .no-run} +--8<-- "examples/tui_crawler/content.py:bundled-idol" +``` + +Drop it into a cache by id (`item_ids=("jade-idol",)`) and hand it to `Adventure.items`, and the whole errand becomes matchable: *took it* is a pattern, *still carrying it* is a condition. A `town_entered` clause narrowed by `has_item` is the walked-home-with-it test, and walking home without it simply does not fire. + +### Activation, and the quest that needs none + +`activation` is a clause like any other: when it matches, the quest becomes active, its `offer` beat displays and lands in the journal, and its objectives start watching. Omit it and the quest is active from session start — a standing charge the party carries from round 0. That one has no activation event and no offer entry in the journal, because there is no command channel before the first command; its offer simply stands in the first player view. + +### Hidden objectives and reveals + +`objectives` holds at least one, in the order you write them, and each is an [`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec] with a completion clause of its own. `hidden=True` keeps an objective out of the player view until something surfaces it — either a `reveal_when` clause of its own, or its own completion, because finishing an objective reveals it. A hidden objective with no reveal clause is a normal shape: the party learns about it by doing it. A `reveal_when` on an objective that was never hidden is rejected at parse, being wiring nothing would read. + +### The completion rule and the ending + +`completion` is `"all"` (the default — every objective) or `"any"` (the first one to land, leaving the rest incomplete). The rule is checked the moment an objective completes, and a satisfied rule completes the quest. + +`concludes_adventure=True` marks the quest whose completion ends the adventure: the session clears any open encounter or battle and enters `victory`, a terminal mode where play commands are refused and referee commands still work. That is the one entrance to victory, so author it once, on the quest that is the point of the module. + +### Rewards + +`rewards` are the same [`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand] surface a trigger's consequences use, issued in authored order *after* the quest completes, each stamped `source="quest:{id}"`. They address characters through the same selectors — `@party` and `@first` — and validation rejects a literal character id for the same reason it does on a trigger. + +Two consequences of the ordering are worth authoring around. On a concluding quest the session is already in `victory` when the rewards issue, so a reward that would resume play — `SpawnMonsters`, `SpawnNpcParty`, `PlaceParty` — is refused and dropped with a note; grants, awards, and flags land fine. And coin paid on the doorstep earns no treasure XP: the end-of-adventure award has already fired by then, so put the story's thanks in `AwardXP` rather than expecting a purse to convert itself. + +### Which beat goes where + +Quests read four display beats from their narrative blocks, and the mapping is worth keeping straight: + +| Moment | Block | Field | +|---|---|---| +| The quest activates | quest | `offer` | +| A hidden objective is revealed | objective | `offer` | +| An objective completes | objective | `progress` | +| The quest completes | quest | `completion` | + +Each of those beats rides its own player-visible event *and* appends to the journal, as itself — a quest's journal is the transcript of what the table was shown, so quest blocks leave the `journal` field to the carriers whose display beat the players never see (a trigger's `fired`). A quest block's `progress` and an objective block's `completion` are read by nobody; they are silently unread, not rejected. + +### Steering a narrator + +`guidance` on any narrative block is text a narrating front end may steer by and no renderer ever prints. Levels get one of their own for the ambience that hangs on no object at all: + +```{.python .no-run} +--8<-- "examples/tui_crawler/content.py:level-guidance" +``` + +[`LevelSpec.guidance`][osrlib.crawl.dungeon.LevelSpec] is inert authored data: the engine reads it nowhere, no event carries it, and it applies while the party is on the level. Like every other level internal it is referee-side by construction — the player view ships no part of it. + ## 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: @@ -153,10 +218,11 @@ adventure = Adventure( dungeons=(barrow,), items=(GearTemplate(id="brass_key", name="Brass key", cost_gp=0),), triggers=(sentinel_wakes,), + quests=(recover_the_key,), ) ``` -`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. +`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 `quests` its errands, and both tuples are document order: when two triggers match the same event they fire in the order you wrote them, and the interpreter walks the triggers of an event before its quests. ## Validate before play @@ -194,8 +260,9 @@ 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.quests import ObjectiveSpec, QuestSpec, TriggerClause from osrlib.crawl.session import GameSession -from osrlib.crawl.triggers import ItemAcquiredPattern, TriggerSpec +from osrlib.crawl.triggers import DungeonEnteredPattern, ItemAcquiredPattern, TriggerSpec from osrlib.data import load_equipment, load_monsters sentinel = GateSpec( @@ -238,6 +305,24 @@ sentinel_wakes = TriggerSpec( ), ) +recover_the_key = QuestSpec( + id="the-key", + name="The Brass Key", + activation=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="barrow")), + objectives=( + ObjectiveSpec( + id="find-the-key", + when=TriggerClause(pattern=ItemAcquiredPattern(item_id="brass_key")), + narrative=NarrativeBlock(progress="The key came out of the spoil heap, green with age."), + ), + ), + rewards=(SetFlag(key="barrow.errand", value="done"),), + narrative=NarrativeBlock( + offer="Bring the brass key back up, and the sentinel's door is somebody else's problem.", + completion="The key is out of the barrow. The errand is closed.", + ), +) + barrow = DungeonSpec(id="barrow", name="The Barrow", levels=(level,)) town = TownSpec(name="Threshold", travel_turns={"barrow": 2}) adventure = Adventure( @@ -246,6 +331,7 @@ adventure = Adventure( dungeons=(barrow,), items=(GearTemplate(id="brass_key", name="Brass key", cost_gp=0),), triggers=(sentinel_wakes,), + quests=(recover_the_key,), ) # Validation catches unknown ids and broken geometry before play ever starts. @@ -258,6 +344,10 @@ session = GameSession.new(Party(members=[hero.character]), adventure, seed=11) session.register_listener(Interpreter(session)) session.execute(EnterDungeon(dungeon_id="barrow")) +# Crossing the threshold activated the quest, and its offer opened the journal. +assert session.quests["the-key"].status == "active" +assert session.journal[0].text.startswith("Bring the brass key back up") + session.execute(MoveParty(direction=Direction.EAST)) session.execute(MoveParty(direction=Direction.EAST)) @@ -267,20 +357,30 @@ assert not refused.accepted assert refused.rejections[0].code == "exploration.door.gate_refused" assert refused.rejections[0].params["refusal"].startswith("The bronze sentinel") -# 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. +# The key lands, and everything watching for it reacts inside the same command: +# the trigger first, then the quest, then the quest's reward. 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", + "session.quest.objective_completed", + "session.quest.completed", + "session.flag.set", ] 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" +assert session.journal[1].text == "The brass key is ours. Something in the barrow noticed." +# One objective, the `all` rule: finishing it finished the quest, and the reward +# landed after the completion. +assert session.quests["the-key"].status == "completed" +assert session.flags["barrow.errand"] == "done" +# Every command a trigger or a quest issued says whose idea it was. +assert {command.source for command in session.command_log if command.source} == { + "trigger:sentinel-wakes", + "quest:the-key", +} opened = session.execute(OpenDoor(direction=Direction.EAST)) assert opened.accepted diff --git a/docs/guides/sessions-commands-events.md b/docs/guides/sessions-commands-events.md index 9a51a2a..3bad671 100644 --- a/docs/guides/sessions-commands-events.md +++ b/docs/guides/sessions-commands-events.md @@ -57,10 +57,48 @@ 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 +it issues this way — `trigger:{id}` for a trigger's firing, `quest:{id}` for everything +a quest causes — 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. +`trigger:portcullis-rises`, the coins came from `quest:the-idol`, and the `record_note` +beside them says which consequence was dropped and why. + +## The lifecycle commands + +Seven referee commands exist for the authored layer to keep its own books with. Three +of them are the trigger and journal vocabulary — +[`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], +[`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry], and +[`RecordNote`][osrlib.crawl.commands.RecordNote]. The other four advance quest state: + +- [`ActivateQuest`][osrlib.crawl.commands.ActivateQuest] puts a quest in play. +- [`RevealObjective`][osrlib.crawl.commands.RevealObjective] surfaces a hidden objective. +- [`CompleteObjective`][osrlib.crawl.commands.CompleteObjective] marks one done — and + reveals it on the way, since an objective the party finished is one it can be told about. +- [`CompleteQuest`][osrlib.crawl.commands.CompleteQuest] finishes the quest, and on a + quest marked as concluding the adventure, ends the session in `victory`. + +They are ordinary commands: legal in every mode, logged, replayed, and stamped like any +other. What they write — per-quest status and per-objective flags in `session.quests` — +is engine-owned session state, so a replay with no listeners registered rebuilds it by +re-executing the log. + +Their ids are a **closed domain**, and this is the one place the lifecycle family is not +uniform. `MarkTriggerFired.trigger_id` is open: a mark records that something fired, needs +no authored trigger behind it, and a game drives it with ids from its own systems. The +four quest commands invert that — they resolve `quest_id` and `objective_id` against the +adventure's own [`QuestSpec`][osrlib.crawl.quests.QuestSpec]s and reject an id no spec +holds (`session.command.unknown_quest`, `session.command.unknown_objective`), because the +state they advance is projected into the player view, and an id with no spec behind it has +no name, no offer, and no objective list to show. A command that contradicts the state it +finds — activating a quest already active, completing an objective already complete — +rejects with `session.command.quest_state` naming the quest and the state that refused it. + +One asymmetry is deliberate: `CompleteQuest` requires the quest to be active and does +*not* check its completion rule. Ruling a quest done is the referee's call; the +interpreter is simply a disciplined issuer that checks the rule before it issues. For the +same reason, rewards are not the command's doing — whoever completes a quest issues its +rewards afterwards, which is why a hand-driven completion grants nothing. ## Session modes and mode gating @@ -83,11 +121,8 @@ Commands that make sense both at rest and on the move (`ReorderParty`, `LightSou `ResolveBattleRound` requires `battle`. A handful, like `DropItems`, span two modes on purpose — dropping treasure to distract pursuers works whether the party is still exploring or already in an encounter. Referee commands (`GrantItem`, `SetFlag`, -`AwardXP`, `AdvanceTime`, the lifecycle trio -[`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], -[`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry] and -[`RecordNote`][osrlib.crawl.commands.RecordNote], and the rest of the -session-owned surface) are legal in +`AwardXP`, `AdvanceTime`, the seven [lifecycle commands](#the-lifecycle-commands), and +the rest of the session-owned surface) are legal in every mode, the two terminal ones included — a referee correcting the world doesn't stop just because the party fell, and an adventure's rewards can land after it ends. Three of them are the exception, each because it would resume play diff --git a/docs/guides/views-and-visibility.md b/docs/guides/views-and-visibility.md index 04be67e..705bc41 100644 --- a/docs/guides/views-and-visibility.md +++ b/docs/guides/views-and-visibility.md @@ -66,7 +66,9 @@ remaining duration (except a potion's — RAW has the referee track that secretl the view reports it as unknown); fatigue, exhaustion, and deprivation status; the session journal as written ([`JournalEntry`][osrlib.crawl.session.JournalEntry] — the beats in order of discovery, each carrying the clock position it landed at, while the -trigger fired-marks behind them stay out of the view entirely); and, when +trigger fired-marks behind them stay out of the view entirely); the quests in play +([`QuestView`][osrlib.crawl.views.QuestView] — id, name, the offer beat and its speaker +attribution, and the revealed objectives with their ids and states); and, when one is running, the current encounter or battle's public shape ([`EncounterView`][osrlib.crawl.views.EncounterView] and [`EncounterGroupView`][osrlib.crawl.views.EncounterGroupView] — a monster group's id, @@ -110,6 +112,34 @@ assert "lever-east" not in journal_view.model_dump_json() assert referee_state["fired_triggers"] == ["lever-east"] ``` +Quests draw the same line, one level finer. `PlayerView.quests` carries the **active** +quests only, in document order: a quest nobody has been given yet is absent, because an +activation clause is wiring like any other, and a finished one leaves the list, because +its record is the journal. Under each, only the **revealed** objectives appear — a hidden +objective's id is not in the projection at all until something surfaces it, which is why +`ObjectiveView.state` needs only `"incomplete"` and `"complete"`. Nothing else about a +quest crosses: no clause, no pattern, no condition, no reward, and no `guidance` from any +narrative block or level. + +```{.python .no-run} +# Active quests only, revealed objectives only, and none of the wiring behind them. +quest = player_view.quests[0] +assert (quest.id, quest.speaker) == ("the-idol", "the temple almoner") +assert [objective.id for objective in quest.objectives] == ["recover-idol"] +assert "reveal_when" not in player_view.model_dump_json() +``` + +### What tells a client the journal grew + +[`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] is not the only +event a growing journal emits. A quest beat's entry *is* the line the quest displayed, so +it reports itself through its own lifecycle event and no journal event follows — emitting +both would show the table one line twice. A client that renders incrementally therefore +watches five codes rather than one: `session.journal.entry_added`, +`session.quest.activated`, `session.quest.objective_revealed`, +`session.quest.objective_completed`, and `session.quest.completed`. A client that would +rather not track any of them reads `PlayerView.journal`, which is always the whole record. + ## Never trust the client The moment a game goes over a network, this split becomes a security boundary, not diff --git a/docs/spec.md b/docs/spec.md index 1a3d760..7a9cfb9 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -121,7 +121,7 @@ for event in result.events: view = session.view(Visibility.PLAYER) ``` -The player view is a safe projection: party status, explored map cells, known active effects, active quests (id, name, display narrative, and visible objectives with their ids and states), and the journal. It never contains unexplored geometry, trap locations, monster HP, referee-only roll outcomes, session flags, hidden objectives, gate or trigger wiring, or the seed. `Visibility.REFEREE` returns everything, for LLM referees, debugging, and tests. +The player view is a safe projection: party status, explored map cells, known active effects, active quests (id, name, display narrative with the block's speaker attribution beside it, since a wire client holds no adventure document to resolve one from, and visible objectives with their ids and states), and the journal. It never contains unexplored geometry, trap locations, monster HP, referee-only roll outcomes, session flags, hidden objectives, gate or trigger wiring, or the seed. `Visibility.REFEREE` returns everything, for LLM referees, debugging, and tests. Full game state, referee-visibility events, and the master seed are server-side secrets: a backend forwards views and player-visible events to clients, never raw state. @@ -201,9 +201,9 @@ An adventure can be won, and its content can be wired — the lever that opens t **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. -**Quests.** A quest spec composes the primitives: id, name, and narrative; an activation trigger (a quest with none is active from session start; there is no accept/decline — a B/X module frames the objective, it does not negotiate, and the offer beat displays at activation); objectives, each an authored trigger plus narrative, optionally hidden until a reveal trigger fires or the objective completes; rewards as referee commands issued immediately on completion, in authored order, before any subsequent player command; a completion rule of all objectives or any; and an optional marker that completing this quest concludes the adventure. +**Quests.** A quest spec composes the primitives: id, name, and narrative; an activation trigger (a quest with none is active from session start — it stands active in the first player view, with no activation event and no offer journal beat behind it, because there is no command channel before the first command; there is no accept/decline — a B/X module frames the objective, it does not negotiate, and the offer beat displays at activation); objectives, each an authored trigger plus narrative, optionally hidden until a reveal trigger fires or the objective completes; rewards as referee commands issued immediately on completion, in authored order, before any subsequent player command; a completion rule of all objectives or any; and an optional marker that completing this quest concludes the adventure. -**The journal.** An appended, event-sourced list in session state, never derived on demand: appending preserves order of discovery, keeps beats whose source state has since changed, gives quest-less triggers a journal voice — and derivation is foreclosed anyway, because consumers cannot evaluate quest state. Entries append when beats land: quest activation, objective reveal and completion, quest completion, and any trigger whose narrative carries a journal form. The journal persists in saves and ships verbatim in the player view. +**The journal.** An appended, event-sourced list in session state, never derived on demand: appending preserves order of discovery, keeps beats whose source state has since changed, gives quest-less triggers a journal voice — and derivation is foreclosed anyway, because consumers cannot evaluate quest state. Entries append when beats land: quest activation, objective reveal and completion, quest completion, and any trigger whose narrative carries a journal form. A quest beat's entry *is* the display text it showed, appended verbatim — the journal is the transcript of what the table was told — while the separately authored journal form is the voice of carriers whose display beat the players never see, a trigger's referee-visibility fired text above all. The journal persists in saves and ships verbatim in the player view. **Narrative blocks.** Every mechanical object — quest, objective, gate, trigger — carries an optional narrative block with three audiences: display beats a deterministic renderer shows verbatim (offer, progress, and completion for quests; refusal and success for gates; fired text for triggers); a journal form; and LLM guidance that is never displayed verbatim. Guidance lives on the narrative block uniformly, plus one ambient guidance slot per dungeon level for steering that attaches to no mechanical object; it applies while its carrier is in play — quest active, gate encountered, trigger fired, level occupied — and is trusted as content, the same posture as description prose, which already flows into narration. A `speaker` attribution stays free prose; a typed NPC reference arrives additively if NPC entities ever land, and prose is never retrofitted into a reference. @@ -211,7 +211,7 @@ An adventure can be won, and its content can be wired — the lever that opens t **Victory and session end.** Completing a quest marked as concluding emits a player-visible adventure-completed event carrying the authored completion narrative and transitions the session to `victory` — a terminal mode mirroring game-over: play commands are illegal, referee commands remain legal except those that would resume play — teleporting the party, spawning an encounter — which is also what lets rewards land after the transition. The session concludes rather than continuing — the adventure module is the playable unit, campaign continuity is a non-goal, and a post-victory mode can arrive additively later where shipping "continue" first and tightening later could not. The same work closes an existing terminal-state gap: a non-battle party wipe (trap, deprivation) routes to game-over instead of leaving the session exploring forever. -**Validation.** `validate_adventure` resolves every authored reference: item ids in conditions, caches, and consequences against the effective catalog; monster, area, and dungeon/level references in triggers; quest and objective ids in lifecycle references. Advisory analysis beyond hard reference checks — flag reads with no writer, trigger cycles, reachability — is authoring-tool territory, not engine validation. +**Validation.** `validate_adventure` resolves every authored reference: item ids in conditions, caches, and consequences against the effective catalog; monster, area, and dungeon/level references in triggers; the same references in every quest clause — activation, an objective's completion, a hidden objective's reveal — and in every reward. Quest and objective ids are checked where they are used rather than where they are written: no document authors a lifecycle command, because the authored-consequence type forecloses it at parse, so the four lifecycle commands resolve their ids against the adventure's quest specs at execution and reject an id no spec holds. Advisory analysis beyond hard reference checks — flag reads with no writer, trigger cycles, reachability — is authoring-tool territory, not engine validation. Everything above is additive within the current `schema_version`: new optional fields, new event types, new save-state blocks, and the new session-mode value (see persistence, replay, and versioning for the enum-value rule). diff --git a/src/osrlib/crawl/narrative.py b/src/osrlib/crawl/narrative.py index 38c3abb..c572a21 100644 --- a/src/osrlib/crawl/narrative.py +++ b/src/osrlib/crawl/narrative.py @@ -9,8 +9,12 @@ English formatter ([`format_message`][osrlib.messages.format_message]) appends the beat that rides an event, so a bare transcript reads the authored line exactly as written. -- **The journal form**, the entry a quest or trigger appends to the party's - written record. +- **The journal form**, the entry a carrier appends to the party's written record. + A quest beat does not use it: what a quest journals *is* the display text it + showed, so the journal reads as the transcript of what the table was told. This + field is the voice of carriers whose display beat the players never see — a + trigger's `fired` text rides a referee-visibility event, so a trigger that should + say something to the table says it here. - **LLM guidance**, steering for a narrating front end that is never displayed verbatim — the same trust posture as an area's description prose, which already flows into narration. @@ -33,18 +37,31 @@ class NarrativeBlock(BaseModel): Every field is free prose defaulting to the empty string, which means unauthored — a block with a refusal beat and nothing else is the normal shape. - The display beats are read by the carriers named: + Which display beats a block speaks depends on what it hangs on: - `refusal`, `success` — a gate ([`GateSpec`][osrlib.crawl.gates.GateSpec]): the line a refused attempt returns, and the line that rides the successful command's event. - - `fired` — a trigger, when its consequences run. - - `offer`, `progress`, `completion` — a quest, at activation, at an - objective's completion, and at the quest's own. + - `fired` — a trigger ([`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]), when + its consequences run. It rides a referee-visibility event, so it is the + referee's line about the wiring; `journal` is the players' line about the + same moment. + - `offer`, `completion` — a quest ([`QuestSpec`][osrlib.crawl.quests.QuestSpec]), + at its activation and at its own completion. + - `offer`, `progress` — an objective + ([`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec]), when it is revealed + (the objective presenting itself) and when it completes (the story advancing). - `journal` is the written-record form, `guidance` the LLM steering that applies - while the carrier is in play, and `speaker` an attribution ("the bronze - sentinel", "Sister Halda") a renderer may put in front of a beat. + Per-objective beats need a per-objective carrier, which is why the objective + reads the same two field names for moments of its own. A quest's `progress` and + an objective's `completion` are read by nobody, and are silently unread rather + than rejected at parse — the same standing convention by which a gate leaves + `fired` alone and a trigger leaves `offer` alone. + + `journal` is the written-record form (unread by the quest layer, which journals + the display text it showed), `guidance` the LLM steering that applies while the + carrier is in play, and `speaker` an attribution ("the bronze sentinel", + "Sister Halda") a renderer may put in front of a beat. Examples: ```python From d684b07286d6f8ad75eb62b4880e08900d13b597 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 7 Aug 2026 00:00:30 -0700 Subject: [PATCH 8/9] address rubber-duck review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI walkthrough's fetch-quest transcript is now two byte-exact excerpts of the real seed-21 run, with the split named in prose and the two teachable surprises — the cache spreading the idol to the thief, and the completion beat riding both closing events — taught instead of hidden. _validate_consequence drops the magic parameter nothing read. The views guide's complete example authors a two-objective quest and registers the interpreter, so the quest projection and the no-journal- event pin run under the docs-examples harness and the no-run fragment has its runnable twin. The example README's scripted run uses seed 21, the seed whose transcript does what the paragraph says. Claude-Session: https://claude.ai/code/session_01NQ83sqNnXVeXEd55NK1xR7 --- docs/front-ends/tui-crawler.md | 33 +++++++++++++-- docs/guides/views-and-visibility.md | 63 ++++++++++++++++++++++++++--- examples/tui_crawler/README.md | 2 +- src/osrlib/crawl/adventure.py | 7 +--- 4 files changed, 89 insertions(+), 16 deletions(-) diff --git a/docs/front-ends/tui-crawler.md b/docs/front-ends/tui-crawler.md index 629c9fd..331bb5a 100644 --- a/docs/front-ends/tui-crawler.md +++ b/docs/front-ends/tui-crawler.md @@ -160,22 +160,47 @@ the ones character creation already drew from: The interpreter is an ordinary [`Listener`][osrlib.crawl.session.Listener]: it runs after every command, matches the events against the adventure's triggers and quests, and acts the only way anything outside the engine may — by executing referee -commands, each stamped with the quest it acted for. The transcript shows the beats as -they land, rendered from typed events by the same formatter as everything else: +commands, each stamped with the quest it acted for. Two moments from the end of the same +milestone run show it, rendered from typed events by the same formatter as everything +else. Emptying the shrine cache: ```text > take idol_shrine - character-0001 acquires jade-idol and 50 gp in coin. + character-0001 acquires 13 gp in coin. + character-0002 acquires 13 gp in coin. + character-0003 acquires jade-idol and 12 gp in coin. + character-0004 acquires 12 gp in coin. Quest the-idol: objective recover-idol is done. The idol comes up out of the hollow, cold as well-water. +``` + +Then, four `move w` steps later, the homecoming: + +```text > town The party enters town town. The adventure ends: 0 XP from monsters and 50 XP from treasure — 12 XP to each of 4 survivor(s). + character-0001 gains 12 XP (base 12), now level 1. + character-0002 gains 9 XP (base 12), now level 1. + character-0003 gains 13 XP (base 12), now level 1. + character-0004 gains 13 XP (base 12), now level 1. Quest the-idol: objective return-home is done. Threshold's gate shuts behind you with the idol inside it. Quest complete: The Jade Idol. The almoner counts out the reward without looking up. The idol is home. - The adventure is over: the-idol is finished. + The adventure is over: the-idol is finished. The almoner counts out the reward without looking up. The idol is home. character-0001 acquires 200 gp in coin. + character-0001 gains 1260 XP (base 1200), now level 1. + character-0002 gains 960 XP (base 1200), now level 1. + character-0003 gains 1320 XP (base 1200), now level 2. + character-0003 advances to level 2 (Footpad): +4 hp (rolled 4). + character-0004 gains 1320 XP (base 1200), now level 1. ``` +Two details of that output are the whole chapter in miniature. The cache spreads across +the party by the ordinary loot rules, so the thief is the one carrying the idol when the +party walks home — and the objective's `has_item` condition asks whether *the party* +carries it, not who. And the completion beat appears twice, on the quest's own event and +again on the adventure's, because each event carries the authored line and the formatter +appends whatever beat rides the event it is given. + ### Why the milestone makes two trips The homecoming objective is a `town_entered` pattern narrowed by a `has_item` diff --git a/docs/guides/views-and-visibility.md b/docs/guides/views-and-visibility.md index 705bc41..1f02945 100644 --- a/docs/guides/views-and-visibility.md +++ b/docs/guides/views-and-visibility.md @@ -123,10 +123,10 @@ narrative block or level. ```{.python .no-run} # Active quests only, revealed objectives only, and none of the wiring behind them. -quest = player_view.quests[0] -assert (quest.id, quest.speaker) == ("the-idol", "the temple almoner") -assert [objective.id for objective in quest.objectives] == ["recover-idol"] -assert "reveal_when" not in player_view.model_dump_json() +quest_view = player_view.quests[0] +assert (quest_view.id, quest_view.speaker) == ("the-lamps", "Sister Halda") +assert [entry.id for entry in quest_view.objectives] == ["find-the-lever"] +assert "name-the-dead" not in player_view.model_dump_json() ``` ### What tells a client the journal grew @@ -170,11 +170,16 @@ from osrlib.crawl.commands import ( MarkTriggerFired, RecordNote, SessionMode, + SetFlag, SpawnMonsters, ) from osrlib.crawl.dungeon import DungeonSpec, LevelSpec +from osrlib.crawl.interpreter import Interpreter +from osrlib.crawl.narrative import NarrativeBlock from osrlib.crawl.party import Party +from osrlib.crawl.quests import ObjectiveSpec, QuestSpec, TriggerClause from osrlib.crawl.session import GameSession +from osrlib.crawl.triggers import DungeonEnteredPattern, FlagSetPattern rules = Ruleset() creation = RngStreams(master_seed=13).get(CHARACTER_CREATION_STREAM) @@ -190,8 +195,29 @@ party = Party(members=[hero.character]) level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0)) crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,)) town = TownSpec(name="Threshold", travel_turns={"crypt": 1}) -adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,)) + +# One errand, offered at the threshold: one objective the party is told about, and +# one it is not. +errand = QuestSpec( + id="the-lamps", + name="The Unlit Lamps", + activation=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="crypt")), + objectives=( + ObjectiveSpec( + id="find-the-lever", + when=TriggerClause(pattern=FlagSetPattern(key="crypt.lever")), + narrative=NarrativeBlock(progress="The lamps come up one by one."), + ), + ObjectiveSpec(id="name-the-dead", when=TriggerClause(pattern=FlagSetPattern(key="crypt.name")), hidden=True), + ), + narrative=NarrativeBlock( + offer="Light the crypt's lamps before the moon sets.", + speaker="Sister Halda", + ), +) +adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,), quests=(errand,)) session = GameSession.new(party, adventure, seed=13) +session.register_listener(Interpreter(session)) session.execute(EnterDungeon(dungeon_id="crypt")) @@ -220,9 +246,34 @@ journal_view = session.view(Visibility.PLAYER) referee_state = session.view(Visibility.REFEREE).state # The beat is for the table; the trigger that produced it is referee-only wiring. -assert [entry.text for entry in journal_view.journal] == ["The lever grinds."] +assert [entry.text for entry in journal_view.journal][-1] == "The lever grinds." assert "lever-east" not in journal_view.model_dump_json() assert referee_state["fired_triggers"] == ["lever-east"] + +# The quest activated at the threshold, and its offer opened the journal. +quest_view = journal_view.quests[0] +assert (quest_view.id, quest_view.name) == ("the-lamps", "The Unlit Lamps") +assert quest_view.narrative == "Light the crypt's lamps before the moon sets." +assert quest_view.speaker == "Sister Halda" +assert journal_view.journal[0].text == quest_view.narrative + +# Only the revealed objective is projected, and none of the wiring behind it. +assert [(entry.id, entry.state) for entry in quest_view.objectives] == [("find-the-lever", "incomplete")] +blob = journal_view.model_dump_json() +assert "name-the-dead" not in blob # a hidden objective has no view at all +assert "pattern_type" not in blob and "crypt.lever" not in blob + +# The flag that objective watches: the quest completes it, journals its beat, and +# reports the beat through its own event — no journal event follows. +lit = session.execute(SetFlag(key="crypt.lever", value=True)) +codes = [event.code for event in lit.events] +assert "session.quest.objective_completed" in codes +assert "session.journal.entry_added" not in codes +assert session.journal[-1].text == "The lamps come up one by one." + +after = session.view(Visibility.PLAYER) +assert [(entry.id, entry.state) for entry in after.quests[0].objectives] == [("find-the-lever", "complete")] +assert session.quests["the-lamps"].status == "active" # the hidden objective is still open ``` ## Where next diff --git a/examples/tui_crawler/README.md b/examples/tui_crawler/README.md index 1f6290e..bb94f54 100644 --- a/examples/tui_crawler/README.md +++ b/examples/tui_crawler/README.md @@ -32,7 +32,7 @@ quit Non-interactive mode replays a transcript with a fixed party and seed: ```sh -uv run python -m examples.tui_crawler --seed 5 --script examples/tui_crawler/scripts/milestone.txt +uv run python -m examples.tui_crawler --seed 21 --script examples/tui_crawler/scripts/milestone.txt ``` That transcript is the milestone playthrough, in two trips: the delve, a generated diff --git a/src/osrlib/crawl/adventure.py b/src/osrlib/crawl/adventure.py index c78941f..b505bc3 100644 --- a/src/osrlib/crawl/adventure.py +++ b/src/osrlib/crawl/adventure.py @@ -359,7 +359,6 @@ def _validate_consequence( adventure: Adventure, monsters: MonsterCatalog, equipment: EquipmentCatalog, - magic: MagicItemCatalog, errors: list[str], ) -> None: """Resolve one authored consequence's references and its character addressing. @@ -417,9 +416,7 @@ def _validate_trigger( owner = f"trigger {trigger.id!r}" _validate_clause(trigger.when, trigger.conditions, owner, adventure, monsters, equipment, magic, errors) for position, consequence in enumerate(trigger.consequences): - _validate_consequence( - consequence, f"{owner}: consequence {position}", adventure, monsters, equipment, magic, errors - ) + _validate_consequence(consequence, f"{owner}: consequence {position}", adventure, monsters, equipment, errors) def _validate_quest( @@ -460,7 +457,7 @@ def _validate_quest( errors, ) for position, reward in enumerate(quest.rewards): - _validate_consequence(reward, f"{owner}: reward {position}", adventure, monsters, equipment, magic, errors) + _validate_consequence(reward, f"{owner}: reward {position}", adventure, monsters, equipment, errors) def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment: EquipmentCatalog) -> None: From 1fa98a5053c455e5a1c394f813c0e88796b452e4 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 7 Aug 2026 00:05:03 -0700 Subject: [PATCH 9/9] fold in rubber-duck re-verification: the journal fragment matches its twin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quest added to the views guide's runnable example opens the journal with its offer beat, and the revision updated the runnable block's assertion to the last-entry form but left the narrated fragment above it on the old whole-journal equality — the fragment its own page disproves. Mirror the [-1] form, which is also the honest teaching now that a quest opens the journal. Claude-Session: https://claude.ai/code/session_01NQ83sqNnXVeXEd55NK1xR7 --- docs/guides/views-and-visibility.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/views-and-visibility.md b/docs/guides/views-and-visibility.md index 1f02945..1362455 100644 --- a/docs/guides/views-and-visibility.md +++ b/docs/guides/views-and-visibility.md @@ -107,7 +107,7 @@ player view whole, while the trigger that wrote it does not reach it at all. ```{.python .no-run} # The beat is for the table; the trigger that produced it is referee-only wiring. -assert [entry.text for entry in journal_view.journal] == ["The lever grinds."] +assert [entry.text for entry in journal_view.journal][-1] == "The lever grinds." assert "lever-east" not in journal_view.model_dump_json() assert referee_state["fired_triggers"] == ["lever-east"] ```