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

Filter by extension

Filter by extension

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

### Changed

- [`RefereeView`][osrlib.crawl.views.RefereeView] has one typed field per group a save keeps, each built from the session's own model, so a front end reads `view.monsters[0].current_hp` and `view.flags["key"]` with the types this reference documents instead of indexing a dict of raw JSON by hand (#105). The fields are the groups [`session_state`][osrlib.persistence.session_state] writes, minus `master_seed` and `rng_streams`, so `view.model_dump(mode="json")` is that save payload without the two withheld keys, which is what keeps the two layouts from drifting apart. `view.command_log` and `view.event_log` contain the [`Command`][osrlib.crawl.commands.Command] and [`Event`][osrlib.core.events.Event] subclasses that were issued and emitted, each with its own fields, and an entry a save carried as a raw mapping for an event type this library has no class for stays the mapping it arrived as. The counters a save writes under `exploration` are [`ExplorationCounters`][osrlib.crawl.views.ExplorationCounters], named as the session names them. The view is a snapshot: it contains copies of the session's mutable state, so play going on afterwards leaves it as it was, and building one still reads the session and changes nothing. Persistence is untouched, so `session_state`, [`save_game`][osrlib.persistence.save_game], and every `schema_version` stand as they were.
- The temple charges the party for a healing service rather than the patient (#73, part of #108). [`PurchaseHealing`][osrlib.crawl.commands.PurchaseHealing] draws the fee from the treated member's purse first and then from the other members in marching order, dead members included. Each purse pays in whole gold pieces, as much of what is still owed as its gold covers, so a purse that cannot cover the rest hands over all of its gold and keeps what it is worth below a gold piece, and the last purse charged pays the outstanding remainder alone. What the party can spend is therefore the whole gold pieces in its purses rather than their total worth, and a party whose whole gold pieces fall short is refused with `items.purchase.insufficient_funds` and keeps every coin, because the check runs before any purse is opened. This is what makes `raise_dead` buyable: the patient is a corpse, nothing hands a corpse coin, and the documented salvage flow (game over, `PlaceParty` to town, raise dead) is funded from the party's pooled wealth. [`HealingPurchasedEvent`][osrlib.crawl.events.HealingPurchasedEvent] carries `payers` and `payments_gp`, which name the purses charged and what each one put in; both default empty, so an older log still parses, and the default English line names the other payers only when the patient could not cover the fee alone. The service names are one exported type, [`HealingService`][osrlib.crawl.commands.HealingService], which both the command field and the `HEALING_SERVICES` price list use, so the offered list and the accepted list cannot drift apart. No draw sequence changed.
- [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] rejects two authorable configurations that save clean and can never fire (#46, #47). An area id colliding with a feature id on the same level is rejected: the two share the trap-reference namespace `"<dungeon>:<level>:<id>"` that gates found, sprung, and removed trap state, so one trap's spring would stand for the other's, and an untouched cache could read as already sprung. An `open`-trigger room trap on an area none of whose boundary edges is a door is rejected too, because opening a door is the only action that springs one and an area with no door edge could never roll for it; a door that `starts_open` still counts, since a referee command or a trigger can close it later. Neither check touches an `enter`-trigger trap, which springs on the party stepping into the area and needs no door at all. An author fixes either shape before the adventure passes validation.
- The found-trap rule is pinned rather than inherited, in the adaptations register and on [`TrapEvent`][osrlib.crawl.events.TrapEvent] (#48). A found *room* trap never springs, at its area's edge or at one of its doors, while a found *treasure* trap still rolls its 2-in-6 on every [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] until a thief removes it, because in B/X anyone can find and avoid a room trap and only a thief can take a treasure trap out. The consequence a front end needs is that `exploration.trap.safe` is a cache outcome only: a door past a found room trap rolls no die and so has nothing to report, and a known-trap-fails-to-fire beat rendered off that code will never fire at a door. Documentation only; no behaviour, event, or draw sequence changed.
Expand All @@ -25,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Removed

- [`RefereeView`][osrlib.crawl.views.RefereeView]`.state`, the one dict that held the whole save payload, is gone (#105). Read the group you want off the field of the same name, `view.flags` for `state["flags"]` and `view.dungeon_state` for `state["dungeon_state"]`, and call `view.model_dump(mode="json")` for the payload whole. This is a removal from the public API, as the rest of this section is, so the release it lands in is a major version. No save document changes: a view was never one.
- A battle declaration's `move` is `close`, `fighting_withdrawal`, or `retreat`, and the `withdraw` value, which names no rule in B/X, is gone: a declaration that carries it fails to parse (#74). `Combat.md` prints two ways out of melee and no third, the fighting withdrawal, backwards at up to half the encounter rate with clear space behind, and the retreat, at full rate with no attack that round while the opponent attacks at +2 and ignores the shield, so a front end offering a fall-back choice sends one of those two. Removing a wire value moves the serialization axis: [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] is 4 for this release, and the 3-to-4 step in [`MIGRATIONS`][osrlib.persistence.MIGRATIONS] clears the value off a logged declaration wherever it sits, since schema 3 never cross-validated `move` against `action`: a `move` declaration becomes `action="hold"` with no move, which is what that round played as, and any other action keeps itself and loses a field nothing read. The adaptations register now pins the other half of the SRD's fighting withdrawal as well: RAW takes no attack away from the withdrawing combatant, but a member declares one thing per round and the whole formation moves together, so a round the party withdraws in is a round it does not attack in. No rule, no rejection, and no draw sequence changed.
- [`EncounterGroup`][osrlib.crawl.encounter.EncounterGroup]`.surrendered` is gone, and with it the `battle.side.surrendered` code on [`MonsterFledEvent`][osrlib.crawl.events.MonsterFledEvent] and its default English template (#100). A monster group is slain or it runs away, so the `outcome` string on [`MonsterDefeatedEvent`][osrlib.crawl.events.MonsterDefeatedEvent] and on [`DefeatedMonsterRecord`][osrlib.crawl.session.DefeatedMonsterRecord] is `"slain"` or `"routed"` and a front end needs no surrender branch. A schema 3 save that carries the key on a group loads with it dropped, because a model ignores a key it does not declare.
- [`Character`][osrlib.core.character.Character]`.literacy` is gone (#100). A character's band comes from [`AbilityTables.literacy`][osrlib.core.abilities.AbilityTables.literacy] given the intelligence score, which is the one statement of the rule. The property never reached a save, so no document changes.
Expand Down
16 changes: 9 additions & 7 deletions docs/front-ends/llm-referees.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@ while not session.mode.terminal: # the party fell, or the adventure is won

## The referee sees everything

The observation side is [`GameSession.view`][osrlib.crawl.session.GameSession.view] with [`Visibility.REFEREE`][osrlib.core.events.Visibility], which returns a [`RefereeView`][osrlib.crawl.views.RefereeView]: the full session state — party internals, monster hit points, session flags, door states, the complete event log — with exactly two things withheld, the RNG internals and the master seed (those live only in the save document). The player view is the opposite discipline, an enumerated whitelist; [Views and visibility](../guides/views-and-visibility.md) draws the line precisely.
The observation side is [`GameSession.view`][osrlib.crawl.session.GameSession.view] with [`Visibility.REFEREE`][osrlib.core.events.Visibility], which returns a [`RefereeView`][osrlib.crawl.views.RefereeView]: the full session state — party internals, monster hit points, session flags, door states, the complete event log — with exactly two things withheld, the RNG internals and the master seed (those live only in the save document). Each group is a field of its own, typed as the session's own model, so an agent reads `view.monsters[0].current_hp` and `view.flags["key"]` off it and serializes the whole observation with `view.model_dump(mode="json")`. The player view is the opposite discipline, an enumerated whitelist; [Views and visibility](../guides/views-and-visibility.md) draws the line precisely.

```{.python .no-run}
# The referee view is full state — flags, monster internals — minus RNG state and the seed.
view = session.view(Visibility.REFEREE)
assert view.state["flags"] == {"ambush_sprung": True}
assert all(monster["current_hp"] >= 0 for monster in view.state["monsters"])
assert "master_seed" not in view.state and "rng_streams" not in view.state
assert view.flags == {"ambush_sprung": True}
assert all(monster.current_hp >= 0 for monster in view.monsters)
dumped = view.model_dump()
assert "master_seed" not in dumped and "rng_streams" not in dumped
```

The event stream carries the same privilege. [`GameSession.execute`][osrlib.crawl.session.GameSession.execute] returns its events unfiltered, and each event is stamped with a visibility: referee-visibility events carry the hidden rolls — surprise, reaction, secret-door detection — that a player-facing front end must strip at its wire (as [the FastAPI pattern](fastapi-pattern.md) does). An in-process referee agent reads them all; they are its perception of what the dice just did.
Expand Down Expand Up @@ -198,9 +199,10 @@ assert all(lines)

# The referee view is full state — flags, monster internals — minus RNG state and the seed.
view = session.view(Visibility.REFEREE)
assert view.state["flags"] == {"ambush_sprung": True}
assert all(monster["current_hp"] >= 0 for monster in view.state["monsters"])
assert "master_seed" not in view.state and "rng_streams" not in view.state
assert view.flags == {"ambush_sprung": True}
assert all(monster.current_hp >= 0 for monster in view.monsters)
dumped = view.model_dump()
assert "master_seed" not in dumped and "rng_streams" not in dumped

# Determinism is the eval story: same seed, same commands, same trajectory.
replay = new_session(seed=7)
Expand Down
6 changes: 3 additions & 3 deletions docs/guides/listeners-and-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Flags solve a smaller version of the same problem: content wiring that isn't a r
session.execute(SetFlag(key="crypt.lever_pulled", value=True))
```

`SetFlag` is accepted in every session mode and always succeeds. Its handler writes the value into `session.flags` and emits a [`FlagSetEvent`][osrlib.crawl.events.FlagSetEvent] with the key and the value. Flags are referee-only state: like listener state, they round-trip through saves (under `session.flags`), but neither flags nor listener state appear in the whitelisted [`PlayerView`][osrlib.crawl.views.PlayerView] a player-facing front end reads. For more information, see [Views and visibility](views-and-visibility.md). A front end that needs a flag's value back (to decide whether to narrate the portcullis creaking open, for example) reads `session.flags` directly when it has the session, or `session.view(Visibility.REFEREE).state["flags"]` when it works from views alone.
`SetFlag` is accepted in every session mode and always succeeds. Its handler writes the value into `session.flags` and emits a [`FlagSetEvent`][osrlib.crawl.events.FlagSetEvent] with the key and the value. Flags are referee-only state: like listener state, they round-trip through saves (under `session.flags`), but neither flags nor listener state appear in the whitelisted [`PlayerView`][osrlib.crawl.views.PlayerView] a player-facing front end reads. For more information, see [Views and visibility](views-and-visibility.md). A front end that needs a flag's value back (to decide whether to narrate the portcullis creaking open, for example) reads `session.flags` directly when it has the session, or `session.view(Visibility.REFEREE).flags` when it works from views alone.

## Lifecycle commands: fired-marks, the journal, and notes

Expand Down Expand Up @@ -240,8 +240,8 @@ session.execute(SetFlag(key="crypt.lever_pulled", value=True))
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}
referee_view = session.view(Visibility.REFEREE)
assert referee_view.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.
Expand Down
22 changes: 11 additions & 11 deletions docs/guides/views-and-visibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,16 @@ Active effects on party members come with their remaining duration, except a pot

The quests in play appear as [`QuestView`][osrlib.crawl.views.QuestView] values with an id, a name, the offer beat and its speaker attribution, and the revealed objectives with their ids, display names, and states. When an encounter or battle is running, the view also contains its public shape ([`EncounterView`][osrlib.crawl.views.EncounterView] and [`EncounterGroupView`][osrlib.crawl.views.EncounterGroupView]): a monster group's id, label, living count, distance, and visible conditions, but never its hit points. Alongside it, the view reports the round's own shape as the table knows it: who must declare, who stands in the front rank, who is held fast, and who is still reloading.

[`RefereeView`][osrlib.crawl.views.RefereeView] is the opposite: everything except the RNG stream states and the master seed. Its single `state` field is the same serialized shape [`session_state`][osrlib.persistence.session_state] produces for a save, so it contains full monster instances with real hit points, the flag store, the NPC roster, session counters, and the complete event log with referee-visibility events in it. Use it for LLM referees and tools that need the truth rather than a player's approximation of it. Never send it to a wire client.
[`RefereeView`][osrlib.crawl.views.RefereeView] is the opposite: everything except the RNG stream states and the master seed. It has one field per group [`session_state`][osrlib.persistence.session_state] writes for a save, each one the session's own model, so `view.monsters` contains full [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] values with real hit points, and `view.flags`, `view.npcs`, `view.exploration`, and `view.event_log` are the flag store, the NPC roster, the session counters, and the complete event log with referee-visibility events in it. Dumping the view to JSON gives that save payload back without the two withheld keys. Use it for LLM referees and tools that need the truth rather than a player's approximation of it. Never send it to a wire client.

## The split in practice

The clearest way to see the split is a spawned monster. The referee view's state contains the monster's live hit points. The player-facing encounter group contains only what the party could plausibly perceive: how many are still standing, how far away they are, and what conditions show.
The clearest way to see the split is a spawned monster. The referee view contains the monster's live hit points. The player-facing encounter group contains only what the party could plausibly perceive: how many are still standing, how far away they are, and what conditions show.

```{.python .no-run}
# The referee sees the goblin's hit points; the player view never carries them.
referee_monster = referee_view.state["monsters"][0]
assert "current_hp" in referee_monster
# The referee sees the goblin's hit points; the player view never contains them.
referee_monster = referee_view.monsters[0]
assert referee_monster.current_hp > 0

player_group = player_view.encounter.groups[0]
assert player_group.count == 1
Expand All @@ -53,7 +53,7 @@ The authored layer shows the same split from the other side. The journal reaches
# The beat is for the table; the trigger that produced it is referee-only wiring.
assert [entry.text for entry in journal_view.journal][-1] == "The lever grinds."
assert "lever-east" not in journal_view.model_dump_json()
assert referee_state["fired_triggers"] == ["lever-east"]
assert referee_after.fired_triggers == ("lever-east",)
```

Quests draw the same line, one level finer. `PlayerView.quests` contains the **active** quests only, in document order. A quest nobody has been given yet is absent, because an activation clause is wiring like any other, and a finished quest leaves the list, because its record is the journal. Under each quest, only the **revealed** objectives appear. A hidden objective's id is not in the projection at all until its `reveal_when` clause fires or the objective completes, which is why `ObjectiveView.state` needs only `"incomplete"` and `"complete"`. Nothing else about a quest reaches the player view: no clause, no pattern, no condition, no reward, and no `guidance` from any narrative block or level.
Expand Down Expand Up @@ -149,9 +149,9 @@ assert session.mode is SessionMode.ENCOUNTER
player_view = session.view(Visibility.PLAYER)
referee_view = session.view(Visibility.REFEREE)

# The referee sees the goblin's hit points; the player view never carries them.
referee_monster = referee_view.state["monsters"][0]
assert "current_hp" in referee_monster
# The referee sees the goblin's hit points; the player view never contains them.
referee_monster = referee_view.monsters[0]
assert referee_monster.current_hp > 0

player_group = player_view.encounter.groups[0]
assert player_group.count == 1
Expand All @@ -163,12 +163,12 @@ session.execute(AddJournalEntry(text="The lever grinds.", source="trigger:lever-
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
referee_after = session.view(Visibility.REFEREE)

# The beat is for the table; the trigger that produced it is referee-only wiring.
assert [entry.text for entry in journal_view.journal][-1] == "The lever grinds."
assert "lever-east" not in journal_view.model_dump_json()
assert referee_state["fired_triggers"] == ["lever-east"]
assert referee_after.fired_triggers == ("lever-east",)

# The quest activated at the threshold, and its offer opened the journal.
quest_view = journal_view.quests[0]
Expand Down
Loading
Loading