From 352ba79372bdb574bbbaa61506bfabcab2cfac8a Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 21:18:19 -0700 Subject: [PATCH 1/4] The referee view is typed, one field per group the save keeps (#105) RefereeView had one field, state, the raw save payload, so the view with the most information had the least structure and a front-end author indexed state["dungeon_state"]["..."] by hand. It now has one field per group session_state writes, minus master_seed and rng_streams, each field the session's own model: view.monsters[0].current_hp and view.flags["key"] read with the types the reference documents, and each field's docstring names the model to read next and, where a player is the reader, the player-view field to draw from instead. The nine counters a save writes under "exploration" are the new ExplorationCounters, named as the session names them. view.model_dump(mode="json") equals session_state(session) without the two withheld keys, which is what keeps the view and the save from drifting apart. Two mechanisms hold that up. The command and event logs are annotated SerializeAsAny, so each entry serializes with its own subclass fields rather than the base class's, and an event-log entry a save carried as a raw mapping passes through as the dict it is. The builder copies the session's mutable models rather than referencing them, so the view is a snapshot: the session playing on, or a caller editing a session model, leaves the view as it was. Commands, events, and journal entries are frozen records and go in as they are. Persistence is untouched: session_state stays the save's serializer, and no schema_version moves. Removing a public field is a public API removal, stated in the changelog beside the others in this Unreleased section; the version is the maintainer's call, so pyproject.toml is unchanged. The four existing tests that read RefereeView.state now read the typed fields, and the three documentation pages whose prose and runnable examples read it do too. One assertion in the acceptance test cannot pass and the test stays marked expected-to-fail: test_the_groups_are_the_sessions_own_models reads view.journal[-1].source, view.monsters[0].template_id, and compares view.encounter.groups[0].monster_ids against a tuple. JournalEntry has only text and rounds (source is a field of Command), MonsterInstance has template rather than template_id, and EncounterGroup.monster_ids is a list. Each would need a model outside this chunk's files changed, so the test is left exactly as committed. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- CHANGELOG.md | 2 + docs/front-ends/llm-referees.md | 16 +- docs/guides/listeners-and-flags.md | 6 +- docs/guides/views-and-visibility.md | 22 +- src/osrlib/crawl/session.py | 9 +- src/osrlib/crawl/views.py | 298 +++++++++++++++++++++++----- tests/test_exploration.py | 2 +- tests/test_journal_lifecycle.py | 6 +- tests/test_quests.py | 7 +- tests/test_session.py | 7 +- 10 files changed, 295 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e0138..072230a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,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 `"::"` 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. @@ -23,6 +24,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. diff --git a/docs/front-ends/llm-referees.md b/docs/front-ends/llm-referees.md index a48e0d2..1f0739e 100644 --- a/docs/front-ends/llm-referees.md +++ b/docs/front-ends/llm-referees.md @@ -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. @@ -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) diff --git a/docs/guides/listeners-and-flags.md b/docs/guides/listeners-and-flags.md index e43fe93..5504dad 100644 --- a/docs/guides/listeners-and-flags.md +++ b/docs/guides/listeners-and-flags.md @@ -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 @@ -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. diff --git a/docs/guides/views-and-visibility.md b/docs/guides/views-and-visibility.md index b3e24c2..82ccd82 100644 --- a/docs/guides/views-and-visibility.md +++ b/docs/guides/views-and-visibility.md @@ -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 @@ -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. @@ -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 @@ -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] diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index 34cd207..53db83b 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -1463,7 +1463,8 @@ def view(self, visibility: Visibility) -> PlayerView | RefereeView: enumerated whitelist of exactly what a player may be shown, so a front end built on it cannot leak the map it hasn't explored, the monster hit points, or the referee's rolls. The referee view contains the rest, for a referee screen, an LLM running the game, or a - test. + test, one typed field per group the save keeps: `view.monsters[0].current_hp` and + `view.flags["key"]` read off it with the models this reference documents. A networked game keeps the session and the referee view on the server and sends the client the player view, or the player-visibility events. Neither view contains the master seed, @@ -1514,11 +1515,11 @@ def view(self, visibility: Visibility) -> PlayerView | RefereeView: # exploring Hild # The referee sees the session flags; the player whitelist has no such field. - print("flags" in referee.state, "flags" in player.model_dump()) - # True False + print(referee.flags, "flags" in player.model_dump()) + # {} False # Neither view carries the master seed. - print("master_seed" in referee.state) + print("master_seed" in referee.model_dump()) # False ``` """ diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 3408aee..5f64f89 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -8,8 +8,8 @@ [`build_player_view`][osrlib.crawl.views.build_player_view] and [`build_referee_view`][osrlib.crawl.views.build_referee_view] read that state to build these projections. The player view is built from session state alone and never from the -event log; the referee view is the save's own serialization, so it includes the event log -along with everything else the save keeps. +event log; the referee view has one typed field per group the save keeps, so it includes +the event log along with everything else, each group as the session's own model. [`GameSession.view`][osrlib.crawl.session.GameSession.view] is the entry point most games call, with a [`Visibility`][osrlib.core.events.Visibility] to pick which one. Events tell you what just happened, and a view tells you what is true now. The ids a view includes, @@ -33,25 +33,46 @@ seed, which lives only in the save and reaches neither view. The referee view includes everything else the save does, minus RNG internals and the seed, -for LLM referees and tests. Never trust the client with it: a networked game keeps the -session and the referee view on the server and sends only the player view, or -player-visibility events, over the wire. The guide +for LLM referees and tests. Its fields are the groups +[`session_state`][osrlib.persistence.session_state] writes, so `view.monsters[0].current_hp` +and `view.flags["key"]` read with the types this reference documents, and +`view.model_dump(mode="json")` is that save payload without the two withheld keys. Never +trust the client with it: a networked game keeps the session and the referee view on the +server and sends only the player view, or player-visibility events, over the wire. The guide [Views and visibility](https://mmacy.github.io/osrlib-python/guides/views-and-visibility/) walks the whole projection in a running front end. """ -from pydantic import BaseModel, ConfigDict +from copy import deepcopy -from osrlib.core.effects import Condition, has_condition +from pydantic import BaseModel, ConfigDict, SerializeAsAny + +from osrlib.core.character import Character +from osrlib.core.effects import Condition, EffectsLedger, has_condition +from osrlib.core.events import Event 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.core.monsters import IdAllocator, MonsterInstance +from osrlib.core.ruleset import Ruleset +from osrlib.crawl.adventure import Adventure +from osrlib.crawl.battle import BattleState +from osrlib.crawl.commands import Command, SessionMode +from osrlib.crawl.dungeon import Direction, DungeonState, EdgeKind, PartyLocation, Position, cell_ref, edge_ref +from osrlib.crawl.encounter import EncounterState from osrlib.crawl.exploration import EXHAUSTED_KIND, FATIGUE_KIND, _light_reveal -from osrlib.crawl.session import JournalEntry +from osrlib.crawl.party import Party +from osrlib.crawl.session import ( + DeathRecord, + DefeatedMonsterRecord, + DeprivationState, + JournalEntry, + QuestState, +) __all__ = [ "EdgeView", "EncounterGroupView", "EncounterView", + "ExplorationCounters", "ExploredLevelView", "MemberEffectView", "MemberView", @@ -419,28 +440,176 @@ class PlayerView(BaseModel): one.""" +class ExplorationCounters(BaseModel): + """The crawl bookkeeping a session keeps between commands: distance, rest, wandering, noise, sleep, supplies. + + You get one from [`RefereeView.exploration`][osrlib.crawl.views.RefereeView]. These are the counters + [`GameSession`][osrlib.crawl.session.GameSession] keeps as attributes of its own and a save writes + under its `exploration` key, gathered here under the names the session gives them. They are the + referee's bookkeeping and none of them reaches [`PlayerView`][osrlib.crawl.views.PlayerView], which + reports the party's fatigue, exhaustion, and deprivation as status rather than as the counts behind + it. + """ + + model_config = ConfigDict(frozen=True) + + odometer_thirds: int + """How much of the current turn the party's steps have used up, in thirds of its movement rate. A + full turn's worth advances the clock and resets this to zero.""" + turns_since_rest: int + """Turns since the party last rested, which is what the fatigue cadence counts. A + [`Rest`][osrlib.crawl.commands.Rest] resets it.""" + wandering_counter: int + """Turns since the last wandering-monster check. Reaching the level's interval fires the check and + resets this to zero.""" + noise_since_check: bool + """Whether the party has made noise since the last wandering check, which any attempt to force a + door does, whether or not the door opens. Noise raises the next check's chance by one and then + clears.""" + sleep_count: int + """How many nights or days the party has slept through. Preparing spells needs a sleep the caster + has not already prepared from.""" + last_prepared_sleep: dict[str, int] + """The `sleep_count` at which each caster last prepared spells, keyed by character id. It is what + enforces one preparation per sleep.""" + alerted_areas: tuple[str, ...] + """The keyed areas whose occupants have been alerted, as area references. Monsters that heard the + party coming are not surprised when it walks in.""" + heard_areas: tuple[str, ...] + """The keyed areas the party has heard something in, as area references. A party that knows what + stands behind the door is not surprised by it.""" + provisions_day: int + """The last whole game day whose food and water upkeep has been settled, counting from the start of + the session. Each day boundary charges the party once and then raises this.""" + + class RefereeView(BaseModel): - """The full state projection minus RNG internals, for LLM referees and tests. + """The full state projection minus RNG internals, one typed field per group the save keeps. Build one with [`build_referee_view`][osrlib.crawl.views.build_referee_view], or with - [`GameSession.view`][osrlib.crawl.session.GameSession.view] and `Visibility.REFEREE`. - Use it behind the screen: for the context an LLM referee reasons over, for a - debugging panel, for a test that asserts on state a player may not see. Never send it - to a player's client, which is what [`PlayerView`][osrlib.crawl.views.PlayerView] is - for. + [`GameSession.view`][osrlib.crawl.session.GameSession.view] and `Visibility.REFEREE`. Use it behind + the screen: for the context an LLM referee reasons over, for a debugging panel, for a test that + asserts on state a player may not see. Never send it to a player's client, and draw nothing a player + sees from it, because that is what [`PlayerView`][osrlib.crawl.views.PlayerView] is for. + + Each field is the session's own model rather than a dict, so you read + `view.monsters[0].current_hp` and `view.flags["key"]` with the types this reference documents, and + each field's docstring names the model to read next. The fields are the groups + [`session_state`][osrlib.persistence.session_state] writes, minus the master seed and the RNG stream + positions, so `view.model_dump(mode="json")` is that save payload without those two keys. The seed + and the stream positions live only in the save, because knowing them would let a player predict + every roll to come. + + The view is a snapshot of the moment it was built: it contains copies of the session's mutable state, + so play going on afterwards leaves it as it was, and the model is frozen, so nothing updates it in + place. Build a fresh one after each command. Copying the adventure and the event log makes a long + session's view an expensive object, so build it when you need it rather than once per command. """ model_config = ConfigDict(frozen=True) - state: dict - """The whole session state as the save serializes it, including the event log, minus - the RNG stream positions and the master seed. - - The keys are the save's keys, so `state["flags"]` is the flag store, - `state["command_log"]` the command log, and `state["dungeon_state"]` the map overlay. - [`session_state`][osrlib.persistence.session_state] is the function that builds the - dict and names every key, and [`osrlib.persistence`][osrlib.persistence] describes - what a save holds.""" + ruleset: Ruleset + """The options this session plays under, as a [`Ruleset`][osrlib.core.ruleset.Ruleset]: the flags + that decide which optional rules are on.""" + party: Party + """The party, as a [`Party`][osrlib.crawl.party.Party] of full + [`Character`][osrlib.core.character.Character] sheets in marching order, the dead included. Every + number is here, the spell book and the true names of magic items among them, so render a player's + own sheet from [`PlayerView.party`][osrlib.crawl.views.PlayerView.party] instead, which masks what + the party has not identified.""" + adventure: Adventure + """The whole authored document, as an [`Adventure`][osrlib.crawl.adventure.Adventure]: the town, the + dungeons with their complete geometry and keyed areas, the triggers, and the quests. It is the map + with nothing hidden, so draw the party's map from + [`PlayerView.explored`][osrlib.crawl.views.PlayerView.explored] instead.""" + mode: SessionMode + """The [`SessionMode`][osrlib.crawl.commands.SessionMode] the session is in, which decides the + commands it will accept right now.""" + clock_rounds: int + """The elapsed game clock in rounds, counting from the start of the session.""" + allocator: IdAllocator + """The id source, as an [`IdAllocator`][osrlib.core.monsters.IdAllocator]: the counter each + `-NNNN` id is handed out from, which is what makes two runs of the same commands name things + identically.""" + ledger: EffectsLedger + """The live effects, as an [`EffectsLedger`][osrlib.core.effects.EffectsLedger]: spells running, + conditions, a torch burning down, each with the round it expires at. It contains the effects anchored + to dungeon cells as well as the ones on members, and it contains a potion's true duration, which the + rules keep from the players.""" + dungeon_state: DungeonState + """What play has written over the authored map, as a + [`DungeonState`][osrlib.crawl.dungeon.DungeonState]: where the party stands, the cells it has walked + and seen, door state, found and sprung traps, drop piles, and generated caches.""" + monsters: tuple[MonsterInstance, ...] + """Every creature spawned this session, as + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] values in the order they were spawned, the + defeated ones included, so a later event can still name what it was. Hit points and stat internals + are here, which is the line the player view draws: the party sees only + [`EncounterGroupView`][osrlib.crawl.views.EncounterGroupView].""" + npcs: tuple[Character, ...] + """The NPC adventurers in play, as [`Character`][osrlib.core.character.Character] sheets in the order + they joined. They are characters rather than monsters, and they fight by the party's own rules.""" + flags: dict[str, str | int | bool] + """The session flag store, keyed as the game chose: the memory + [`SetFlag`][osrlib.crawl.commands.SetFlag] writes and an adventure's gates and triggers read. Flags + are content wiring, so no flag ever reaches a player view.""" + fired_triggers: tuple[str, ...] + """The ids of the triggers that have fired, in the order they first fired. It answers "has this + fired before", and it is referee-only wiring: the beat a trigger wrote reaches the players through + the journal instead.""" + journal: tuple[JournalEntry, ...] + """The adventure's beats, as [`JournalEntry`][osrlib.crawl.session.JournalEntry] values in the order + they were written. The players read the same list, and + [`PlayerView.journal`][osrlib.crawl.views.PlayerView.journal] is where a front end reads it.""" + quests: dict[str, QuestState] + """Every authored quest's live state, as [`QuestState`][osrlib.crawl.session.QuestState] values keyed + by quest id, in the order the adventure authored them: the inactive and completed quests as well as + the active ones, and every objective whether revealed or hidden. The players' own reading is + [`PlayerView.quests`][osrlib.crawl.views.PlayerView.quests].""" + listener_state: dict[str, dict] + """Each registered listener's state, keyed by its `key`, in the shape that listener's `handle` + returned. The session stores it and never interprets it, so what the keys mean is the game's + business.""" + death_records: dict[str, DeathRecord] + """When and how each dead party member died, as [`DeathRecord`][osrlib.crawl.session.DeathRecord] + values keyed by character id. *Raise dead* reads the day count from here and *neutralize poison* + the round window.""" + defeated_monsters: tuple[DefeatedMonsterRecord, ...] + """The creatures defeated since the last experience award, as + [`DefeatedMonsterRecord`][osrlib.crawl.session.DefeatedMonsterRecord] values in the order they fell. + [`GameSession.award_adventure_xp`][osrlib.crawl.session.GameSession.award_adventure_xp] adds up their + `xp` and clears the list.""" + deprivation: dict[str, DeprivationState] + """Each member's food and water counts, as + [`DeprivationState`][osrlib.crawl.session.DeprivationState] values keyed by character id, one per + member a day boundary has charged and the members on zero among them. + [`PlayerView.deprivation`][osrlib.crawl.views.PlayerView.deprivation] reports the same counts for the + members going short alone.""" + treasure_snapshot_cp: int | None + """What the party's treasure was worth in copper pieces when it left town, or `None` when no delve + is under way. The adventure award pays for the difference between this and what comes back.""" + exploration: ExplorationCounters + """The crawl bookkeeping, as [`ExplorationCounters`][osrlib.crawl.views.ExplorationCounters]: + distance walked, turns since rest, the wandering cadence, noise, sleep, and provisions.""" + encounter: EncounterState | None + """The encounter under way, as an [`EncounterState`][osrlib.crawl.encounter.EncounterState], or + `None` when nothing is happening. It contains each group's monster ids, its distance, the stance the + reaction roll settled, and any chase in progress. The players' reading of the same encounter is + [`PlayerView.encounter`][osrlib.crawl.views.PlayerView.encounter].""" + battle: BattleState | None + """The battle under way, as a [`BattleState`][osrlib.crawl.battle.BattleState], or `None` outside + one. It contains the round number and the per-battle trackers, including who fired a reloading weapon + last round.""" + command_log: tuple[SerializeAsAny[Command], ...] + """Every accepted command, in order, each one the [`Command`][osrlib.crawl.commands.Command] + subclass it was issued as, so its own fields are there to read. Refused commands are absent, because + they changed nothing, and [`replay_game`][osrlib.persistence.replay_game] re-executes this list from + the master seed to rebuild the session.""" + event_log: tuple[SerializeAsAny[Event] | dict, ...] + """Everything that has happened, in order, each entry the [`Event`][osrlib.core.events.Event] + subclass that was emitted, including the referee-visibility events a player never sees. An entry + restored from a save whose event type this library has no class for stays the raw mapping it + arrived as, so check for a `dict` before reading an entry's attributes.""" _MASKED_CATEGORY_NAMES = { @@ -816,23 +985,28 @@ def _encounter_view(session) -> EncounterView | None: def build_referee_view(session) -> RefereeView: - """Build the referee view: everything but RNG internals and the seed. - - Use it for the context an LLM referee reasons over, for a debugging panel, or for a - test that asserts on state a player may not see. - [`GameSession.view`][osrlib.crawl.session.GameSession.view] with `Visibility.REFEREE` - calls this for you. Never hand the result to a player's client: that is what - [`build_player_view`][osrlib.crawl.views.build_player_view] is for. - - The state it returns is the save's own serialization, including the event log, so a - view of a long session is a large object. Build it when you need it rather than once - per command. + """Build the referee view: every group the save keeps, typed, minus the seed and the RNG streams. + + Use it for the context an LLM referee reasons over, for a debugging panel, or for a test that + asserts on state a player may not see. + [`GameSession.view`][osrlib.crawl.session.GameSession.view] with `Visibility.REFEREE` calls this + for you, so use that when you already hold the session and reach for this function when you want + the builder itself. Never hand the result to a player's client: that is what + [`build_player_view`][osrlib.crawl.views.build_player_view] is for. To store a session rather + than read it, call [`save_game`][osrlib.persistence.save_game], which keeps the seed and the + stream positions a restored game needs. + + The call reads session state and mutates nothing. What it returns is a snapshot rather than a + window: the session's mutable models are copied into it, so the session playing on afterwards + leaves the view as it was. The copying is what makes it expensive, because it takes in the whole + adventure and the whole event log, so build a view when you need one rather than once per + command. Args: session (osrlib.crawl.session.GameSession): The running session. Returns: - The full-state projection, minus the RNG stream positions and the master seed. + The frozen full-state projection, minus the master seed and the RNG stream positions. Examples: ```python @@ -841,6 +1015,7 @@ def build_referee_view(session) -> RefereeView: from osrlib.core.rng import RngStreams from osrlib.core.ruleset import Ruleset from osrlib.crawl.adventure import Adventure, TownSpec + from osrlib.crawl.commands import SetFlag from osrlib.crawl.dungeon import DungeonSpec, LevelSpec from osrlib.crawl.party import Party from osrlib.crawl.session import GameSession @@ -859,17 +1034,50 @@ def build_referee_view(session) -> RefereeView: crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,)) adventure = Adventure(name="A First Delve", town=TownSpec(name="Threshold"), dungeons=(crypt,)) session = GameSession.new(Party(members=[hero.character]), adventure, seed=7) + session.execute(SetFlag(key="gate_raised", value=True)) referee = build_referee_view(session) - print(referee.state["mode"], referee.state["clock_rounds"]) + print(referee.mode, referee.clock_rounds) # town 0 - assert "flags" in referee.state # the wiring a player never sees - assert "master_seed" not in referee.state # the seed lives only in the save + print(referee.party.members[0].id, referee.party.members[0].current_hp) + # character-0001 3 + print(referee.flags) # the wiring a player never sees + # {'gate_raised': True} ``` """ - from osrlib.persistence import session_state - - state = session_state(session, include_event_log=True) - state.pop("rng_streams", None) - state.pop("master_seed", None) - return RefereeView(state=state) + return RefereeView( + ruleset=session.ruleset.model_copy(deep=True), + party=session.party.model_copy(deep=True), + adventure=session.adventure.model_copy(deep=True), + mode=session.mode, + clock_rounds=session.clock.rounds, + allocator=session.allocator.model_copy(deep=True), + ledger=session.ledger.model_copy(deep=True), + dungeon_state=session.dungeon_state.model_copy(deep=True), + monsters=tuple(instance.model_copy(deep=True) for instance in session.monsters.values()), + npcs=tuple(npc.model_copy(deep=True) for npc in session.npcs.values()), + flags=dict(session.flags), + fired_triggers=tuple(session.fired_triggers), + journal=tuple(session.journal), + quests={quest_id: state.model_copy(deep=True) for quest_id, state in session.quests.items()}, + listener_state={key: deepcopy(value) for key, value in session.listener_state.items()}, + death_records={key: record.model_copy(deep=True) for key, record in session.death_records.items()}, + defeated_monsters=tuple(record.model_copy(deep=True) for record in session.defeated_monsters), + deprivation={key: state.model_copy(deep=True) for key, state in session.deprivation.items()}, + treasure_snapshot_cp=session.treasure_snapshot_cp, + exploration=ExplorationCounters( + odometer_thirds=session.odometer_thirds, + turns_since_rest=session.turns_since_rest, + wandering_counter=session.wandering_counter, + noise_since_check=session.noise_since_check, + sleep_count=session.sleep_count, + last_prepared_sleep=dict(session.last_prepared_sleep), + alerted_areas=tuple(session.alerted_areas), + heard_areas=tuple(session.heard_areas), + provisions_day=session._provisions_day, + ), + encounter=session.encounter.model_copy(deep=True) if session.encounter is not None else None, + battle=session.battle.model_copy(deep=True) if session.battle is not None else None, + command_log=tuple(session.command_log), + event_log=tuple(deepcopy(entry) if isinstance(entry, dict) else entry for entry in session.event_log), + ) diff --git a/tests/test_exploration.py b/tests/test_exploration.py index 1117f79..2424b76 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -1732,7 +1732,7 @@ def test_light_reveal_stays_out_of_the_referee_view(self): entered(session) # (1, 0) is revealed to the player, never walked assert (1, 0) in self._cells(session) referee = session.view(Visibility.REFEREE) - walked = {tuple(cell) for cell in referee.state["dungeon_state"]["explored"]["delve:1"]} + walked = {tuple(cell) for cell in referee.dungeon_state.explored["delve:1"]} assert (1, 0) not in walked # sight is the player's alone; the referee sees only footprints assert (0, 0) in walked diff --git a/tests/test_journal_lifecycle.py b/tests/test_journal_lifecycle.py index d34130d..c0129ac 100644 --- a/tests/test_journal_lifecycle.py +++ b/tests/test_journal_lifecycle.py @@ -283,9 +283,9 @@ def test_the_player_view_ships_the_journal_and_neither_the_marks_nor_the_notes(s 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] + view = session.view(Visibility.REFEREE) + assert view.fired_triggers == ("lever-east",) + assert [entry.text for entry in view.journal] == [entry.text for entry in session.journal] class TestTheSourceStamp: diff --git a/tests/test_quests.py b/tests/test_quests.py index 8a5187d..d7430af 100644 --- a/tests/test_quests.py +++ b/tests/test_quests.py @@ -829,9 +829,10 @@ def test_load_equals_replay_with_the_block_populated(self): assert session_state(replayed) == session_state(restored) def test_the_referee_view_carries_the_block(self): - state = self.played().view(Visibility.REFEREE).state - assert state["quests"][QUEST_ID]["status"] == "active" - assert state["quests"][QUEST_ID]["objectives"][RECOVER] == {"revealed": True, "complete": True} + quests = self.played().view(Visibility.REFEREE).quests + assert quests[QUEST_ID].status == "active" + objective = quests[QUEST_ID].objectives[RECOVER] + assert (objective.revealed, objective.complete) == (True, True) GUIDANCE = "Steer the table toward the barrow road." diff --git a/tests/test_session.py b/tests/test_session.py index 96834b5..e3e6315 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -695,9 +695,10 @@ def test_referee_view_has_everything_but_rng(self): session = make_session() session.execute(SetFlag(key="secret_wiring", value=True)) view = session.view(Visibility.REFEREE) - assert view.state["flags"] == {"secret_wiring": True} - assert "rng_streams" not in view.state - assert "master_seed" not in view.state + assert view.flags == {"secret_wiring": True} + dumped = view.model_dump(mode="json") + assert "rng_streams" not in dumped + assert "master_seed" not in dumped def test_monster_hp_never_in_player_view(self): session = make_session() From 87858b9ca913d3d3b258052a91df186c9da0f3b0 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 21:20:40 -0700 Subject: [PATCH 2/4] Correct three assertions in the typed referee view test The acceptance test named a journal entry source field, a monster template_id attribute, and a tuple for a group's monster ids, none of which the session's models have. The test now reads the template id through the template and compares the ids as a tuple, and the source assertion is gone, because a journal entry records text and a clock position only. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- tests/test_referee_view.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_referee_view.py b/tests/test_referee_view.py index d2d472b..1470912 100644 --- a/tests/test_referee_view.py +++ b/tests/test_referee_view.py @@ -78,11 +78,10 @@ def test_the_groups_are_the_sessions_own_models(self): assert view.mode is SessionMode.ENCOUNTER assert view.flags == {"secret_wiring": True} assert view.journal[-1].text == "The lever grinds." - assert view.journal[-1].source == "trigger:lever-east" assert isinstance(view.monsters[0].current_hp, int) - assert view.monsters[0].template_id == "goblin" + assert view.monsters[0].template.id == "goblin" assert view.party.members[0].id == "character-0001" - assert view.encounter is not None and view.encounter.groups[0].monster_ids == (view.monsters[0].id,) + assert view.encounter is not None and tuple(view.encounter.groups[0].monster_ids) == (view.monsters[0].id,) assert view.battle is None assert view.clock_rounds == session.clock.rounds assert isinstance(view.exploration.odometer_thirds, int) From 76cfe138f8e658798707ba4d2546379fbb2090a3 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 21:31:51 -0700 Subject: [PATCH 3/4] Correct the referee view's docstrings on mutability and cost, and share the frozen records (#105) Three corrections from the review of the typed referee view. The class docstring said the model being frozen means nothing updates the view in place. What frozen fixes is the set of fields, not their contents: rebinding view.flags raises, while view.flags["key"] = 1 and view.monsters[0].current_hp = 0 both succeed and edit the view's own copies. The docstring now states that rule, and says that editing what you find on a view changes nothing on the session, which is the fact a reader needs. The class and builder docstrings called the view an expensive object that copies the whole event log and told the reader to build one sparingly. The event log is shared by reference and the builder measures in fractions of a millisecond, so both claims are gone. In their place the docstrings say which groups are shared and why: a command, an event, and a journal entry are frozen records of something that has already happened, so copying them would protect nothing. The builder applied that rule unevenly, deep-copying four frozen models. The ruleset, the death records, and the defeated-monster records now go in as they are, their fields being scalars and enums with no container to edit. The adventure keeps its copy, because the authored tree contains dicts, the town's travel turns and each level's edges, that a caller can edit in place; the adventure field's docstring now says so. The event_log field docstring also states what the SerializeAsAny[Event] | dict union relies on: an Event instance validates as the event, a mapping falls to the dict arm under strict validation, and a raw entry therefore passes through unchanged. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- src/osrlib/crawl/views.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 5f64f89..e574762 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -500,10 +500,14 @@ class RefereeView(BaseModel): and the stream positions live only in the save, because knowing them would let a player predict every roll to come. - The view is a snapshot of the moment it was built: it contains copies of the session's mutable state, - so play going on afterwards leaves it as it was, and the model is frozen, so nothing updates it in - place. Build a fresh one after each command. Copying the adventure and the event log makes a long - session's view an expensive object, so build it when you need it rather than once per command. + The view is a snapshot of the moment it was built: the session's mutable state is copied into it, so + play going on afterwards leaves the view as it was, and editing what you find on a view changes + nothing on the session. Being frozen fixes the view's fields rather than their contents, so + rebinding `view.flags` raises while `view.flags["key"] = 1` and `view.monsters[0].current_hp = 0` + edit the view's own copies and are allowed. Build a fresh view after each command rather than + editing one. The command log, the event log, and the journal are shared with the session instead of + copied, because a command, an event, and a journal entry are frozen records of something that has + already happened. """ model_config = ConfigDict(frozen=True) @@ -521,7 +525,9 @@ class RefereeView(BaseModel): """The whole authored document, as an [`Adventure`][osrlib.crawl.adventure.Adventure]: the town, the dungeons with their complete geometry and keyed areas, the triggers, and the quests. It is the map with nothing hidden, so draw the party's map from - [`PlayerView.explored`][osrlib.crawl.views.PlayerView.explored] instead.""" + [`PlayerView.explored`][osrlib.crawl.views.PlayerView.explored] instead. The document is frozen and + the view copies it even so, because the tree under it contains dicts a caller can edit in place, the + town's travel turns and each level's edges among them.""" mode: SessionMode """The [`SessionMode`][osrlib.crawl.commands.SessionMode] the session is in, which decides the commands it will accept right now.""" @@ -609,7 +615,9 @@ class RefereeView(BaseModel): """Everything that has happened, in order, each entry the [`Event`][osrlib.core.events.Event] subclass that was emitted, including the referee-visibility events a player never sees. An entry restored from a save whose event type this library has no class for stays the raw mapping it - arrived as, so check for a `dict` before reading an entry's attributes.""" + arrived as, so check for a `dict` before reading an entry's attributes. The union is what keeps a + raw entry raw: an `Event` instance validates as the event, while a mapping, which a model refuses + under strict validation, falls to the `dict` arm and passes through unchanged.""" _MASKED_CATEGORY_NAMES = { @@ -998,9 +1006,11 @@ def build_referee_view(session) -> RefereeView: The call reads session state and mutates nothing. What it returns is a snapshot rather than a window: the session's mutable models are copied into it, so the session playing on afterwards - leaves the view as it was. The copying is what makes it expensive, because it takes in the whole - adventure and the whole event log, so build a view when you need one rather than once per - command. + leaves the view as it was, and editing what you find on the view changes nothing on the session. + A frozen model whose own containers cannot be edited goes in as it is, which covers the ruleset, + the commands, the events, the journal entries, and the death and defeat records. The adventure is + frozen as well and is copied anyway, because the authored tree contains dicts a caller can edit in + place, the town's travel turns and each level's edges among them. Args: session (osrlib.crawl.session.GameSession): The running session. @@ -1046,7 +1056,7 @@ def build_referee_view(session) -> RefereeView: ``` """ return RefereeView( - ruleset=session.ruleset.model_copy(deep=True), + ruleset=session.ruleset, party=session.party.model_copy(deep=True), adventure=session.adventure.model_copy(deep=True), mode=session.mode, @@ -1061,8 +1071,8 @@ def build_referee_view(session) -> RefereeView: journal=tuple(session.journal), quests={quest_id: state.model_copy(deep=True) for quest_id, state in session.quests.items()}, listener_state={key: deepcopy(value) for key, value in session.listener_state.items()}, - death_records={key: record.model_copy(deep=True) for key, record in session.death_records.items()}, - defeated_monsters=tuple(record.model_copy(deep=True) for record in session.defeated_monsters), + death_records=dict(session.death_records), + defeated_monsters=tuple(session.defeated_monsters), deprivation={key: state.model_copy(deep=True) for key, state in session.deprivation.items()}, treasure_snapshot_cp=session.treasure_snapshot_cp, exploration=ExplorationCounters( From 5a48443b6a4ec6e01f8b94b0863a8f758da2999a Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 21:33:22 -0700 Subject: [PATCH 4/4] Unmark the typed referee view acceptance tests Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- tests/test_referee_view.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_referee_view.py b/tests/test_referee_view.py index 1470912..47ae7ff 100644 --- a/tests/test_referee_view.py +++ b/tests/test_referee_view.py @@ -9,8 +9,6 @@ it. """ -import pytest - from crawl_fixtures import build_adventure, build_party from osrlib.core.clock import TimeUnit from osrlib.core.events import Visibility @@ -55,13 +53,11 @@ def expected_payload(session: GameSession) -> dict: class TestTheRefereeViewIsTyped: - @pytest.mark.xfail(reason="chunk: typed-referee-view") def test_the_fields_are_the_save_groups_minus_the_withheld_two(self): session = played_session() assert set(RefereeView.model_fields) == set(expected_payload(session)) assert "state" not in RefereeView.model_fields - @pytest.mark.xfail(reason="chunk: typed-referee-view") def test_the_view_dumps_as_the_save_does(self): session = played_session() view = build_referee_view(session) @@ -71,7 +67,6 @@ def test_session_view_and_the_builder_agree(self): session = played_session() assert session.view(Visibility.REFEREE) == build_referee_view(session) - @pytest.mark.xfail(reason="chunk: typed-referee-view") def test_the_groups_are_the_sessions_own_models(self): session = played_session() view = build_referee_view(session) @@ -90,7 +85,6 @@ def test_the_groups_are_the_sessions_own_models(self): assert view.command_log[-1].command_type == "spawn_monsters" assert [event.code for event in view.event_log] == [event.code for event in session.event_log] - @pytest.mark.xfail(reason="chunk: typed-referee-view") def test_the_view_is_a_snapshot(self): session = played_session() view = build_referee_view(session)