Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added

- An objective now has a display name everywhere the id used to stand alone (#65). `ObjectiveSpec.name` is the authored label — optional and defaulting empty, additive within schema 3, so every existing document loads unchanged; empty means unauthored, and everything that shows a label falls back to the objective's id. `ObjectiveView.name` carries that resolved label, so the view's promise of "what it is called" is finally true and a quest log built from `PlayerView.quests` never captions a checkbox with `find-the-lever`. `ObjectiveRevealedEvent` and `ObjectiveCompletedEvent` carry `name` (the objective's resolved label) and `quest_name` (the owning quest's name — the symmetry `QuestActivatedEvent` already shipped), `AdventureCompletedEvent` carries the concluding quest's `name`, and the default formatter's templates print the names in place of the raw ids: `Quest The Jade Idol: objective Recover the idol is done.` The new event fields default empty purely so a log written before they existed still parses — the engine always fills them, and the templates fall back to the ids for those older events, which is exactly the old wording. The bundled examples and fixtures author names for their objectives, and the two quest-bearing scenario goldens moved with the added fields and the renamed transcript lines; no draw sequence changed.

## [1.5.0] - 2026-08-07

### Added
Expand Down
10 changes: 5 additions & 5 deletions docs/front-ends/tui-crawler.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ referee-only state themselves:
`PlayerView` when called with `Visibility.PLAYER` — hit points, gold, and carried
valuables, and nothing a referee-only view would add. `_status` also walks
`PlayerView.quests`: the **active** quests only, each with its revealed objectives
and their states, which is why the closing status after victory lists no quest at
all — a finished quest leaves the projection, and its record is the journal.
by display name and state, which is why the closing status after victory lists no
quest at all — a finished quest leaves the projection, and its record is the journal.
`_journal` renders `PlayerView.journal`, the authored record in order of discovery,
each beat stamped with the clock round it landed at. Both verbs are pure view
reads: they execute no command, draw nothing, and log nothing, so a script may
Expand Down Expand Up @@ -185,7 +185,7 @@ else. Emptying the shrine cache:
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.
Quest The Jade Idol: objective Recover the idol is done. The idol comes up out of the hollow, cold as well-water.
```

Then, four `move w` steps later, the homecoming:
Expand All @@ -198,9 +198,9 @@ Then, four `move w` steps later, the homecoming:
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 The Jade Idol: objective Bring it 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.
The adventure is over: The Jade 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.
Expand Down
3 changes: 2 additions & 1 deletion docs/guides/views-and-visibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ session journal as written ([`JournalEntry`][osrlib.crawl.session.JournalEntry]
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); 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
attribution, and the revealed objectives with their ids, display names, 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,
Expand Down Expand Up @@ -130,6 +130,7 @@ narrative block or level.
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 quest_view.objectives[0].name == "Find the lever" # the authored name, or the id when unauthored
assert "name-the-dead" not in player_view.model_dump_json()
```

Expand Down
4 changes: 2 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
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, display names, 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.

Expand Down Expand Up @@ -201,7 +201,7 @@ 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 — 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.
**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 and an optional display name — defaulting empty so a document written before the field existed loads unchanged, with everything that shows a label (the view, the lifecycle events, the default formatter) falling back to the objective's id — 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. 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.

Expand Down
2 changes: 1 addition & 1 deletion examples/tui_crawler/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ def _status(session) -> None:
# Active quests only: a completed quest leaves the projection, its record kept
# by the journal.
for quest in view.quests:
objectives = ", ".join(f"{objective.id} {objective.state}" for objective in quest.objectives)
objectives = ", ".join(f"{objective.name} {objective.state}" for objective in quest.objectives)
print(f" Quest: {quest.name}" + (f" — {objectives}" if objectives else ""))


Expand Down
2 changes: 2 additions & 0 deletions examples/tui_crawler/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,13 @@ def _fetch_quest() -> QuestSpec:
objectives=(
ObjectiveSpec(
id="recover-idol",
name="Recover the 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",
name="Bring it home",
when=TriggerClause(
pattern=TownEnteredPattern(),
conditions=(HasItemCondition(item_id=IDOL_ID),),
Expand Down
23 changes: 19 additions & 4 deletions src/osrlib/crawl/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -904,8 +904,12 @@ class QuestActivatedEvent(Event):
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.
`name` is the objective's display label — its authored name, or its id when the
document authors none — and `quest_name` the owning quest's name, both resolved
at emission so a renderer holds no document to look them up in. Both default
empty only because an event logged before the fields existed still parses; the
engine always fills them. `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"})
Expand All @@ -914,15 +918,21 @@ class ObjectiveRevealedEvent(Event):
code: str = "session.quest.objective_revealed"
visibility: Visibility = Visibility.PLAYER
quest_id: str
quest_name: str = ""
objective_id: str
name: 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.
`name` is the objective's display label — its authored name, or its id when the
document authors none — and `quest_name` the owning quest's name, both resolved
at emission so a renderer holds no document to look them up in. Both default
empty only because an event logged before the fields existed still parses; the
engine always fills them. `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"})
Expand All @@ -931,7 +941,9 @@ class ObjectiveCompletedEvent(Event):
code: str = "session.quest.objective_completed"
visibility: Visibility = Visibility.PLAYER
quest_id: str
quest_name: str = ""
objective_id: str
name: str = ""
narrative: str | None = None


Expand All @@ -958,6 +970,8 @@ class AdventureCompletedEvent(Event):

Follows the [`QuestCompletedEvent`][osrlib.crawl.events.QuestCompletedEvent] of
the quest that concludes the adventure, and carries the same completion beat.
`name` is that quest's authored display name, defaulting empty only because an
event logged before the field existed still parses; the engine always fills it.
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.
Expand All @@ -969,6 +983,7 @@ class AdventureCompletedEvent(Event):
code: str = "session.adventure.completed"
visibility: Visibility = Visibility.PLAYER
quest_id: str
name: str = ""
narrative: str | None = None


Expand Down
10 changes: 9 additions & 1 deletion src/osrlib/crawl/quests.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,17 @@ def _conditions_never_consume(self) -> TriggerClause:


class ObjectiveSpec(BaseModel):
"""One objective: how it completes, whether it starts hidden, and its text.
"""One objective: what it is called, 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.

`name` is the objective's display label, the words a quest log shows beside its
checkbox. It defaults empty — a document written before the field existed loads
unchanged, additive within the schema version — and empty means unauthored:
everywhere a label is shown (the view, the lifecycle events, the default
formatter), an unauthored name falls back to the objective's id.

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
Expand All @@ -112,6 +118,7 @@ class ObjectiveSpec(BaseModel):

recover = ObjectiveSpec(
id="recover-idol",
name="Recover the flask",
when=TriggerClause(pattern=ItemAcquiredPattern(item_id="holy_water")),
narrative=NarrativeBlock(progress="The flask is yours; the shrine is quiet again."),
)
Expand All @@ -122,6 +129,7 @@ class ObjectiveSpec(BaseModel):
model_config = ConfigDict(frozen=True)

id: str = Field(min_length=1)
name: str = ""
when: TriggerClause
hidden: bool = False
reveal_when: TriggerClause | None = None
Expand Down
22 changes: 19 additions & 3 deletions src/osrlib/crawl/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1182,7 +1182,15 @@ def _handle_reveal_objective(session: GameSession, command: RevealObjective) ->
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)]
return [], [
ObjectiveRevealedEvent(
quest_id=spec.id,
quest_name=spec.name,
objective_id=objective.id,
name=objective.name or objective.id,
narrative=beat or None,
)
]


def _handle_complete_objective(session: GameSession, command: CompleteObjective) -> tuple[list[Rejection], list[Event]]:
Expand All @@ -1204,7 +1212,15 @@ def _handle_complete_objective(session: GameSession, command: CompleteObjective)
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)]
return [], [
ObjectiveCompletedEvent(
quest_id=spec.id,
quest_name=spec.name,
objective_id=objective.id,
name=objective.name or objective.id,
narrative=beat or None,
)
]


def _handle_complete_quest(session: GameSession, command: CompleteQuest) -> tuple[list[Rejection], list[Event]]:
Expand All @@ -1228,7 +1244,7 @@ def _handle_complete_quest(session: GameSession, command: CompleteQuest) -> tupl
session.encounter = None
session.battle = None
session.mode = SessionMode.VICTORY
events.append(AdventureCompletedEvent(quest_id=spec.id, narrative=beat or None))
events.append(AdventureCompletedEvent(quest_id=spec.id, name=spec.name, narrative=beat or None))
return [], events


Expand Down
10 changes: 9 additions & 1 deletion src/osrlib/crawl/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ class ObjectiveView(BaseModel):

id: str
"""The objective's authored id, scoped to its quest."""
name: str
"""The objective's display label: its authored `name`, or its id when the
document authors none — never empty, because the view's job is what it is
called."""
state: str
"""`"incomplete"` or `"complete"`."""

Expand Down Expand Up @@ -396,7 +400,11 @@ def _quest_views(session):
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")
ObjectiveView(
id=objective.id,
name=objective.name or objective.id,
state="complete" if objective_state.complete else "incomplete",
)
)
narrative = quest.narrative
yield QuestView(
Expand Down
Loading
Loading