diff --git a/CHANGELOG.md b/CHANGELOG.md index c751c1b..9878013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- The lifecycle command surface an authored trigger or quest layer writes its bookkeeping with, plus the annotation that says on whose behalf a command was issued. Every `Command` gains an optional `source` — a string naming the authored object (a trigger or quest id) or the game system that issued it, never the empty string. Execution never reads it, so a stamped command does exactly what the unstamped one does; it rides the command into the log and survives a save, a load, and a replay, which is what makes "why did the party get that item?" answerable from the log alone. Three new referee commands, legal in every mode (terminal ones included) and rejecting nothing: `MarkTriggerFired` appends a trigger id to the new `session.fired_triggers`, the state that answers once-only semantics — marking an already-marked trigger is accepted, appends nothing, and still emits its referee-visibility `TriggerFiredEvent` (`session.trigger.fired`), so state records that a trigger has fired while the log records each firing; `AddJournalEntry` appends a `JournalEntry` — the authored text plus the clock position it landed at — to the new `session.journal` and emits the player-visible `JournalEntryAddedEvent` (`session.journal.entry_added`) carrying the whole entry; and `RecordNote` records an annotation with no state effect at all, emitting the referee-visibility `NoteRecordedEvent` (`session.note.recorded`) — the mechanism for machine-issued records and a referee's own margin notes alike. `PlayerView` gains `journal`, the entries shipped verbatim; the fired-marks and the notes stay referee-only, since content wiring is the game's secret. Both blocks persist under new payload keys with empty defaults, so there is no schema bump and no migration: a save written before them loads with both empty and starts remembering, and because these commands are the blocks' only writers, a replay — which runs with no listeners registered — rebuilds them exactly by re-executing the log. Nothing in the library issues these commands yet; they are the documented referee surface, and the library-shipped interpreter that drives them arrives with the authored trigger and quest layer. - `SessionMode.VICTORY` — the second terminal mode, the session that ended by finishing what it set out to do, beside `game_over`'s ending by wipe. Both answer the new `SessionMode.terminal` property, the one place "has this session ended?" is decided, for the engine and for a front end's loop alike. The legality contract in a terminal mode: every play command is illegal (`session.command.wrong_mode`, its `mode` param carrying `victory`), and every referee command is legal — grants, awards, flags, door writes, identification, time, dice — which is what lets an adventure's rewards land after it concludes. Three referee commands are the exception, each because it would resume play in a session that is over: `SpawnMonsters` and `SpawnNpcParty` open an encounter and are illegal in both terminal modes, and `PlaceParty` teleports the party into a play mode and is illegal in `victory`. `PlaceParty` remains legal in `game_over`, where it is the salvage door — `PlaceParty(town)` then `PurchaseHealing(service="raise_dead")`, with the clock still running on the revival window. Nothing in the library transitions *into* `victory` yet; the entrance arrives with the authored quest layer, and the mode, its property, and its legality rules ship first so that transition has a contract to land on. The new enum value is additive within the current `schema_version`: a `victory` save is one an older engine has never seen, the documented accepted risk for a new serialized enum value, and there is no migration. - Authored gates on doors and level transitions: `DoorSpec.requires` and `TransitionSpec.requires` carry a `GateSpec` — a condition the party must satisfy for opening the door or taking the stair to be a legal command. The condition vocabulary is the new discriminated union in `osrlib.crawl.gates`: `HasItemCondition` (some member's carried inventory holds an item with that catalog id — equipment or magic item, equipped slots included — optionally `consumes=True`), `FlagEqualsCondition` (a session flag holds a value, compared strictly: an absent key matches nothing, not even `False`, and a stored `True` never satisfies an authored `1`), and `EffectActiveCondition` (an active effect of that kind is attached to a party member). Evaluation is pure and level-triggered — `condition_holds` reads live state at the moment of the attempt and stores nothing, so a key dropped or sold stops opening its door — and the member domain is the whole party, living or dead, because the party carries its dead and their packs. A failed gate is an ordinary rejection with its own codes, `exploration.door.gate_refused` and `exploration.transition.gate_refused`, each carrying the author's refusal text when one was written; it is checked last, after every mundane refusal, so it fires exactly when the gate alone bars the way, and on `ForceDoor` that means a refused attempt makes no noise, denies no surprise, and rolls no die. Gates and locks are orthogonal layers: a door carrying both requires both, `PickLock` addresses only the lock, `SetDoorState` rewrites only the overlay, and a door standing open admits passage unchecked until it closes again. `validate_adventure` resolves `has_item` ids against the effective equipment catalog or the magic-item catalog; flag keys and effect kinds are open domains and stay unchecked. The fields are additive with `None` defaults, so there is no schema bump and no migration: existing documents and saves load unchanged, and an ungated adventure plays exactly as before, consuming no extra draws. - `NarrativeBlock` (`osrlib.crawl.narrative`) — the authored text attached to a mechanical object, in three audiences: display beats a deterministic renderer shows verbatim (`refusal` and `success` for gates; `fired` for triggers; `offer`, `progress`, and `completion` for quests), a `journal` form, and `guidance` for an LLM narrator that is never displayed as written, plus a free-prose `speaker` attribution. Gates read the first two: `refusal` rides the rejection, and `success` rides the successful command's own event through the new optional `DoorEvent.narrative` and `LocationEnteredEvent.narrative` fields, which `format_message` appends verbatim after the templated line. Authored text on an event is content data in a structured field — events still carry message codes and never engine-baked English. diff --git a/docs/guides/listeners-and-flags.md b/docs/guides/listeners-and-flags.md index 9f892af..10eb241 100644 --- a/docs/guides/listeners-and-flags.md +++ b/docs/guides/listeners-and-flags.md @@ -89,6 +89,39 @@ to decide whether to narrate the portcullis creaking open, say — reads `sessio when it holds the session, or `session.view(Visibility.REFEREE).state["flags"]` when it works from views alone. +## Lifecycle commands: fired-marks, the journal, and notes + +Flags are one vocabulary a reactive listener writes with. Three more referee commands cover the +bookkeeping an authored trigger or quest layer needs, and they behave exactly like `SetFlag` — +legal in every mode, never rejected, issued through `execute`, and logged and replayed like any +other command: + +- [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired] records that an authored trigger has + fired, appending its id to `session.fired_triggers` — the state that answers once-only + semantics. Marking a trigger that has already fired is accepted, appends nothing, and still + emits its [`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent], so a repeatable + trigger's every firing shows up in the log while the state stays a list of ids in first-fired + order. +- [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry] appends a beat to `session.journal`, + stamped with the clock position it landed at. The journal is the one part of this vocabulary the + players see: it ships verbatim in the [`PlayerView`][osrlib.crawl.views.PlayerView], and its + [`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] is player-visible. +- [`RecordNote`][osrlib.crawl.commands.RecordNote] records an annotation with no state effect at + all — the mechanism for machine-issued records (a consequence that was dropped, a cascade cut + short) and for a referee's own margin notes alike. Its event is referee-visibility, like the + fired-mark's. + +Both blocks are engine-owned session state: they persist in saves, and because these commands are +their only writers, a replay — which runs with no listeners registered — rebuilds them exactly by +re-executing the log. That is also why a listener must act by issuing commands rather than by +remembering things itself, the discipline this page opened with. + +The optional `source` stamp (see +[Sessions, commands, and events](sessions-commands-events.md)) is what ties the vocabulary +together: a listener that stamps the commands it issues with its own quest or trigger id leaves a +log that answers *why* every entry is there. A library-shipped trigger and quest interpreter will +be built on exactly this surface when it arrives; a game's own listener can drive it today. + ## The fetch quest, worked The TUI crawler (see [the complete front end](../front-ends/tui-crawler.md)) hides a named @@ -141,7 +174,14 @@ from osrlib.core.events import Event, Visibility from osrlib.core.rng import RngStreams from osrlib.core.ruleset import Ruleset from osrlib.crawl.adventure import Adventure, TownSpec -from osrlib.crawl.commands import EnterDungeon, MoveParty, SetFlag +from osrlib.crawl.commands import ( + AddJournalEntry, + EnterDungeon, + MarkTriggerFired, + MoveParty, + RecordNote, + SetFlag, +) from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec from osrlib.crawl.events import PartyMovedEvent from osrlib.crawl.party import Party @@ -194,6 +234,19 @@ assert session.flags == {"crypt.lever_pulled": True} # A front end working from views alone reads flags off the referee view instead. referee_state = session.view(Visibility.REFEREE).state assert referee_state["flags"] == {"crypt.lever_pulled": True} + +# The lifecycle vocabulary: mark the trigger, write the beat, annotate the margin. The +# source stamp says on whose behalf each command was issued. +session.execute(MarkTriggerFired(trigger_id="crypt.lever", source="trigger:crypt.lever")) +session.execute(AddJournalEntry(text="The lever grinds.", source="trigger:crypt.lever")) +session.execute(MarkTriggerFired(trigger_id="crypt.lever", source="trigger:crypt.lever")) +session.execute(RecordNote(text="The portcullis consequence had nothing to open.")) + +# A re-mark appends nothing; the journal is player-visible state, the marks are not. +assert session.fired_triggers == ["crypt.lever"] +assert [entry.text for entry in session.journal] == ["The lever grinds."] +assert session.view(Visibility.PLAYER).journal == tuple(session.journal) +assert session.command_log[-1].source is None # the note was the referee's own ``` ## Where next diff --git a/docs/guides/sessions-commands-events.md b/docs/guides/sessions-commands-events.md index 50ee386..2d0548f 100644 --- a/docs/guides/sessions-commands-events.md +++ b/docs/guides/sessions-commands-events.md @@ -35,6 +35,20 @@ Listeners are how a game adds its own reactive rules (a quest tracker, an achiev log) without touching the kernel; see [Listeners and flags](listeners-and-flags.md) for the extension point itself. +Every command also carries an optional `source`: a string naming the authored object — +a trigger or quest id — or the game system on whose behalf the command was issued. +Execution never reads it, so a stamped command does exactly what the unstamped one +does; it rides the command into the log and survives a save, a load, and a replay. The +log therefore records not just *who* acted but *on whose behalf*, which is what makes +"why did the party get that item?" answerable from the log alone: + +```{.python .no-run} +# The source stamp annotates the log and changes nothing about execution. +session.execute(AddJournalEntry(text="The lever grinds.", source="trigger:lever-east")) +assert session.command_log[-1].source == "trigger:lever-east" +assert session.view(Visibility.PLAYER).journal[-1].text == "The lever grinds." +``` + ## Session modes and mode gating [`SessionMode`][osrlib.crawl.commands.SessionMode] is a small, closed set: `town`, @@ -56,7 +70,11 @@ 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`, and the rest of the session-owned surface) are legal in +`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 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 @@ -216,10 +234,11 @@ crawl events together and is what the session's own log uses. ```python from osrlib.core.alignment import Alignment from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character +from osrlib.core.events import Visibility from osrlib.core.rng import RngStreams from osrlib.core.ruleset import Ruleset from osrlib.crawl.adventure import Adventure, TownSpec -from osrlib.crawl.commands import EnterDungeon, MoveParty, SessionMode, parse_command +from osrlib.crawl.commands import AddJournalEntry, EnterDungeon, MoveParty, SessionMode, parse_command from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec from osrlib.crawl.events import parse_any_event from osrlib.crawl.party import Party @@ -274,6 +293,11 @@ assert result.accepted lines = [format_message(event) for event in result.events] assert lines # every accepted command's events format to a default English line +# The source stamp annotates the log and changes nothing about execution. +session.execute(AddJournalEntry(text="The lever grinds.", source="trigger:lever-east")) +assert session.command_log[-1].source == "trigger:lever-east" +assert session.view(Visibility.PLAYER).journal[-1].text == "The lever grinds." + # Commands and events round-trip through their wire discriminator; unknown types parse to None. move_payload = MoveParty(direction=Direction.EAST).model_dump(mode="json") assert parse_command(move_payload) == MoveParty(direction=Direction.EAST) diff --git a/docs/guides/views-and-visibility.md b/docs/guides/views-and-visibility.md index 415bb88..04be67e 100644 --- a/docs/guides/views-and-visibility.md +++ b/docs/guides/views-and-visibility.md @@ -24,6 +24,15 @@ that streams or narrates the raw event log as it happens — an LLM referee doin turn-by-turn narration, say — is responsible for checking `.visibility` itself before showing an event to a player, the same way it would filter a database query. +The authored layer splits the same way. A journal beat is written for the table, so +[`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] is +player-visible and carries the authored text itself — content data in a structured +field, alongside the event's message code, never engine-baked English. The wiring that +produced the beat is not: a fired trigger +([`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent]) and a referee note +([`NoteRecordedEvent`][osrlib.crawl.events.NoteRecordedEvent]) are referee-visibility, +exactly as a flag write is, because content wiring is the game's secret. + Most front ends never need to do that filtering by hand, though, because osrlib also ships two ready-made projections of the *whole session*, one per audience, and either one already applies this filtering for its consumer. @@ -54,7 +63,10 @@ a plain wall throughout ([`ExploredLevelView`][osrlib.crawl.views.ExploredLevelV and [`EdgeView`][osrlib.crawl.views.EdgeView]); known dropped piles and emptied treasure caches in that explored space; active effects on party members with their remaining duration (except a potion's — RAW has the referee track that secretly, so -the view reports it as unknown); fatigue, exhaustion, and deprivation status; and, when +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 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, @@ -88,6 +100,16 @@ assert player_group.count == 1 assert "current_hp" not in player_group.model_dump() ``` +The authored layer shows the same shape from the other side: the journal reaches the +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 "lever-east" not in journal_view.model_dump_json() +assert referee_state["fired_triggers"] == ["lever-east"] +``` + ## Never trust the client The moment a game goes over a network, this split becomes a security boundary, not @@ -112,7 +134,14 @@ from osrlib.core.events import Visibility from osrlib.core.rng import RngStreams from osrlib.core.ruleset import Ruleset from osrlib.crawl.adventure import Adventure, TownSpec -from osrlib.crawl.commands import EnterDungeon, SessionMode, SpawnMonsters +from osrlib.crawl.commands import ( + AddJournalEntry, + EnterDungeon, + MarkTriggerFired, + RecordNote, + SessionMode, + SpawnMonsters, +) from osrlib.crawl.dungeon import DungeonSpec, LevelSpec from osrlib.crawl.party import Party from osrlib.crawl.session import GameSession @@ -151,6 +180,19 @@ assert "current_hp" in referee_monster player_group = player_view.encounter.groups[0] assert player_group.count == 1 assert "current_hp" not in player_group.model_dump() + +# A trigger fires: it is marked, it writes a journal beat, and the referee annotates it. +session.execute(MarkTriggerFired(trigger_id="lever-east")) +session.execute(AddJournalEntry(text="The lever grinds.", source="trigger:lever-east")) +session.execute(RecordNote(text="The east lever is the only one that answers.")) + +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 "lever-east" not in journal_view.model_dump_json() +assert referee_state["fired_triggers"] == ["lever-east"] ``` ## Where next diff --git a/docs/phase-13-plan.md b/docs/phase-13-plan.md new file mode 100644 index 0000000..304e718 --- /dev/null +++ b/docs/phase-13-plan.md @@ -0,0 +1,109 @@ +# Phase 13 plan — journal and lifecycle commands + +Implementation plan for phase 13 of [the osrlib spec](spec.md): the engine substrate for authored triggers and quests, with no interpreter — the optional `source` field on the base command model, session-state blocks for trigger fired-marks and the journal, the `MarkTriggerFired`, `AddJournalEntry`, and `RecordNote` referee commands with their events, the journal in the player view, and persistence of the new blocks. In the dependency chain (10 → 11 → 14 → 15, with 12 and 13 order-independent before their consumers), this phase must land before phase 14, whose interpreter issues these commands and reads this state; nothing in this phase consumes them itself. The milestone: **save/load and replay rebuild journal and fired-marks exactly, with no listeners registered, and `source` survives the command log.** + +Five facts shape the design: + +- **The command surface is the `SetFlag` pattern applied three times.** Each lifecycle command is pure session bookkeeping: no RNG draws, no clock time, no rejections, and a handler of a few lines beside `_handle_set_flag` at the bottom of `session.py`. All three inherit the base `_ALL_MODES` legality — they are legal in both terminal modes because none resumes play (they mutate bookkeeping, not the world), which is also load-bearing forward: phase 15's rewards land after the victory transition, and the interpreter's marks and notes must land beside them. The census machinery absorbs them mechanically: `REFEREE_COMMANDS` gains three members, `RESUME_PLAY_CARVE_OUTS` gains none, and the docs gates enforce the three-section docstrings. +- **`source` is one field on one base class, and a four-golden regeneration.** `Command` gains `source: str | None = None`; every subclass inherits it, no handler reads it, and `parse_command` and the frozen-model contract need no change. Because the golden generators dump commands with `model_dump(mode="json")` — the same serialization `session_state` uses for saves — every stored golden that carries serialized commands — `phase4_delve`, `phase5_milestone`, `phase11_gates`, `phase12_wipe`; the kernel goldens carry none — gains a `"source": null` key per command and regenerates, a mechanical pure-additive diff explained in the commit message per the standing golden rule (the phase 11 `narrative` precedent). That includes `phase11_gates`'s `refusals` block, which records the probe commands beside the accepted log — the added-null-keys diff is still the entire diff. "Ignored by execution" is pinned by test, not prose: two sessions executing the same command stamped and unstamped reach identical state except the log's own `source` values. +- **Replay must accept every logged mark, so re-marking is idempotent.** Under phase 14's semantics a repeatable trigger issues `MarkTriggerFired` before each firing, and a replay re-executes every logged command — so marking an already-marked trigger must be accepted, not rejected. The pin: a re-mark appends nothing (fired-marks answer "has this trigger ever fired", and nothing consumes a count) but still emits `TriggerFiredEvent` — the event is the log's record of *each* firing, state is the record of the first. Fired-marks are a `list[str]` in first-fired order: ordered and deterministic where a set is banned, and membership at authored-trigger scale needs no index. +- **The journal is state, never derived, and the entry model is the view.** `JournalEntry` is a frozen model beside `DeathRecord` in `session.py`, and `session.journal` is the appended list the spec names. An entry carries its `text` and the clock position (`rounds`) at append — pinned here because append-time is capturable only at append: the view API promises front ends never replay the event log, and a compacted save sheds the log entirely, so a journal without its own timestamps would strand "when" unrecoverable. The field is `rounds`, deliberately diverging from its neighbor `DeathRecord.round`: the entry copies a clock position, and the clock surface it copies is plural everywhere — `GameClock.rounds`, `PlayerView.clock_rounds` — where `DeathRecord.round` names the round an event happened in. The player view ships the entries verbatim (`tuple[JournalEntry, ...]`), exactly the spec's word — a mirror view-model would be a second home for a shape that is already safe by construction. No import cycle: `session.py` reaches `views.py` lazily inside `view()`, so `views.py` importing `JournalEntry` at module level is clean. +- **Visibility and persistence follow the flag precedent exactly.** `JournalEntryAddedEvent` is player-visible carrying the authored text — content data in a structured field, the spec's explicit carve-out, with message codes intact; `TriggerFiredEvent` and `NoteRecordedEvent` are referee-visibility, as `FlagSetEvent` is, because marks and notes are content wiring. The player-view whitelist admits the `journal` block and nothing else new — fired-marks never appear, pinned by the leak test. Persistence is two new payload keys (`fired_triggers`, `journal`) loaded with empty defaults: additive within `schema_version` 3, no bump, no migration; a pre-phase save loads with an empty journal and starts remembering, and an older engine loading a newer save drops the unknown keys — the documented additive-risk posture. + +## Scope + +In scope: + +- `source: str | None` on the base `Command`, with the planned regeneration of the four goldens that store command logs +- `MarkTriggerFired`, `AddJournalEntry`, and `RecordNote` in `crawl/commands.py`, with handlers in `crawl/session.py` +- `TriggerFiredEvent`, `JournalEntryAddedEvent`, and `NoteRecordedEvent` in `crawl/events.py`, registered and templated +- `JournalEntry` and the `fired_triggers`/`journal` session-state blocks, persisted in `persistence.py` +- `PlayerView.journal` +- The phase golden (`tests/goldens/phase13_journal.json`), `tests/test_journal_lifecycle.py`, docs, and the changelog + +Out of scope (deferred to the phase that picks each up): + +- **Trigger specs, the interpreter listener, document-order matching, the cascade depth bound, and dropped-consequence/truncation recording** — phase 14. `RecordNote` ships the recording *mechanism*; phase 14 ships its uses. Nothing in the library issues any of the three commands this phase — they are the documented referee surface, issuable by game code today, and shipping them unissued is the roadmap's explicit instruction ("the engine substrate with no interpreter"), not dead accommodation code. +- **`validate_adventure` trigger reference checks** — phase 14, with the trigger spec they resolve against. +- **Quest lifecycle commands, quest state, active quests in the player view, and journal appends at quest beats** — phase 15, which reuses this phase's journal block unchanged. +- **In-library stamping of `source`** — the interpreter (phase 14) and quest rewards (phase 15) are the stampers; this phase pins only that the field survives the log. +- **Validation of `trigger_id` against anything** — pinned open: no trigger spec exists to resolve against, and the open-domain posture is `SetFlag`'s key precedent. Phase 14 decides whether the command tightens when specs exist. +- **New rejection codes** — none anywhere in the phase: all three commands are `_ALL_MODES` referee commands (`wrong_mode` cannot fire) with total handlers, so `Rejections: None` like `SetFlag`, and `tools/docs/rejection_codes.json` is untouched. +- **A `Ruleset` flag and an adaptations entry** — nothing here reads the SRD: B/X has no journal, no triggers, and no session bookkeeping, so every pin is a spec-design decision and the register's silence is the phase 11/12 precedent. + +## Work items + +### 1. `source` on the base command — `crawl/commands.py` + +- `source: str | None = Field(default=None, min_length=1)` on `Command`, beneath `command_type`. The attribute docstring states the contract in the present tense: an annotation naming the authored object (trigger or quest id) or game system on whose behalf the command was issued; execution never reads it; it is logged and replayed with the command so the log alone answers "why did this happen". `min_length=1` makes the empty string unrepresentable — absent is `None`, never `""`. +- No subclass changes: the field inherits everywhere, `extra="ignore"` and frozen are untouched, and the discriminated union parses it wherever it appears. The command JSON Schema reference regenerates automatically (`tools/docs/gen_schema_reference.py` walks `ALL_COMMAND_CLASSES`). +- The golden consequence, planned: regenerate exactly `phase4_delve.json`, `phase5_milestone.json`, `phase11_gates.json`, and `phase12_wipe.json` — the four whose payloads store command logs — with the added `"source": null` keys as the entire diff, explained in the commit message. The creation, RNG-vector, and kernel battle goldens store no commands and do not change. + +### 2. The three commands — `crawl/commands.py` + +- `MarkTriggerFired`: `command_type: Literal["mark_trigger_fired"]`, `trigger_id: str` (min length 1). Docstring: records that an authored trigger has fired, before its consequences issue; fired-state answers once-only semantics and survives save, load, and replay; marking an already-marked trigger is accepted, changes nothing, and still emits the event — each mark in the log is one firing. Modes: all six. Rejections: none. Events: `TriggerFiredEvent`. +- `AddJournalEntry`: `command_type: Literal["add_journal_entry"]`, `text: str` (min length 1). Docstring: appends an authored beat to the session journal — the voice of a trigger or quest moment; entries append in order of discovery and are never rewritten or derived. Modes: all six. Rejections: none. Events: `JournalEntryAddedEvent`. +- `RecordNote`: `command_type: Literal["record_note"]`, `text: str` (min length 1). Docstring: records a referee-visibility annotation in the logs with no state effect — the mechanism for machine-issued records (a dropped consequence, a truncated cascade) and freeform referee margin notes alike. Modes: all six. Rejections: none. Events: `NoteRecordedEvent`. +- All three append to `ALL_COMMAND_CLASSES` after `RollDice` (the stable wire order grows at the tail) and join `__all__`. Docstring language stays present-tense contract — the decision-log vocabulary gate reads every docstring, so no "will arrive in a later phase" phrasing; the interpreter relationship is stated as "the surface a library-shipped trigger/quest interpreter or a game's own listener drives" — the spec's own framing of the referee-command surface ("the surface a game's own logic, module scripting, the library's trigger/quest interpreter, or an LLM referee drives"), phrased as present-tense contract. + +### 3. The three events — `crawl/events.py`, `messages.py` + +- `TriggerFiredEvent`: `event_type: Literal["trigger_fired"]`, code `session.trigger.fired`, `visibility: REFEREE`, field `trigger_id: str`. Referee-visibility by spec: trigger internals are content wiring, the `FlagSetEvent` posture. +- `JournalEntryAddedEvent`: `event_type: Literal["journal_entry_added"]`, code `session.journal.entry_added`, `visibility: PLAYER`, fields `text: str` and `rounds: int` — the appended entry, whole. Player-visible authored text is content data in a structured field (the spec's carve-out); the code is still the machine surface. +- `NoteRecordedEvent`: `event_type: Literal["note_recorded"]`, code `session.note.recorded`, `visibility: REFEREE`, field `text: str`. +- The `session.` namespace is pinned deliberately: these are session-lifecycle bookkeeping beside `session.flag.set`, not adjudication rolls — `adjudication.` stays the dice namespace. +- All three register in `CRAWL_EVENT_CLASSES` (tail order, like the commands) and `messages.py` gains three templates (`Trigger {trigger_id} fired.`, `Journal: {text}`, `Referee note: {text}` — exact strings settled in implementation); the message-codes reference regenerates from the registry. + +### 4. Session state and handlers — `crawl/session.py` + +- `JournalEntry`, frozen, beside `DeathRecord`: `text: str` (min length 1), `rounds: int` (ge 0) — the clock position when the entry landed. Joins `__all__`; the docstring states the append-only contract and why the stamp lives on the entry (a view renders "when" without the event log). +- `GameSession.__init__` gains `self.fired_triggers: list[str] = []` and `self.journal: list[JournalEntry] = []`, documented with the other engine-owned state in the class docstring. +- Handlers at the module bottom, registered in `_REFEREE_HANDLERS` (which `_handlers()` merges): + - `_handle_mark_trigger_fired`: append `trigger_id` to `fired_triggers` only if absent; return `[TriggerFiredEvent(trigger_id=...)]` unconditionally. + - `_handle_add_journal_entry`: build `JournalEntry(text=..., rounds=session.clock.rounds)`, append, return `[JournalEntryAddedEvent(text=..., rounds=...)]`. + - `_handle_record_note`: return `[NoteRecordedEvent(text=...)]` — no mutation at all. +- No draws, no time, no interaction with the wipe check (no deaths in these events) — the handlers are total, so the command fuzzer can never make them raise. + +### 5. Persistence — `persistence.py` + +- `session_state` gains `"fired_triggers": list(session.fired_triggers)` and `"journal": [entry.model_dump(mode="json") for entry in session.journal]`; the module docstring's save-contents enumeration gains both blocks. The referee view carries them for free (it is `session_state` minus RNG and seed). +- `load_game` restores both with empty-default `get`s — `fired_triggers` as `[str(entry) for entry in payload.get("fired_triggers", [])]`, the journal as `[JournalEntry.model_validate(entry) for entry in payload.get("journal", [])]` (the `death_records` per-entry validation pattern) — so every pre-phase save loads unchanged. No migration, no `SCHEMA_VERSION` bump: new payload keys with defaults are the additive case the versioning rules name. Replay needs no code at all — the handlers rebuild both blocks by re-execution, which is the milestone. + +### 6. The journal in the player view — `crawl/views.py` + +- `PlayerView.journal: tuple[JournalEntry, ...]`, built as `tuple(session.journal)`; `views.py` imports `JournalEntry` from `crawl.session` (cycle-free, argued in the facts). The module docstring's whitelist enumeration gains the journal. +- What does not join the view, pinned by test: `fired_triggers` (trigger wiring), note texts (referee-visibility events, and the view never carries events anyway). The referee view's coverage of both blocks comes with work item 5. + +### 7. Docs and spec impacts — applied with the implementation PR + +- **`docs/spec.md` needs zero edits**: the interpreter-and-command-log and journal sections state this phase's whole contract in the present tense, and the roadmap entry exists. The `rounds` stamp on entries is below spec altitude — an implementation pin recorded here and in the `JournalEntry` docstring. +- **`docs/adaptations.md` gains no entries** — no SRD text is touched or reinterpreted anywhere in the phase; the silence is deliberate and precedented. +- **`docs/guides/sessions-commands-events.md`**: the referee-command paragraph gains the three lifecycle commands and the `source` field — the log now carries who acted *and on whose behalf*. **`docs/guides/views-and-visibility.md`**: the player-view whitelist gains the journal; the visibility examples gain the journal-entry/trigger-fired split. **`docs/guides/listeners-and-flags.md`**: the listener pattern's command vocabulary gains the three commands — a quest-tracking listener can mark, journal, and annotate through the log today, which is exactly how the library-shipped interpreter will behave when it arrives (guide prose, not docstrings, so the forward reference is fine there). +- **`CHANGELOG.md`** `[Unreleased]`, one Added bullet covering the phase: the `source` annotation on every command, the three lifecycle commands with their events and visibility, the journal and fired-marks as engine session state, the journal in the player view, and the additive persistence contract. No release work item — the bullet rides `[Unreleased]` per the cadence. + +### 8. Tests — `tests/test_journal_lifecycle.py` (new) and the goldens + +- **Command semantics**: a mark appends once and in order; a re-mark leaves `fired_triggers` unchanged and still emits; journal entries append in order with the correct `rounds` stamps (one entry before and one after an `AdvanceTime` shows different stamps); `RecordNote` leaves every state block equal (full `session_state` comparison minus the two logs); empty `text`/`trigger_id`/`source` rejected at construction. +- **Legality**: all three accepted in `game_over` and `victory` (direct mode assignment, the phase 12 posture); `REFEREE_COMMANDS` gains the three members and the resume-play census test passes with no new carve-outs; `sample_command` in `test_commands.py` and the fuzzer strategies in `test_crawl_properties.py` gain the three shapes. +- **Visibility**: `TriggerFiredEvent` and `NoteRecordedEvent` referee, `JournalEntryAddedEvent` player, asserted at the executed seam through a player-visibility filter. +- **`source`**: a stamped command round-trips through `save_game`/`load_game` with the stamp intact; execution-ignorance pinned via the twin-session state-equality test from the facts; an unstamped pre-phase logged command parses with `source=None`. +- **Views**: the whitelist test gains `journal`; the leak pins — the serialized player view contains no `fired_triggers` key and no note text after a session that used all three commands. +- **Persistence**: round-trip with populated blocks; a pre-phase save fixture (payload without the new keys) loads with empty blocks; `load(save)` equals `replay(seed, commands)` with both blocks populated — the standing equivalence guarantee now covering them. +- **The golden** — `tests/generate_phase13_goldens.py`, `tests/goldens/phase13_journal.json`, `tests/test_phase13_goldens.py`, on the phase 12 pattern (the test imports the generator's build/run). A small delve scripted the way phase 14's interpreter will drive it, with no listeners registered: a lever-pull stands in as `SetFlag` stamped `source="trigger:lever-east"`, preceded by its `MarkTriggerFired` and followed by its `AddJournalEntry`; a second mark of the same trigger id later in the run pins idempotence in the stored artifact; a `RecordNote` records a referee margin; a `GrantItem` stamped with a quest-shaped source shows the "why did the party get this" answer sitting in the log. Asserts: final `fired_triggers` and `journal` exact (stamps included), replay of the accepted log reaches identical state, `load(save)` equals `replay(seed, commands)`, and the reloaded command log carries every `source` verbatim — the milestone, verbatim. +- The full gate green: `uv sync && uv run ruff format --check && uv run ruff check && uv run pyright && uv run pytest && uv run mkdocs build --strict`. + +## Sequencing + +1. Work item 1 (`source` on the base command) with its tests and the four golden regenerations — the wire shape settles first, in one commit whose message explains the diff. +2. Work items 2–4 (commands, events, session state, handlers) with the semantics, legality, and visibility tests. +3. Work items 5 and 6 (persistence, the player view) with the round-trip, pre-phase-save, and leak tests. +4. Work items 7 and 8 remainder (docs sweep, changelog, the phase golden; the full gate on both OSes). + +## Definition of done + +- `uv sync && uv run ruff format --check && uv run ruff check && uv run pyright && uv run pytest && uv run mkdocs build --strict` green on both OSes. +- The milestone runs in the phase golden: with no listeners registered, save/load and replay rebuild `fired_triggers` and the journal exactly, and every `source` stamp survives the command log through save, load, and replay. +- No `SCHEMA_VERSION` bump and no migration; pre-phase saves load with empty blocks; the only changes to existing goldens are the four planned `"source": null` regenerations, explained in their commit message. +- The three commands clear every census and docs gate — referee legality with no new carve-outs, three-section docstrings with documented modes equal to `allowed_modes`, registered events with templates — and `rejection_codes.json` is untouched. +- The player view ships the journal verbatim and leaks neither fired-marks nor notes, pinned by test; trigger-fired and note events are referee-visibility, journal-entry events player-visible. +- No spec edit and no adaptations entry lands — both silences argued in this plan. diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index adeccde..3a41f86 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -37,6 +37,7 @@ __all__ = [ "ALL_COMMAND_CLASSES", + "AddJournalEntry", "AdvanceTime", "AnyCommand", "AwardXP", @@ -60,6 +61,7 @@ "LearnSpell", "LightSource", "ListenAtDoor", + "MarkTriggerFired", "MoveParty", "OpenDoor", "Parley", @@ -68,6 +70,7 @@ "PrepareSpells", "PurchaseEquipment", "PurchaseHealing", + "RecordNote", "RemoveTreasureTrap", "ReorderParty", "ResolveBattleRound", @@ -137,6 +140,12 @@ class Command(BaseModel): model_config = ConfigDict(frozen=True, extra="ignore") command_type: str + source: str | None = Field(default=None, min_length=1) + """An annotation naming the authored object — a trigger or quest id — or the game + system on whose behalf the command was issued. Execution never reads it: a stamped + command does exactly what the same command unstamped does. It is logged and replayed + with the command, so the log alone answers "why did this happen". Absent is `None`; + the empty string is not a value.""" allowed_modes: ClassVar[frozenset[SessionMode]] = _ALL_MODES @@ -1826,6 +1835,80 @@ def _expression_must_parse(cls, value: str) -> str: return value +class MarkTriggerFired(Command): + """Referee: record that an authored trigger has fired. + + One of the lifecycle commands a trigger-and-quest interpreter, a game's own + listener, or an LLM referee drives: the mark goes in before the trigger's + consequences issue, so fired-state is what answers once-only semantics, and it + survives save, load, and replay like any other session state. Referee commands + are legal in every mode, terminal modes included. Marking an already-marked + trigger is accepted and changes nothing: session state records that a trigger + *has* fired, while each mark in the command log records *one* firing. + + Modes: + `town`, `exploring`, `encounter`, `battle`, `game_over`, `victory` + + Rejections: + None. + + Events: + [`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent] with the + trigger id, for every mark. + """ + + command_type: Literal["mark_trigger_fired"] = "mark_trigger_fired" + trigger_id: str = Field(min_length=1) + + +class AddJournalEntry(Command): + """Referee: append an authored beat to the session journal. + + The journal is the party's record of the adventure in order of discovery: + entries append, are never rewritten, and are never derived from other state, so + a beat outlives whatever produced it. Each entry is stamped with the clock + position it landed at, and + [`PlayerView.journal`][osrlib.crawl.views.PlayerView] ships the entries as they + were written. Referee commands are legal in every mode, terminal modes included + — a closing beat lands after the adventure has concluded. + + Modes: + `town`, `exploring`, `encounter`, `battle`, `game_over`, `victory` + + Rejections: + None. + + Events: + [`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] with + the text and the clock position. + """ + + command_type: Literal["add_journal_entry"] = "add_journal_entry" + text: str = Field(min_length=1) + + +class RecordNote(Command): + """Referee: record an annotation in the logs, with no state effect at all. + + The note lands as a referee-visibility event and touches nothing: it is the + mechanism for machine-issued records — a consequence that was dropped, a + cascade that was cut short — and for a referee's own margin notes alike. + Referee commands are legal in every mode, terminal modes included. + + Modes: + `town`, `exploring`, `encounter`, `battle`, `game_over`, `victory` + + Rejections: + None. + + Events: + [`NoteRecordedEvent`][osrlib.crawl.events.NoteRecordedEvent] with the text. + """ + + command_type: Literal["record_note"] = "record_note" + text: str = Field(min_length=1) + + ALL_COMMAND_CLASSES: tuple[type[Command], ...] = ( MoveParty, TurnParty, @@ -1874,6 +1957,9 @@ def _expression_must_parse(cls, value: str) -> str: AdvanceTime, IdentifyItem, RollDice, + MarkTriggerFired, + AddJournalEntry, + RecordNote, ) """Every command class — the discriminated union's members, in a stable wire order.""" diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index c0070a3..03b866d 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -51,6 +51,7 @@ "ItemsDroppedEvent", "ItemsGivenEvent", "ItemsLeftBehindEvent", + "JournalEntryAddedEvent", "LightEvent", "ListenedEvent", "LocationEnteredEvent", @@ -58,6 +59,7 @@ "MonsterFledEvent", "MonstersLeftBehindEvent", "MonstersSpawnedEvent", + "NoteRecordedEvent", "NpcPartySpawnedEvent", "PartyMovedEvent", "ProvisionsEvent", @@ -70,6 +72,7 @@ "TimeAdvancedEvent", "TrapEvent", "TreasureSoldEvent", + "TriggerFiredEvent", "WanderingCheckEvent", "XpAwardedEvent", "parse_any_event", @@ -806,6 +809,55 @@ class DiceRolledEvent(Event): rolls: tuple[int, ...] +class TriggerFiredEvent(Event): + """An authored trigger fired (referee — trigger wiring is the game's secret). + + Emitted for every [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], + a mark of an already-fired trigger included: session state records that a + trigger has fired, and these events record each firing. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.trigger.fired"}) + + event_type: Literal["trigger_fired"] = "trigger_fired" + code: str = "session.trigger.fired" + visibility: Visibility = Visibility.REFEREE + trigger_id: str + + +class JournalEntryAddedEvent(Event): + """A beat was appended to the session journal — the whole entry, as written. + + Player-visible: the journal is written for the table. The authored `text` is + 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. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.journal.entry_added"}) + + event_type: Literal["journal_entry_added"] = "journal_entry_added" + code: str = "session.journal.entry_added" + visibility: Visibility = Visibility.PLAYER + text: str + rounds: int + + +class NoteRecordedEvent(Event): + """A referee annotation was recorded (referee — and it changes no state). + + The report of a machine-issued record — a dropped consequence, a cascade cut + short — or of a referee's own margin note. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.note.recorded"}) + + event_type: Literal["note_recorded"] = "note_recorded" + code: str = "session.note.recorded" + visibility: Visibility = Visibility.REFEREE + text: str + + CRAWL_EVENT_CLASSES: tuple[type[Event], ...] = ( PartyMovedEvent, LocationEnteredEvent, @@ -854,6 +906,9 @@ class DiceRolledEvent(Event): TimeAdvancedEvent, GameOverEvent, DiceRolledEvent, + TriggerFiredEvent, + JournalEntryAddedEvent, + NoteRecordedEvent, ) """Every crawl event class, in declaration order.""" diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index b327d7d..fe72234 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -10,8 +10,8 @@ (master seed), the [`IdAllocator`][osrlib.core.monsters.IdAllocator], the [`EffectsLedger`][osrlib.core.effects.EffectsLedger], the [`GameClock`][osrlib.core.clock.GameClock], the entity registry (characters and -live monster instances), the flag store, the listener-state store, the command -and event logs, the mode, and the crawl state. +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. `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 @@ -32,7 +32,7 @@ from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Protocol -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from osrlib.core.alignment import Alignment from osrlib.core.character import ADVANCEMENT_STREAM, Character @@ -54,6 +54,7 @@ from osrlib.core.validation import Rejection from osrlib.crawl.adventure import Adventure, _effective_equipment, _effective_monsters, validate_adventure from osrlib.crawl.commands import ( + AddJournalEntry, AdvanceTime, AwardXP, Command, @@ -61,7 +62,9 @@ GrantCoins, GrantItem, IdentifyItem, + MarkTriggerFired, PlaceParty, + RecordNote, RollDice, SessionMode, SetDoorState, @@ -77,10 +80,13 @@ FlagSetEvent, GameOverEvent, ItemAcquiredEvent, + JournalEntryAddedEvent, LightEvent, LocationEnteredEvent, MonstersSpawnedEvent, + NoteRecordedEvent, TimeAdvancedEvent, + TriggerFiredEvent, XpAwardedEvent, ) from osrlib.crawl.party import Party @@ -102,6 +108,7 @@ "ENCOUNTER_STREAM", "EXPLORATION_STREAM", "GameSession", + "JournalEntry", "LIGHT_EFFECT_KINDS", "Listener", "MONSTER_ACTION_STREAM", @@ -145,6 +152,22 @@ class DeathRecord(BaseModel): cause: str +class JournalEntry(BaseModel): + """One journal beat: the authored text and the clock position it landed at. + + The journal is append-only — entries are never rewritten and never derived from + other state — and each entry carries its own `rounds` stamp because the moment + of appending is the only moment it can be captured: a front end renders "when" + from the view alone, and a save compacted of its event log still knows when + every beat landed. + """ + + model_config = ConfigDict(frozen=True) + + text: str = Field(min_length=1) + rounds: int = Field(ge=0) + + class DefeatedMonsterRecord(BaseModel): """One defeated monster — the XP award's input.""" @@ -234,6 +257,13 @@ class GameSession: session flags. Everything that happened is on `event_log`, every accepted 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. """ def __init__( @@ -277,6 +307,8 @@ def __init__( self.monsters: dict[str, MonsterInstance] = {} self.npcs: dict[str, Character] = {} self.flags: dict[str, str | int | bool] = {} + self.fired_triggers: list[str] = [] + self.journal: list[JournalEntry] = [] self.listener_state: dict[str, dict] = {} self.listeners: list[Listener] = [] self.command_log: list[Command] = [] @@ -937,6 +969,25 @@ def _handle_set_flag(session: GameSession, command: SetFlag) -> tuple[list[Rejec return [], [FlagSetEvent(key=command.key, value=command.value)] +def _handle_mark_trigger_fired(session: GameSession, command: MarkTriggerFired) -> tuple[list[Rejection], list[Event]]: + # Fired-marks answer "has this trigger fired", so a repeat mark adds nothing; + # the event fires every time, because a repeatable trigger's every firing is + # marked and the log is the record of each one. + if command.trigger_id not in session.fired_triggers: + session.fired_triggers.append(command.trigger_id) + return [], [TriggerFiredEvent(trigger_id=command.trigger_id)] + + +def _handle_add_journal_entry(session: GameSession, command: AddJournalEntry) -> tuple[list[Rejection], list[Event]]: + entry = JournalEntry(text=command.text, rounds=session.clock.rounds) + session.journal.append(entry) + return [], [JournalEntryAddedEvent(text=entry.text, rounds=entry.rounds)] + + +def _handle_record_note(session: GameSession, command: RecordNote) -> tuple[list[Rejection], list[Event]]: + return [], [NoteRecordedEvent(text=command.text)] + + 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 @@ -1112,6 +1163,9 @@ def _handle_roll_dice(session: GameSession, command: RollDice) -> tuple[list[Rej GrantCoins: _handle_grant_coins, AwardXP: _handle_award_xp, SetFlag: _handle_set_flag, + MarkTriggerFired: _handle_mark_trigger_fired, + AddJournalEntry: _handle_add_journal_entry, + RecordNote: _handle_record_note, SpawnMonsters: _handle_spawn_monsters, SpawnNpcParty: _handle_spawn_npc_party, SetDoorState: _handle_set_door_state, diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index dc46c29..0feacc2 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -9,13 +9,15 @@ the party's light has shown it, and what its light reveals right now (secret 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 current +with remaining durations, the elapsed clock, the mode, the journal (the appended +beats verbatim, each with the clock position it landed at), 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, RNG state, or the seed — the -seed lives only in the save, and neither view carries it. +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. 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: @@ -29,6 +31,7 @@ from osrlib.core.items import MagicItemCategory, MagicItemInstance, magic_item_template from osrlib.crawl.dungeon import Direction, EdgeKind, PartyLocation, Position, cell_ref, edge_ref from osrlib.crawl.exploration import EXHAUSTED_KIND, FATIGUE_KIND, _light_reveal +from osrlib.crawl.session import JournalEntry __all__ = [ "EdgeView", @@ -152,6 +155,10 @@ class PlayerView(BaseModel): fatigued: bool exhausted: bool deprivation: dict[str, dict[str, int]] + journal: tuple[JournalEntry, ...] + """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.""" encounter: EncounterView | None = None @@ -320,6 +327,7 @@ def build_player_view(session) -> PlayerView: fatigued=fatigued, exhausted=exhausted, deprivation=deprivation, + journal=tuple(session.journal), encounter=_encounter_view(session), ) diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index 214a428..0138caf 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -347,6 +347,9 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: "adjudication.dice_rolled": lambda event: ( f"Referee rolls {event.expression}: [{', '.join(str(die) for die in event.rolls)}] — total {event.total}." ), + "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}", } diff --git a/src/osrlib/persistence.py b/src/osrlib/persistence.py index 2c9abe7..390d21b 100644 --- a/src/osrlib/persistence.py +++ b/src/osrlib/persistence.py @@ -4,10 +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, 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, 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. @@ -35,7 +36,7 @@ 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 +from osrlib.crawl.session import DeathRecord, DefeatedMonsterRecord, DeprivationState, GameSession, JournalEntry from osrlib.errors import ContentValidationError, ReplayVersionError from osrlib.versioning import SCHEMA_VERSION, check_document, engine_version, stamp_document @@ -110,6 +111,8 @@ def session_state(session: GameSession, *, include_event_log: bool = True) -> di "monsters": [instance.model_dump(mode="json") for instance in session.monsters.values()], "npcs": [npc.model_dump(mode="json") for npc in session.npcs.values()], "flags": dict(session.flags), + "fired_triggers": list(session.fired_triggers), + "journal": [entry.model_dump(mode="json") for entry in session.journal], "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], @@ -253,6 +256,10 @@ def load_game(document: Mapping[str, object]) -> GameSession: raise ContentValidationError("an NPC in the save carries no id") session.npcs[npc.id] = npc session.flags = dict(payload["flags"]) + # Both blocks arrived after schema version 3 and load with empty defaults, so + # 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", [])] 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/generate_phase13_goldens.py b/tests/generate_phase13_goldens.py new file mode 100644 index 0000000..e23b7c3 --- /dev/null +++ b/tests/generate_phase13_goldens.py @@ -0,0 +1,142 @@ +"""Generate the phase 13 golden: the journal a trigger layer would write. + +One golden file, `phase13_journal.json` — a short delve driven the way an authored +trigger and quest layer drives one, with no listeners registered. The party steps to +the east lever: the pull is a `MarkTriggerFired` and a `SetFlag` stamped with the +trigger's own id, and the beat it leaves behind is an `AddJournalEntry` stamped the +same way. A turn's breather later the party hauls the same lever again — the re-mark +appends nothing to the fired-marks and still writes its second journal beat, stamped a +turn further on — a `RecordNote` puts the referee's margin on the record, and a `GrantItem` +stamped with a quest id shows "why did the party get this" sitting in the log. + +The milestone the file records: save/load and replay rebuild the journal and the +fired-marks exactly, and every `source` stamp survives the command log through both. + +Run `uv run python tests/generate_phase13_goldens.py` and explain any golden change +in the commit message. +""" + +import json +from pathlib import Path + +from crawl_fixtures import build_adventure, build_party +from osrlib.core.events import Event +from osrlib.crawl.commands import ( + AddJournalEntry, + Command, + EnterDungeon, + GrantItem, + MarkTriggerFired, + MoveParty, + RecordNote, + Rest, + SetFlag, + parse_command, +) +from osrlib.crawl.dungeon import Direction +from osrlib.crawl.session import GameSession +from osrlib.messages import format_message + +GOLDEN_PATH = Path(__file__).parent / "goldens" / "phase13_journal.json" +SEED = 20_260_807 + +LEVER = "trigger:lever-east" +QUEST = "quest:the-jade-idol" + +# Enter, walk to the lever, pull it; breathe, pull it again; note the margin; pay out. +SCRIPT: tuple[Command, ...] = ( + EnterDungeon(dungeon_id="delve"), + MoveParty(direction=Direction.EAST), + MarkTriggerFired(trigger_id="lever-east", source=LEVER), + SetFlag(key="crypt.portcullis", value="open", source=LEVER), + AddJournalEntry(text="The east lever gives; somewhere below, a portcullis grinds upward.", source=LEVER), + Rest(kind="turn"), + MarkTriggerFired(trigger_id="lever-east", source=LEVER), + AddJournalEntry(text="The lever, hauled a second time, only grinds.", source=LEVER), + RecordNote(text="The portcullis was already up: the second pull changed nothing."), + GrantItem(character_id="character-0001", item_id="silver_dagger", quantity=1, source=QUEST), +) + + +def new_session(seed: int) -> GameSession: + """The scenario's session: the stock party at the test delve's entrance.""" + return GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=seed) + + +def snapshot(session: GameSession) -> dict: + """The end state: draws, time, and the two blocks this phase exists for.""" + 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), + "journal": [entry.model_dump(mode="json") for entry in session.journal], + } + + +def run_scenario(seed: int) -> GameSession: + """Play the script, asserting the beats the golden exists to record. + + Returns: + The finished session, its journal written and its lever marked once. + + Raises: + RuntimeError: If a command is refused, if the re-mark appends a second + fired-mark, or if the two beats carry the same clock stamp. + """ + session = new_session(seed) + for command in SCRIPT: + result = session.execute(command) + if not result.accepted: + codes = [rejection.code for rejection in result.rejections] + raise RuntimeError(f"{command.command_type} was refused with {codes}") + if session.fired_triggers != ["lever-east"]: + raise RuntimeError(f"the re-mark appended a duplicate: {session.fired_triggers}") + if len(session.journal) != 2: + raise RuntimeError(f"the run wrote {len(session.journal)} journal entries, not 2") + if session.journal[0].rounds == session.journal[1].rounds: + raise RuntimeError("the two beats must straddle the turn between them") + return session + + +def replay_scenario(seed: int, commands) -> GameSession: + """Replay the accepted-command log — the stamped commands included.""" + session = new_session(seed) + 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 = run_scenario(seed) + transcript = [format_message(entry) for entry in session.event_log if isinstance(entry, Event)] + return { + "master_seed": seed, + "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, entries = len(golden["command_log"]), len(golden["final_state"]["journal"]) + print(f"wrote {GOLDEN_PATH} from seed {SEED} ({commands} commands, {entries} journal entries)") + + +if __name__ == "__main__": + main() diff --git a/tests/goldens/phase11_gates.json b/tests/goldens/phase11_gates.json index e221236..81fd174 100644 --- a/tests/goldens/phase11_gates.json +++ b/tests/goldens/phase11_gates.json @@ -2,16 +2,19 @@ "command_log": [ { "command_type": "enter_dungeon", - "dungeon_id": "warren" + "dungeon_id": "warren", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "take_treasure", "feature_id": "niche", - "recipient_id": null + "recipient_id": null, + "source": null }, { "character_id": "character-0001", @@ -26,15 +29,18 @@ "item_ids": [ "brass_key" ], - "recipient_id": "character-0004" + "recipient_id": "character-0004", + "source": null }, { "command_type": "open_door", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "set_door_state", @@ -43,6 +49,7 @@ "dungeon_id": "warren", "level_number": 1, "open": null, + "source": null, "unlocked": true, "wedged": null, "x": 2, @@ -50,26 +57,32 @@ }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { - "command_type": "use_stairs" + "command_type": "use_stairs", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "take_treasure", "feature_id": "vault", - "recipient_id": null + "recipient_id": null, + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { - "command_type": "use_stairs" + "command_type": "use_stairs", + "source": null }, { "command_type": "set_door_state", @@ -78,6 +91,7 @@ "dungeon_id": "warren", "level_number": 1, "open": true, + "source": null, "unlocked": null, "wedged": null, "x": 3, @@ -85,27 +99,33 @@ }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "close_door", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "open_door", - "direction": "south" + "direction": "south", + "source": null }, { "command_type": "move_party", - "direction": "south" + "direction": "south", + "source": null } ], "event_log": [ @@ -379,7 +399,8 @@ "code": "exploration.door.gate_refused", "command": { "command_type": "open_door", - "direction": "east" + "direction": "east", + "source": null }, "params": { "direction": "east", @@ -391,7 +412,8 @@ "code": "exploration.door.locked", "command": { "command_type": "open_door", - "direction": "south" + "direction": "south", + "source": null }, "params": {} }, @@ -400,7 +422,8 @@ "code": "exploration.door.gate_refused", "command": { "command_type": "open_door", - "direction": "south" + "direction": "south", + "source": null }, "params": { "direction": "south", @@ -411,7 +434,8 @@ "after_commands": 13, "code": "exploration.transition.gate_refused", "command": { - "command_type": "use_stairs" + "command_type": "use_stairs", + "source": null }, "params": { "refusal": "The ferryman's hand stays out, empty. No token, no crossing." @@ -422,7 +446,8 @@ "code": "exploration.door.gate_refused", "command": { "command_type": "open_door", - "direction": "east" + "direction": "east", + "source": null }, "params": { "direction": "east", @@ -434,7 +459,8 @@ "code": "exploration.move.blocked", "command": { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, "params": { "direction": "east" @@ -445,7 +471,8 @@ "code": "exploration.door.gate_refused", "command": { "command_type": "open_door", - "direction": "east" + "direction": "east", + "source": null }, "params": { "direction": "east", diff --git a/tests/goldens/phase12_wipe.json b/tests/goldens/phase12_wipe.json index 607e468..92f20ec 100644 --- a/tests/goldens/phase12_wipe.json +++ b/tests/goldens/phase12_wipe.json @@ -2,19 +2,23 @@ "command_log": [ { "command_type": "enter_dungeon", - "dungeon_id": "vault" + "dungeon_id": "vault", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "roll_dice", - "expression": "2d6" + "expression": "2d6", + "source": null }, { "command_type": "set_flag", "key": "fume_vault_epitaph", + "source": null, "value": "four went in" } ], diff --git a/tests/goldens/phase13_journal.json b/tests/goldens/phase13_journal.json new file mode 100644 index 0000000..54a0258 --- /dev/null +++ b/tests/goldens/phase13_journal.json @@ -0,0 +1,180 @@ +{ + "command_log": [ + { + "command_type": "enter_dungeon", + "dungeon_id": "delve", + "source": null + }, + { + "command_type": "move_party", + "direction": "east", + "source": null + }, + { + "command_type": "mark_trigger_fired", + "source": "trigger:lever-east", + "trigger_id": "lever-east" + }, + { + "command_type": "set_flag", + "key": "crypt.portcullis", + "source": "trigger:lever-east", + "value": "open" + }, + { + "command_type": "add_journal_entry", + "source": "trigger:lever-east", + "text": "The east lever gives; somewhere below, a portcullis grinds upward." + }, + { + "command_type": "rest", + "kind": "turn", + "source": null + }, + { + "command_type": "mark_trigger_fired", + "source": "trigger:lever-east", + "trigger_id": "lever-east" + }, + { + "command_type": "add_journal_entry", + "source": "trigger:lever-east", + "text": "The lever, hauled a second time, only grinds." + }, + { + "command_type": "record_note", + "source": null, + "text": "The portcullis was already up: the second pull changed nothing." + }, + { + "character_id": "character-0001", + "command_type": "grant_item", + "item_id": "silver_dagger", + "quantity": 1, + "source": "quest:the-jade-idol" + } + ], + "event_log": [ + { + "code": "exploration.location.entered", + "event_type": "location_entered", + "level_number": 1, + "location_id": "delve", + "location_kind": "dungeon", + "narrative": null, + "visibility": "player" + }, + { + "code": "exploration.party.moved", + "event_type": "party_moved", + "facing": "east", + "visibility": "player", + "x": 1, + "y": 0 + }, + { + "code": "session.trigger.fired", + "event_type": "trigger_fired", + "trigger_id": "lever-east", + "visibility": "referee" + }, + { + "code": "session.flag.set", + "event_type": "flag_set", + "key": "crypt.portcullis", + "value": "open", + "visibility": "referee" + }, + { + "code": "session.journal.entry_added", + "event_type": "journal_entry_added", + "rounds": 360, + "text": "The east lever gives; somewhere below, a portcullis grinds upward.", + "visibility": "player" + }, + { + "code": "exploration.rest.rested", + "event_type": "rested", + "kind": "turn", + "visibility": "player" + }, + { + "code": "session.trigger.fired", + "event_type": "trigger_fired", + "trigger_id": "lever-east", + "visibility": "referee" + }, + { + "code": "session.journal.entry_added", + "event_type": "journal_entry_added", + "rounds": 420, + "text": "The lever, hauled a second time, only grinds.", + "visibility": "player" + }, + { + "code": "session.note.recorded", + "event_type": "note_recorded", + "text": "The portcullis was already up: the second pull changed nothing.", + "visibility": "referee" + }, + { + "character_id": "character-0001", + "code": "exploration.item.acquired", + "coins_gp_value": 0, + "event_type": "item_acquired", + "item_ids": [ + "silver_dagger" + ], + "visibility": "player" + } + ], + "final_state": { + "clock_rounds": 420, + "fired_triggers": [ + "lever-east" + ], + "flags": { + "crypt.portcullis": "open" + }, + "journal": [ + { + "rounds": 360, + "text": "The east lever gives; somewhere below, a portcullis grinds upward." + }, + { + "rounds": 420, + "text": "The lever, hauled a second time, only grinds." + } + ], + "location": { + "dungeon_id": "delve", + "facing": "east", + "kind": "dungeon", + "level_number": 1, + "position": [ + 1, + 0 + ] + }, + "mode": "exploring", + "streams": { + "effects": { + "inc": 259550629418758129033878154670639456931, + "state": 82891674844845924114032831778237792946 + } + } + }, + "master_seed": 20260807, + "transcript": [ + "The party enters dungeon delve (level 1).", + "The party moves to (1, 0), facing east.", + "Trigger lever-east fired.", + "Flag crypt.portcullis = 'open'.", + "Journal: The east lever gives; somewhere below, a portcullis grinds upward.", + "The party rests (turn).", + "Trigger lever-east fired.", + "Journal: The lever, hauled a second time, only grinds.", + "Referee note: The portcullis was already up: the second pull changed nothing.", + "character-0001 acquires silver_dagger." + ] +} diff --git a/tests/goldens/phase4_delve.json b/tests/goldens/phase4_delve.json index ac1ef18..dfc827f 100644 --- a/tests/goldens/phase4_delve.json +++ b/tests/goldens/phase4_delve.json @@ -14,7 +14,8 @@ "pp": 0, "sp": 0 }, - "command_type": "grant_coins" + "command_type": "grant_coins", + "source": null }, { "character_id": "character-0001", @@ -30,7 +31,8 @@ "iron_spikes", "rations_standard", "waterskin" - ] + ], + "source": null }, { "character_id": "character-0002", @@ -41,7 +43,8 @@ "pp": 0, "sp": 0 }, - "command_type": "grant_coins" + "command_type": "grant_coins", + "source": null }, { "character_id": "character-0002", @@ -51,7 +54,8 @@ "chainmail", "rations_standard", "waterskin" - ] + ], + "source": null }, { "character_id": "character-0003", @@ -62,7 +66,8 @@ "pp": 0, "sp": 0 }, - "command_type": "grant_coins" + "command_type": "grant_coins", + "source": null }, { "character_id": "character-0003", @@ -72,7 +77,8 @@ "leather", "rations_standard", "waterskin" - ] + ], + "source": null }, { "character_id": "character-0004", @@ -83,7 +89,8 @@ "pp": 0, "sp": 0 }, - "command_type": "grant_coins" + "command_type": "grant_coins", + "source": null }, { "character_id": "character-0004", @@ -93,7 +100,8 @@ "chainmail", "rations_standard", "waterskin" - ] + ], + "source": null }, { "character_id": "character-0005", @@ -104,7 +112,8 @@ "pp": 0, "sp": 0 }, - "command_type": "grant_coins" + "command_type": "grant_coins", + "source": null }, { "character_id": "character-0005", @@ -113,147 +122,178 @@ "dagger", "rations_standard", "waterskin" - ] + ], + "source": null }, { "character_id": "character-0001", "command_type": "equip_item", - "item_id": "sword" + "item_id": "sword", + "source": null }, { "character_id": "character-0001", "command_type": "equip_item", - "item_id": "chainmail" + "item_id": "chainmail", + "source": null }, { "character_id": "character-0002", "command_type": "equip_item", - "item_id": "sword" + "item_id": "sword", + "source": null }, { "character_id": "character-0002", "command_type": "equip_item", - "item_id": "chainmail" + "item_id": "chainmail", + "source": null }, { "character_id": "character-0003", "command_type": "equip_item", - "item_id": "sword" + "item_id": "sword", + "source": null }, { "character_id": "character-0003", "command_type": "equip_item", - "item_id": "leather" + "item_id": "leather", + "source": null }, { "character_id": "character-0004", "command_type": "equip_item", - "item_id": "mace" + "item_id": "mace", + "source": null }, { "character_id": "character-0004", "command_type": "equip_item", - "item_id": "chainmail" + "item_id": "chainmail", + "source": null }, { "character_id": "character-0005", "command_type": "equip_item", - "item_id": "dagger" + "item_id": "dagger", + "source": null }, { "command_type": "enter_dungeon", - "dungeon_id": "halls" + "dungeon_id": "halls", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "south" + "direction": "south", + "source": null }, { "command_type": "move_party", - "direction": "north" + "direction": "north", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "character_id": "character-0003", "command_type": "listen_at_door", - "direction": "south" + "direction": "south", + "source": null }, { "character_id": "character-0001", "command_type": "force_door", - "direction": "south" + "direction": "south", + "source": null }, { "character_id": "character-0001", "command_type": "force_door", - "direction": "south" + "direction": "south", + "source": null }, { "character_id": "character-0001", "command_type": "force_door", - "direction": "south" + "direction": "south", + "source": null }, { "command_type": "wedge_door", - "direction": "south" + "direction": "south", + "source": null }, { "command_type": "move_party", - "direction": "south" + "direction": "south", + "source": null }, { - "command_type": "engage_battle" + "command_type": "engage_battle", + "source": null }, { "command_type": "resolve_battle_round", @@ -318,7 +358,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -385,7 +426,8 @@ ], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -452,7 +494,8 @@ ], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -517,7 +560,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -582,7 +626,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -647,21 +692,25 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "take_treasure", "feature_id": "coffer", - "recipient_id": null + "recipient_id": null, + "source": null }, { "character_id": "character-0002", "command_type": "search", - "kind": "secret_doors" + "kind": "secret_doors", + "source": null }, { "command_type": "resolve_battle_round", @@ -726,7 +775,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -791,7 +841,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -856,7 +907,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -921,12 +973,14 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "character_id": "character-0005", "command_type": "search", - "kind": "secret_doors" + "kind": "secret_doors", + "source": null }, { "command_type": "reorder_party", @@ -936,38 +990,47 @@ "character-0002", "character-0003", "character-0004" - ] + ], + "source": null }, { "command_type": "open_door", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "wedge_door", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { - "command_type": "use_stairs" + "command_type": "use_stairs", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "open_door", - "direction": "south" + "direction": "south", + "source": null }, { "command_type": "move_party", - "direction": "south" + "direction": "south", + "source": null }, { "command_type": "resolve_battle_round", @@ -1032,7 +1095,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -1097,7 +1161,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -1162,27 +1227,33 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "move_party", - "direction": "north" + "direction": "north", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "south" + "direction": "south", + "source": null }, { "command_type": "evade", - "drop": "none" + "drop": "none", + "source": null }, { "character_id": "character-0001", @@ -1194,7 +1265,8 @@ "sp": 0 }, "command_type": "drop_items", - "item_ids": [] + "item_ids": [], + "source": null }, { "character_id": "character-0001", @@ -1206,132 +1278,171 @@ "sp": 0 }, "command_type": "drop_items", - "item_ids": [] + "item_ids": [], + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "command_type": "move_party", - "direction": "north" + "direction": "north", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "south" + "direction": "south", + "source": null }, { "command_type": "evade", - "drop": "none" + "drop": "none", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { - "command_type": "wait" + "command_type": "wait", + "source": null }, { "command_type": "move_party", - "direction": "north" + "direction": "north", + "source": null }, { "command_type": "rest", - "kind": "night" + "kind": "night", + "source": null }, { "character_id": "character-0005", @@ -1357,167 +1468,207 @@ "reversed": false, "spell_id": "fire_ball" } - ] + ], + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { - "command_type": "use_stairs" + "command_type": "use_stairs", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "north" + "direction": "north", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { - "command_type": "engage_battle" + "command_type": "engage_battle", + "source": null }, { "command_type": "resolve_battle_round", @@ -1582,44 +1733,54 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "character_id": "character-0001", "command_type": "light_source", - "item_id": "torch" + "item_id": "torch", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { "command_type": "rest", - "kind": "turn" + "kind": "turn", + "source": null }, { - "command_type": "engage_battle" + "command_type": "engage_battle", + "source": null }, { "command_type": "resolve_battle_round", @@ -1684,7 +1845,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -1751,7 +1913,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -1820,7 +1983,8 @@ ], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -1885,10 +2049,12 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { - "command_type": "travel_to_town" + "command_type": "travel_to_town", + "source": null } ], "defeated_monsters": [], diff --git a/tests/goldens/phase5_milestone.json b/tests/goldens/phase5_milestone.json index 038d8a0..5b251ae 100644 --- a/tests/goldens/phase5_milestone.json +++ b/tests/goldens/phase5_milestone.json @@ -6,18 +6,22 @@ "command_log": [ { "command_type": "enter_dungeon", - "dungeon_id": "barrow" + "dungeon_id": "barrow", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { - "command_type": "engage_battle" + "command_type": "engage_battle", + "source": null }, { "command_type": "resolve_battle_round", @@ -70,7 +74,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -123,7 +128,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -176,7 +182,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -229,7 +236,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -282,7 +290,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -335,25 +344,30 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "take_treasure", "feature_id": "cache-0001", - "recipient_id": null + "recipient_id": null, + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "take_treasure", "feature_id": "idol_shrine", - "recipient_id": null + "recipient_id": null, + "source": null }, { "character_id": "character-0002", @@ -364,26 +378,32 @@ "pp": 0, "sp": 0 }, - "command_type": "grant_coins" + "command_type": "grant_coins", + "source": null }, { - "command_type": "use_stairs" + "command_type": "use_stairs", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "move_party", - "direction": "east" + "direction": "east", + "source": null }, { "command_type": "take_treasure", "feature_id": "cache-0002", - "recipient_id": null + "recipient_id": null, + "source": null }, { - "command_type": "engage_battle" + "command_type": "engage_battle", + "source": null }, { "command_type": "resolve_battle_round", @@ -436,7 +456,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -489,7 +510,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -542,7 +564,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -595,12 +618,14 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "take_treasure", "feature_id": "pile", - "recipient_id": null + "recipient_id": null, + "source": null }, { "command_type": "resolve_battle_round", @@ -653,7 +678,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -706,7 +732,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -759,7 +786,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -812,7 +840,8 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "resolve_battle_round", @@ -865,74 +894,90 @@ "targets": [], "weapon_id": null } - ] + ], + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { - "command_type": "use_stairs" + "command_type": "use_stairs", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { "command_type": "move_party", - "direction": "west" + "direction": "west", + "source": null }, { - "command_type": "travel_to_town" + "command_type": "travel_to_town", + "source": null }, { "command_type": "set_flag", "key": "quest.idol", + "source": null, "value": "recovered" }, { "amount": 600, "character_id": "character-0001", - "command_type": "award_xp" + "command_type": "award_xp", + "source": null }, { "amount": 600, "character_id": "character-0002", - "command_type": "award_xp" + "command_type": "award_xp", + "source": null }, { "amount": 600, "character_id": "character-0003", - "command_type": "award_xp" + "command_type": "award_xp", + "source": null }, { "amount": 600, "character_id": "character-0004", - "command_type": "award_xp" + "command_type": "award_xp", + "source": null }, { "command_type": "sell_treasure", "item_ids": [ "valuable-0001", "valuable-0002" - ] + ], + "source": null }, { "character_id": "character-0001", "command_type": "purchase_healing", - "service": "cure_light_wounds" + "service": "cure_light_wounds", + "source": null } ], "defeated_monsters": [], diff --git a/tests/test_commands.py b/tests/test_commands.py index cf19e76..cbedcaa 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -30,6 +30,9 @@ "advance_time", "identify_item", "roll_dice", + "mark_trigger_fired", + "add_journal_entry", + "record_note", } # The referee commands a terminal session withholds, each because it would resume @@ -94,6 +97,9 @@ def sample_command(command_class): "SellTreasure": dict(item_ids=("valuable-0001",)), "PurchaseHealing": dict(character_id="character-0001", service="cure_light_wounds"), "RollDice": dict(expression="2d6"), + "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."), } return command_class(**samples[command_class.__name__]) diff --git a/tests/test_crawl_properties.py b/tests/test_crawl_properties.py index 323fb0b..95a4000 100644 --- a/tests/test_crawl_properties.py +++ b/tests/test_crawl_properties.py @@ -165,6 +165,20 @@ def command_strategy(): fields[field_name] = st.sampled_from(["hd_budget", "damage", "illuminate"]) elif field_name == "value": fields[field_name] = st.sampled_from([True, 7, "open"]) + 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. + fields[field_name] = st.sampled_from(["lever-east", "lever-east", "idol-lifted", "no-such-trigger"]) + elif field_name == "text": + # Journal beats reach the player view verbatim, so these strings must + # avoid every substring the leak tests below search the view blob for. + fields[field_name] = st.sampled_from( + ["The lever grinds.", "A consequence was dropped.", "Nothing at all happens."] + ) + elif field_name == "source": + # The stamp the trigger and quest layers put on the commands they + # issue: the fuzz drives both stamped and unstamped shapes. + fields[field_name] = st.sampled_from([None, "trigger:lever-east", "quest:the_idol"]) elif field_name == "amount" or field_name == "n": fields[field_name] = st.integers(min_value=0, max_value=100) elif field_name == "quantity": diff --git a/tests/test_events_kernel.py b/tests/test_events_kernel.py index 91c410d..a9c3601 100644 --- a/tests/test_events_kernel.py +++ b/tests/test_events_kernel.py @@ -149,6 +149,9 @@ def sample_event(event_class, code): "AdventureXpAwardEvent": dict(monster_xp=100, treasure_xp=250, share=87, survivors=("character-0001",)), "TreasureSoldEvent": dict(character_id="character-0001", instance_ids=("valuable-0001",), gp_value=500), "HealingPurchasedEvent": dict(character_id="character-0001", service="cure_light_wounds", cost_gp=25), + "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."), } return event_class(code=code, **samples[event_class.__name__]) diff --git a/tests/test_journal_lifecycle.py b/tests/test_journal_lifecycle.py new file mode 100644 index 0000000..ae6e7ee --- /dev/null +++ b/tests/test_journal_lifecycle.py @@ -0,0 +1,297 @@ +"""The lifecycle commands, the journal, the fired-marks, and the `source` stamp. + +Three commands write the bookkeeping an authored trigger or quest layer needs: +`MarkTriggerFired` records fired-state, `AddJournalEntry` appends a beat, and +`RecordNote` records an annotation and changes nothing. None of them draws a die +or spends a round, all three are legal in every mode, and the two that mutate write +nothing but their own block. + +The `source` stamp is an annotation and nothing else: a stamped command executes +exactly as the same command unstamped does, and the only trace it leaves is on the +logged command itself, where a save, a load, and a replay all carry it verbatim. +""" + +import json + +import pytest +from pydantic import ValidationError + +from crawl_fixtures import build_adventure, build_party +from osrlib.core.events import Visibility +from osrlib.crawl.commands import ( + AddJournalEntry, + AdvanceTime, + Command, + EnterDungeon, + MarkTriggerFired, + MoveParty, + RecordNote, + RollDice, + SessionMode, + SetFlag, + parse_command, +) +from osrlib.crawl.dungeon import Direction +from osrlib.crawl.events import JournalEntryAddedEvent, NoteRecordedEvent, TriggerFiredEvent +from osrlib.crawl.session import GameSession, JournalEntry +from osrlib.persistence import load_game, save_game, session_state + +TERMINAL_MODES = (SessionMode.GAME_OVER, SessionMode.VICTORY) + +STAMP = "trigger:lever-east" + +# One command of each temper: a mode switch, a move that draws and spends time, a +# referee roll on its own stream, a flag write, and a span of clock. +SCRIPT: tuple[Command, ...] = ( + EnterDungeon(dungeon_id="delve"), + MoveParty(direction=Direction.EAST), + RollDice(expression="2d6"), + SetFlag(key="portcullis", value="open"), + AdvanceTime(n=2, unit="turn"), +) + + +def make_session(seed: int = 17) -> GameSession: + return GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=seed) + + +def run(commands) -> GameSession: + session = make_session() + for command in commands: + result = session.execute(command) + assert result.accepted, [rejection.code for rejection in result.rejections] + return session + + +def stream_states(session: GameSession) -> dict: + return {key: state.model_dump(mode="json") for key, state in session.streams.export_states().items()} + + +def state_but_the_logs(session: GameSession) -> dict: + """Every state block, with the two logs (which every command touches) removed.""" + state = session_state(session) + state.pop("command_log") + state.pop("event_log") + return state + + +class TestMarkTriggerFired: + def test_marks_append_in_first_fired_order(self): + session = run( + [ + MarkTriggerFired(trigger_id="lever-east"), + MarkTriggerFired(trigger_id="idol-lifted"), + MarkTriggerFired(trigger_id="gate-opened"), + ] + ) + assert session.fired_triggers == ["lever-east", "idol-lifted", "gate-opened"] + + def test_a_re_mark_appends_nothing_and_still_reports_the_firing(self): + session = run([MarkTriggerFired(trigger_id="lever-east"), MarkTriggerFired(trigger_id="idol-lifted")]) + before = state_but_the_logs(session) + result = session.execute(MarkTriggerFired(trigger_id="lever-east")) + assert result.accepted + assert session.fired_triggers == ["lever-east", "idol-lifted"], "state records that a trigger has fired" + assert state_but_the_logs(session) == before + events = [event for event in result.events if isinstance(event, TriggerFiredEvent)] + assert [event.trigger_id for event in events] == ["lever-east"], "and the log records each firing" + + def test_a_mark_draws_nothing_and_spends_no_time(self): + session = run([EnterDungeon(dungeon_id="delve")]) + before, rounds = stream_states(session), session.clock.rounds + assert session.execute(MarkTriggerFired(trigger_id="lever-east")).accepted + assert stream_states(session) == before + assert session.clock.rounds == rounds + + def test_the_trigger_id_is_open_domain_but_never_empty(self): + session = run([MarkTriggerFired(trigger_id="no-such-trigger-anywhere")]) + assert session.fired_triggers == ["no-such-trigger-anywhere"] + with pytest.raises(ValidationError): + MarkTriggerFired(trigger_id="") + + +class TestAddJournalEntry: + def test_entries_append_in_order_stamped_with_the_clock(self): + session = run( + [ + AddJournalEntry(text="The lever grinds; somewhere below, a portcullis rises."), + AdvanceTime(n=2, unit="turn"), + AddJournalEntry(text="The idol is lighter than it looks."), + ] + ) + assert session.journal == [ + JournalEntry(text="The lever grinds; somewhere below, a portcullis rises.", rounds=0), + JournalEntry(text="The idol is lighter than it looks.", rounds=120), + ] + assert session.clock.rounds == 120, "the entries stamp the clock, they do not move it" + + def test_the_same_beat_twice_appends_twice(self): + text = "The door refuses." + session = run([AddJournalEntry(text=text), AddJournalEntry(text=text)]) + assert [entry.text for entry in session.journal] == [text, text] + + def test_an_entry_draws_nothing_and_spends_no_time(self): + session = run([EnterDungeon(dungeon_id="delve")]) + before, rounds = stream_states(session), session.clock.rounds + assert session.execute(AddJournalEntry(text="Down the stair, into the dark.")).accepted + assert stream_states(session) == before + assert session.clock.rounds == rounds + + def test_an_empty_beat_is_not_a_beat(self): + with pytest.raises(ValidationError): + AddJournalEntry(text="") + + +class TestRecordNote: + def test_a_note_changes_no_state_at_all(self): + session = run([MarkTriggerFired(trigger_id="lever-east"), AddJournalEntry(text="The lever grinds.")]) + before = state_but_the_logs(session) + result = session.execute(RecordNote(text="The second consequence was dropped: no such item.")) + assert result.accepted + assert state_but_the_logs(session) == before + assert [event.text for event in result.events if isinstance(event, NoteRecordedEvent)] == [ + "The second consequence was dropped: no such item." + ] + + def test_an_empty_note_is_not_a_note(self): + with pytest.raises(ValidationError): + RecordNote(text="") + + +class TestLegality: + @pytest.mark.parametrize("mode", TERMINAL_MODES, ids=lambda mode: mode.value) + def test_all_three_execute_in_a_terminal_mode(self, mode): + session = make_session() + session.mode = mode + commands = ( + MarkTriggerFired(trigger_id="idol-returned"), + AddJournalEntry(text="The idol sits on the altar where it began."), + RecordNote(text="The reward landed after the ending."), + ) + for command in commands: + assert session.execute(command).accepted, command.command_type + assert session.mode is mode + assert len(session.command_log) == len(commands) + assert session.fired_triggers == ["idol-returned"] + assert [entry.text for entry in session.journal] == ["The idol sits on the altar where it began."] + + +class TestVisibility: + def test_the_journal_is_for_the_table_and_the_wiring_is_not(self): + session = make_session() + # Off the zero mark first, so the beat's clock stamp is a real number rather + # than the value an unstamped field would carry anyway. The span runs before + # the three commands, so what they emit is all this test collects. + assert session.execute(AdvanceTime(n=2, unit="turn")).accepted + emitted = [] + for command in ( + MarkTriggerFired(trigger_id="lever-east"), + AddJournalEntry(text="The lever grinds; somewhere below, a portcullis rises."), + RecordNote(text="The east lever is the only one that answers."), + ): + result = session.execute(command) + assert result.accepted + emitted.extend(result.events) + player = [event for event in emitted if event.visibility is Visibility.PLAYER] + referee = [event for event in emitted if event.visibility is Visibility.REFEREE] + assert [type(event) for event in player] == [JournalEntryAddedEvent] + assert [type(event) for event in referee] == [TriggerFiredEvent, NoteRecordedEvent] + assert player[0].text == "The lever grinds; somewhere below, a portcullis rises." + assert player[0].rounds == session.clock.rounds == 120 + + +class TestPersistenceAndTheView: + """Both blocks are ordinary session state: saved, loaded, replayed, projected.""" + + LIFECYCLE: tuple[Command, ...] = ( + MarkTriggerFired(trigger_id="lever-east"), + SetFlag(key="portcullis", value="open", source="trigger:lever-east"), + AddJournalEntry(text="The lever grinds; somewhere below, a portcullis rises.", source="trigger:lever-east"), + AdvanceTime(n=1, unit="turn"), + MarkTriggerFired(trigger_id="lever-east"), + AddJournalEntry(text="The same lever, the same grinding.", source="trigger:lever-east"), + RecordNote(text="The east lever is the only one that answers."), + ) + + def test_both_blocks_round_trip_through_a_save(self): + session = run(self.LIFECYCLE) + restored = load_game(json.loads(json.dumps(save_game(session)))) + assert restored.fired_triggers == ["lever-east"] + assert restored.journal == session.journal + assert [entry.rounds for entry in restored.journal] == [0, 60] + assert session_state(restored) == session_state(session) + + def test_a_save_written_without_the_blocks_loads_empty_and_starts_remembering(self): + document = json.loads(json.dumps(save_game(run([EnterDungeon(dungeon_id="delve")])))) + del document["payload"]["fired_triggers"] + del document["payload"]["journal"] + restored = load_game(document) + assert restored.fired_triggers == [] + assert restored.journal == [] + assert restored.execute(MarkTriggerFired(trigger_id="lever-east")).accepted + assert restored.execute(AddJournalEntry(text="The lever grinds.")).accepted + assert restored.fired_triggers == ["lever-east"] + assert [entry.text for entry in restored.journal] == ["The lever grinds."] + + def test_load_equals_replay_with_both_blocks_populated(self): + from osrlib.core.character import party_to_document + from osrlib.core.ruleset import Ruleset + from osrlib.persistence import replay_game + + session = run(self.LIFECYCLE) + restored = load_game(json.loads(json.dumps(save_game(session)))) + replayed = replay_game( + 17, + party_to_document(build_party().members), + build_adventure(wandering_chance=0), + Ruleset(), + [command.model_dump(mode="json") for command in session.command_log], + ) + assert replayed.fired_triggers == ["lever-east"] + assert replayed.journal == session.journal + assert session_state(replayed) == session_state(restored) + + def test_the_player_view_ships_the_journal_and_neither_the_marks_nor_the_notes(self): + session = run(self.LIFECYCLE) + view = session.view(Visibility.PLAYER) + assert view.journal == tuple(session.journal) + blob = view.model_dump_json() + parsed = json.loads(blob) + assert "fired_triggers" not in parsed + assert "lever-east" not in blob, "the trigger id is wiring, and wiring is the game's secret" + assert "The east lever is the only one that answers." not in blob, "a referee note is not for the table" + assert "The lever grinds; somewhere below, a portcullis rises." in blob + + def test_the_referee_view_carries_both_blocks(self): + session = run(self.LIFECYCLE) + state = session.view(Visibility.REFEREE).state + assert state["fired_triggers"] == ["lever-east"] + assert [entry["text"] for entry in state["journal"]] == [entry.text for entry in session.journal] + + +class TestTheSourceStamp: + def test_a_stamped_command_survives_save_and_load(self): + session = run([command.model_copy(update={"source": STAMP}) for command in SCRIPT]) + restored = load_game(json.loads(json.dumps(save_game(session)))) + assert [command.source for command in restored.command_log] == [STAMP] * len(SCRIPT) + assert session_state(restored) == session_state(session) + + def test_execution_ignores_the_stamp(self): + stamped = run([command.model_copy(update={"source": STAMP}) for command in SCRIPT]) + plain = run(SCRIPT) + stamped_state = session_state(stamped) + plain_state = session_state(plain) + logged = stamped_state.pop("command_log"), plain_state.pop("command_log") + assert stamped_state == plain_state, "the stamp annotates the log and changes nothing else" + assert [entry.pop("source") for entry in logged[0]] == [STAMP] * len(SCRIPT) + assert [entry.pop("source") for entry in logged[1]] == [None] * len(SCRIPT) + assert logged[0] == logged[1] + + def test_a_command_logged_without_a_stamp_parses_as_unstamped(self): + command = parse_command({"command_type": "set_flag", "key": "portcullis", "value": "open"}) + assert command is not None + assert command.source is None + + def test_the_empty_string_is_not_a_stamp(self): + with pytest.raises(ValidationError): + SetFlag(key="portcullis", value="open", source="") diff --git a/tests/test_phase13_goldens.py b/tests/test_phase13_goldens.py new file mode 100644 index 0000000..408cea8 --- /dev/null +++ b/tests/test_phase13_goldens.py @@ -0,0 +1,135 @@ +"""The phase 13 golden: the lever, the journal it wrote, and the stamps on the log. + +Regenerate with `uv run python tests/generate_phase13_goldens.py` (and explain why in +the commit message). The golden records a short delve driven the way an authored +trigger and quest layer drives one, with no listeners registered: a trigger marked, +its flag written, its beat journalled, the same trigger marked again a turn later, a +referee note, and a quest-stamped grant. It is where the milestone is checked — +save/load and replay rebuild the journal and the fired-marks exactly, and every +`source` stamp survives the command log through both. +""" + +import json +from pathlib import Path + +import pytest + +from generate_phase13_goldens import LEVER, QUEST, SCRIPT, build_golden, replay_scenario, run_scenario, snapshot +from osrlib.core.events import Event +from osrlib.crawl.commands import parse_command +from osrlib.messages import format_message +from osrlib.persistence import load_game, save_game, session_state + +GOLDEN_PATH = Path(__file__).parent / "goldens" / "phase13_journal.json" + +REGENERATE_HINT = ( + "golden mismatch; if the change is intentional, regenerate with " + "`uv run python tests/generate_phase13_goldens.py` and explain why in the commit message" +) + + +def canonical(value) -> str: + return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + + +@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, played command by command.""" + return run_scenario(golden["master_seed"]) + + +@pytest.fixture(scope="module") +def replayed(golden): + """The determinism contract: the accepted-command log alone.""" + 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_scripted_command_was_accepted_and_logged(self, golden, scripted): + assert len(golden["command_log"]) == len(SCRIPT) == len(scripted.command_log) + + +class TestMilestoneBeats: + def test_the_lever_is_marked_once_and_reported_twice(self, golden): + assert golden["final_state"]["fired_triggers"] == ["lever-east"] + codes = [event.get("code") for event in golden["event_log"]] + assert codes.count("session.trigger.fired") == 2, "state records the trigger, the log records each firing" + assert [entry["command_type"] for entry in golden["command_log"]].count("mark_trigger_fired") == 2 + + def test_the_journal_is_two_beats_stamped_a_turn_apart(self, golden): + journal = golden["final_state"]["journal"] + assert [entry["text"] for entry in journal] == [ + "The east lever gives; somewhere below, a portcullis grinds upward.", + "The lever, hauled a second time, only grinds.", + ] + assert journal[1]["rounds"] - journal[0]["rounds"] == 60, "the breather between them was one turn" + assert [line for line in golden["transcript"] if line.startswith("Journal: ")] == [ + f"Journal: {entry['text']}" for entry in journal + ] + + def test_the_note_and_the_mark_stay_behind_the_screen(self, golden): + visibility = { + event["code"]: event["visibility"] + for event in golden["event_log"] + if event.get("code", "").startswith("session.") + } + assert visibility["session.trigger.fired"] == "referee" + assert visibility["session.note.recorded"] == "referee" + assert visibility["session.journal.entry_added"] == "player" + + def test_every_stamp_is_on_the_logged_command(self, golden): + stamps = [(entry["command_type"], entry["source"]) for entry in golden["command_log"]] + assert stamps == [ + ("enter_dungeon", None), + ("move_party", None), + ("mark_trigger_fired", LEVER), + ("set_flag", LEVER), + ("add_journal_entry", LEVER), + ("rest", None), + ("mark_trigger_fired", LEVER), + ("add_journal_entry", LEVER), + ("record_note", None), + ("grant_item", QUEST), + ] + + +class TestReplayAndLoadAgree: + def test_the_replay_reaches_the_same_state_as_the_scripted_run(self, scripted, replayed): + assert session_state(replayed) == session_state(scripted) + + def test_streams_clock_and_both_blocks_match_the_golden(self, golden, replayed): + assert canonical(snapshot(replayed)) == canonical(golden["final_state"]), REGENERATE_HINT + + def test_the_event_stream_and_transcript_match(self, golden, replayed): + 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_a_save_loads_back_to_the_replayed_state_with_both_blocks_intact(self, scripted, replayed): + restored = load_game(json.loads(json.dumps(save_game(scripted)))) + assert restored.fired_triggers == scripted.fired_triggers == replayed.fired_triggers + assert restored.journal == scripted.journal == replayed.journal + assert session_state(restored) == session_state(replayed), ( + "load(save) equals replay(seed, commands), the journal and the fired-marks included" + ) + + def test_the_reloaded_command_log_carries_every_stamp_verbatim(self, golden, scripted): + restored = load_game(json.loads(json.dumps(save_game(scripted)))) + assert [command.source for command in restored.command_log] == [ + entry["source"] for entry in golden["command_log"] + ] + assert [command.source for command in restored.command_log].count(LEVER) == 5 + assert [command.source for command in restored.command_log].count(QUEST) == 1 diff --git a/tests/test_session.py b/tests/test_session.py index fdcc693..5312acf 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -9,6 +9,7 @@ from osrlib.core.events import Visibility from osrlib.core.items import Coins from osrlib.crawl.commands import ( + AddJournalEntry, AdvanceTime, AwardXP, CloseDoor, @@ -475,12 +476,14 @@ def test_player_view_carries_the_whitelist(self): session = make_session() outfit(session) session.execute(EnterDungeon(dungeon_id="delve")) + session.execute(AddJournalEntry(text="Down the stair, into the dark.")) view = session.view(Visibility.PLAYER) assert view.party[0].current_hp == 6 assert view.mode == "exploring" 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."] def test_player_view_never_leaks_the_basics(self): session = make_session(seed=99) diff --git a/tests/test_terminal_modes.py b/tests/test_terminal_modes.py index 4fe3468..7466400 100644 --- a/tests/test_terminal_modes.py +++ b/tests/test_terminal_modes.py @@ -27,15 +27,18 @@ from osrlib.core.ruleset import Ruleset from osrlib.crawl.commands import ( ALL_COMMAND_CLASSES, + AddJournalEntry, AdvanceTime, AwardXP, EnterDungeon, Evade, GrantCoins, GrantItem, + MarkTriggerFired, MoveParty, PlaceParty, PurchaseHealing, + RecordNote, Rest, RollDice, SessionMode, @@ -169,6 +172,9 @@ def test_the_referee_surface_still_executes_in_victory(self): SetFlag(key="idol_returned", value=True), AdvanceTime(n=1, unit="turn"), RollDice(expression="2d6"), + MarkTriggerFired(trigger_id="idol-returned"), + AddJournalEntry(text="The idol sits on the altar where it began."), + RecordNote(text="The reward landed after the ending."), ] for command in accepted: assert session.execute(command).accepted, command.command_type