diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f062e0..497a30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/front-ends/tui-crawler.md b/docs/front-ends/tui-crawler.md index 64d002a..edd742f 100644 --- a/docs/front-ends/tui-crawler.md +++ b/docs/front-ends/tui-crawler.md @@ -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 @@ -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: @@ -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. diff --git a/docs/guides/views-and-visibility.md b/docs/guides/views-and-visibility.md index 1faef83..9a8bb50 100644 --- a/docs/guides/views-and-visibility.md +++ b/docs/guides/views-and-visibility.md @@ -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, @@ -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() ``` diff --git a/docs/spec.md b/docs/spec.md index 88fa055..4a7b299 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 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. @@ -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. diff --git a/examples/tui_crawler/__main__.py b/examples/tui_crawler/__main__.py index 4e6ae60..ec1cd4a 100644 --- a/examples/tui_crawler/__main__.py +++ b/examples/tui_crawler/__main__.py @@ -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 "")) diff --git a/examples/tui_crawler/content.py b/examples/tui_crawler/content.py index 17dac22..c26e7b3 100644 --- a/examples/tui_crawler/content.py +++ b/examples/tui_crawler/content.py @@ -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),), diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 4300787..a4fe229 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -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"}) @@ -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"}) @@ -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 @@ -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. @@ -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 diff --git a/src/osrlib/crawl/quests.py b/src/osrlib/crawl/quests.py index 2574b9a..ad92900 100644 --- a/src/osrlib/crawl/quests.py +++ b/src/osrlib/crawl/quests.py @@ -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 @@ -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."), ) @@ -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 diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index a16a7ac..10d0b14 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -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]]: @@ -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]]: @@ -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 diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 65a1d59..29cde11 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -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"`.""" @@ -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( diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index 982e2d9..f923424 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -6,10 +6,10 @@ the code string itself rather than raising, so a transcript stays printable even when it holds event types this version of the library doesn't recognize. -Templates reference entity IDs, not names — events carry structured facts and IDs -only. A front end or narrator that wants prose with names resolves IDs itself and -localizes freely; this formatter exists so a bare kernel transcript is readable without -one. +Templates reference what the event carries: entity IDs, and — where an event resolves +one at emission, as the quest and objective events do — an authored display name. A +front end or narrator that wants richer prose resolves IDs itself and localizes +freely; this formatter exists so a bare kernel transcript is readable without one. """ from collections.abc import Callable @@ -351,12 +351,16 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: "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}.", + # The quest and objective names fall back to the ids so an event logged before + # the name fields existed still formats — the engine always fills them. + "session.quest.objective_revealed": lambda event: ( + f"Quest {event.quest_name or event.quest_id}: a new objective, {event.name or event.objective_id}." + ), "session.quest.objective_completed": lambda event: ( - f"Quest {event.quest_id}: objective {event.objective_id} is done." + f"Quest {event.quest_name or event.quest_id}: objective {event.name or 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.", + "session.adventure.completed": lambda event: f"The adventure is over: {event.name or event.quest_id} is finished.", } diff --git a/tests/crawl_fixtures.py b/tests/crawl_fixtures.py index b998f63..30c5870 100644 --- a/tests/crawl_fixtures.py +++ b/tests/crawl_fixtures.py @@ -76,8 +76,10 @@ "QUEST_NAME", "QUEST_OFFER", "QUEST_RECOVER", + "QUEST_RECOVER_NAME", "QUEST_RECOVER_PROGRESS", "QUEST_RETURN", + "QUEST_RETURN_NAME", "QUEST_RETURN_OFFER", "QUEST_RETURN_PROGRESS", "QUEST_SPEAKER", @@ -107,9 +109,13 @@ QUEST_RECOVER = "recover-idol" """The visible objective: the idol comes out of the delve.""" +QUEST_RECOVER_NAME = "Recover the idol" + QUEST_RETURN = "return-home" """The hidden objective: revealed in room_a, completed by walking home carrying it.""" +QUEST_RETURN_NAME = "Walk it home" + 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." @@ -250,11 +256,13 @@ def build_fetch_quest(**overrides) -> QuestSpec: objectives=( ObjectiveSpec( id=QUEST_RECOVER, + name=QUEST_RECOVER_NAME, when=TriggerClause(pattern=ItemAcquiredPattern(item_id="holy_water")), narrative=NarrativeBlock(progress=QUEST_RECOVER_PROGRESS), ), ObjectiveSpec( id=QUEST_RETURN, + name=QUEST_RETURN_NAME, when=TriggerClause(pattern=TownEnteredPattern(), conditions=(HasItemCondition(item_id="holy_water"),)), hidden=True, reveal_when=TriggerClause( @@ -610,11 +618,13 @@ def build_barrow_adventure() -> Adventure: objectives=( ObjectiveSpec( id="recover-idol", + name="Recover the 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", + name="Speak the rite", when=TriggerClause(pattern=FlagSetPattern(key=RITE_KEY, value="spoken")), hidden=True, reveal_when=TriggerClause( diff --git a/tests/goldens/phase15_quest.json b/tests/goldens/phase15_quest.json index 61a6e6e..53a5a9b 100644 --- a/tests/goldens/phase15_quest.json +++ b/tests/goldens/phase15_quest.json @@ -198,9 +198,11 @@ { "code": "session.quest.objective_completed", "event_type": "objective_completed", + "name": "Recover the idol", "narrative": "The idol comes out of its niche as if it were waiting.", "objective_id": "recover-idol", "quest_id": "the-idol", + "quest_name": "The Votive Idol", "visibility": "player" }, { @@ -224,9 +226,11 @@ { "code": "session.quest.objective_revealed", "event_type": "objective_revealed", + "name": "Speak the rite", "narrative": "The slab wants words said over it before the idol may leave.", "objective_id": "speak-the-rite", "quest_id": "the-idol", + "quest_name": "The Votive Idol", "visibility": "player" }, { @@ -239,9 +243,11 @@ { "code": "session.quest.objective_completed", "event_type": "objective_completed", + "name": "Speak the rite", "narrative": "The rite is spoken, and the cold goes out of the air.", "objective_id": "speak-the-rite", "quest_id": "the-idol", + "quest_name": "The Votive Idol", "visibility": "player" }, { @@ -255,6 +261,7 @@ { "code": "session.adventure.completed", "event_type": "adventure_completed", + "name": "The Votive Idol", "narrative": "The barrow is quiet. The idol is yours to carry home.", "quest_id": "the-idol", "visibility": "player" @@ -429,14 +436,14 @@ "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.", + "Quest The Votive Idol: objective Recover the 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.", + "Quest The Votive 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 The Votive 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.", + "The adventure is over: The Votive 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.", diff --git a/tests/goldens/phase5_milestone.json b/tests/goldens/phase5_milestone.json index 9faf456..731c874 100644 --- a/tests/goldens/phase5_milestone.json +++ b/tests/goldens/phase5_milestone.json @@ -3333,9 +3333,11 @@ { "code": "session.quest.objective_completed", "event_type": "objective_completed", + "name": "Recover the idol", "narrative": "The idol comes up out of the hollow, cold as well-water.", "objective_id": "recover-idol", "quest_id": "the-idol", + "quest_name": "The Jade Idol", "visibility": "player" }, { @@ -3443,9 +3445,11 @@ { "code": "session.quest.objective_completed", "event_type": "objective_completed", + "name": "Bring it home", "narrative": "Threshold's gate shuts behind you with the idol inside it.", "objective_id": "return-home", "quest_id": "the-idol", + "quest_name": "The Jade Idol", "visibility": "player" }, { @@ -3459,6 +3463,7 @@ { "code": "session.adventure.completed", "event_type": "adventure_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" @@ -3601,7 +3606,7 @@ "master_seed": 21, "mode": "victory", "party_document": { - "engine_version": "1.4.0", + "engine_version": "1.5.0", "kind": "party", "payload": { "characters": [ @@ -4169,7 +4174,7 @@ "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.", "The party moves to (3, 0), facing west.", "The party enters area guard_room (level 1).", "The party moves to (2, 0), facing west.", @@ -4181,9 +4186,9 @@ "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.", diff --git a/tests/test_example_crawler.py b/tests/test_example_crawler.py index adc6bbb..6b5f1b7 100644 --- a/tests/test_example_crawler.py +++ b/tests/test_example_crawler.py @@ -55,7 +55,7 @@ def test_scripted_run_reaches_the_milestone(self): assert "quest.idol = 'recovered'" in out # The status view renders the active quest, and the journal verb prints # the authored record with its clock stamps. - assert "Quest: The Jade Idol — recover-idol incomplete, return-home incomplete" in out + assert "Quest: The Jade Idol — Recover the idol incomplete, Bring it home incomplete" in out assert "[round 120] The temple wants the Jade Idol off the barrow king's altar" in out # The adventure is over, and the closing status says so. assert "[victory]" in out @@ -66,10 +66,10 @@ def test_the_quest_runs_as_authored_data(self): # 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 The Jade Idol: objective Recover the idol is done." in out + assert "Quest The Jade Idol: objective Bring it home is done." in out assert "Quest complete: The Jade Idol." in out - assert "The adventure is over: the-idol is finished." in out + assert "The adventure is over: The Jade 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 diff --git a/tests/test_phase15_goldens.py b/tests/test_phase15_goldens.py index 35ebd62..07feb0e 100644 --- a/tests/test_phase15_goldens.py +++ b/tests/test_phase15_goldens.py @@ -108,6 +108,7 @@ def test_the_bundled_idol_completes_the_visible_objective_on_its_catalog_id(self event for event in golden["event_log"] if event.get("code") == "session.quest.objective_completed" ) assert completed["objective_id"] == "recover-idol" + assert (completed["name"], completed["quest_name"]) == ("Recover the idol", "The Votive 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): @@ -115,6 +116,7 @@ def test_the_crypt_reveals_the_hidden_objective_and_the_rite_completes_it(self, event for event in golden["event_log"] if event.get("code") == "session.quest.objective_revealed" ) assert revealed["objective_id"] == "speak-the-rite" + assert (revealed["name"], revealed["quest_name"]) == ("Speak the rite", "The Votive Idol") 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) @@ -131,6 +133,7 @@ def test_the_completion_ends_the_adventure_in_victory(self, golden): 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["name"] == ending["name"] == "The Votive Idol" 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" diff --git a/tests/test_quests.py b/tests/test_quests.py index 2e7c463..8a5187d 100644 --- a/tests/test_quests.py +++ b/tests/test_quests.py @@ -25,8 +25,10 @@ QUEST_NAME, QUEST_OFFER, QUEST_RECOVER, + QUEST_RECOVER_NAME, QUEST_RECOVER_PROGRESS, QUEST_RETURN, + QUEST_RETURN_NAME, QUEST_RETURN_OFFER, QUEST_RETURN_PROGRESS, QUEST_SPEAKER, @@ -73,7 +75,9 @@ # The shared fetch quest's ids and beats, under the short names this module reads by. RECOVER = QUEST_RECOVER +RECOVER_NAME = QUEST_RECOVER_NAME RETURN = QUEST_RETURN +RETURN_NAME = QUEST_RETURN_NAME OFFER = QUEST_OFFER COMPLETION = QUEST_COMPLETION RECOVER_PROGRESS = QUEST_RECOVER_PROGRESS @@ -139,10 +143,16 @@ def test_an_objective_round_trips(self): def test_defaults_are_the_visible_shape(self): objective = ObjectiveSpec(id="recover", when=TriggerClause(pattern=TownEnteredPattern())) + assert objective.name == "" assert not objective.hidden assert objective.reveal_when is None assert objective.narrative is None + def test_a_document_written_before_objective_names_parses_with_the_empty_default(self): + payload = build_quest().objectives[0].model_dump(mode="json") + payload.pop("name") + assert ObjectiveSpec.model_validate(payload).name == "" + 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 @@ -652,8 +662,21 @@ def test_every_lifecycle_event_is_for_the_table_and_carries_its_beat(self): COMPLETION, COMPLETION, ] - assert emitted[0].name == emitted[3].name == "The Jade Idol" + assert emitted[0].name == emitted[3].name == emitted[4].name == "The Jade Idol" assert emitted[1].objective_id == RETURN and emitted[2].objective_id == RECOVER + # The objective events carry the display labels resolved: the objective's + # name and its quest's, so no renderer has to reach for the document. + assert emitted[1].name == RETURN_NAME and emitted[2].name == RECOVER_NAME + assert emitted[1].quest_name == emitted[2].quest_name == QUEST_NAME + + def test_an_unauthored_objective_name_rides_the_events_as_the_id(self): + quest = build_quest( + objectives=tuple(objective.model_copy(update={"name": ""}) for objective in build_quest().objectives) + ) + session = active_session(quest) + result = session.execute(CompleteObjective(quest_id=QUEST_ID, objective_id=RECOVER)) + event = next(event for event in result.events if isinstance(event, ObjectiveCompletedEvent)) + assert event.name == RECOVER def test_the_beat_rides_the_event_verbatim_through_the_formatter(self): from osrlib.messages import format_message @@ -663,6 +686,41 @@ def test_the_beat_rides_the_event_verbatim_through_the_formatter(self): event = next(event for event in result.events if isinstance(event, QuestCompletedEvent)) assert format_message(event) == f"Quest complete: The Jade Idol. {COMPLETION}" + def test_the_objective_templates_print_names_never_ids(self): + from osrlib.messages import format_message + + session = active_session(build_quest(concludes_adventure=True)) + revealed = next( + event + for event in session.execute(RevealObjective(quest_id=QUEST_ID, objective_id=RETURN)).events + if isinstance(event, ObjectiveRevealedEvent) + ) + assert format_message(revealed) == f"Quest {QUEST_NAME}: a new objective, {RETURN_NAME}. {RETURN_OFFER}" + completed = next( + event + for event in session.execute(CompleteObjective(quest_id=QUEST_ID, objective_id=RECOVER)).events + if isinstance(event, ObjectiveCompletedEvent) + ) + assert format_message(completed) == f"Quest {QUEST_NAME}: objective {RECOVER_NAME} is done. {RECOVER_PROGRESS}" + ended = next( + event + for event in session.execute(CompleteQuest(quest_id=QUEST_ID)).events + if isinstance(event, AdventureCompletedEvent) + ) + assert format_message(ended) == f"The adventure is over: {QUEST_NAME} is finished. {COMPLETION}" + + def test_an_event_logged_before_the_name_fields_formats_by_falling_back_to_the_ids(self): + from osrlib.messages import format_message + + # A schema-3 log written before the fields existed parses with the empty + # defaults, and the formatter falls back to the ids — the old wording. + old = ObjectiveCompletedEvent.model_validate( + {"event_type": "objective_completed", "quest_id": QUEST_ID, "objective_id": RECOVER} + ) + assert format_message(old) == f"Quest {QUEST_ID}: objective {RECOVER} is done." + older = AdventureCompletedEvent.model_validate({"event_type": "adventure_completed", "quest_id": QUEST_ID}) + assert format_message(older) == f"The adventure is over: {QUEST_ID} is finished." + class TestVictory: def test_the_concluding_completion_ends_the_adventure(self): @@ -805,6 +863,19 @@ def test_only_revealed_objectives_appear_in_authored_order(self): 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_objective_ships_its_display_name(self): + view = self.quests(active_session())[0].objectives[0] + assert (view.id, view.name) == (RECOVER, RECOVER_NAME) + + def test_an_unauthored_objective_name_falls_back_to_the_id(self): + # The view's job is what the objective is called, so it never ships an + # empty label: an unauthored name degrades to the authored id. + quest = build_quest( + objectives=tuple(objective.model_copy(update={"name": ""}) for objective in build_quest().objectives) + ) + view = self.quests(active_session(quest))[0].objectives[0] + assert view.name == RECOVER + def test_an_objectives_state_is_incomplete_until_it_is_done(self): session = active_session() assert self.quests(session)[0].objectives[0].state == "incomplete" @@ -848,6 +919,7 @@ def test_the_view_carries_no_quest_wiring(self): ): 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 RETURN_NAME not in blob, "and its display name is exactly as secret as its id" 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"