osrlib.persistence
Save a game, load it back, or rebuild it by replaying what the player did.
Two ways lead from a stored game to a running
GameSession, and they reach the same place.
The one you want most of the time is save and load.
save_game turns a live session into a plain dictionary you
can write as JSON, and load_game turns that dictionary
back into a session that continues where it left off. A save is self-contained: it includes
the adventure's own content, so loading needs no other file, and you can hand a player a
save without handing them the adventure it came from.
The other way is replay. replay_game starts from
nothing but the master seed, the party as it stood before play began, the adventure, the
ruleset, and the list of commands the player issued, and runs the whole game again from the
first command. Because every random draw in osrlib comes from a seeded stream, the second
run lands on the same rolls as the first, and the session it produces matches the one a load
of the same game produces, field for field. That match depends on the party document being
the one you took before any session touched the party, because
GameSession.new assigns the member ids itself.
Replay is for auditing a game, reproducing a bug report, or checking that a rules change
moved nothing it shouldn't have.
A save contains the session's whole state: the party, the adventure content, the explored dungeon, the clock, the active effects, the spawned monsters and NPCs, flags, fired triggers, the journal, quest progress, listener state, the session mode, the exploration counters, any encounter or battle in progress, every RNG stream's position, and the master seed. Beside that state sit two records of what happened: the log of accepted commands, and the log of events unless you ask for it to be left out. The records are history, not ingredients. A load rebuilds the session from the state and re-derives nothing from the logs, which is why loading costs the same however long the game has run.
The two logs do different jobs. The command log is what
replay_game consumes, so a save without it can be loaded
but not replayed. The event log is the transcript a front end shows, and it's the part you
can drop, with include_event_log=False, when the save is only meant to be resumed.
A save is a stamped document of kind "save", the envelope described in
osrlib.versioning. Its schema_version is what lets an older save
still load: load_game runs the payload through
MIGRATIONS on the way in, step by step, until it reaches
the shape this library reads. Its engine_version is what guards replay, because the same
commands under different rules can produce a different game.
replay_game refuses that with
ReplayVersionError when you give it the recorded
version to compare. Loading a save across engine versions stays fine, since a load reads
state rather than re-deriving it.
Typical usage:
import json
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character, party_to_document
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
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
from osrlib.persistence import load_game, replay_game, save_game, session_state
rules = Ruleset()
roll = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
pc = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=rules, stream=roll)
# Keep the party as it stands before any session touches it: that is what a replay starts from.
starting_party = party_to_document([pc.character])
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0))
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(Party(members=[pc.character]), adventure, seed=7, ruleset=rules)
session.execute(EnterDungeon(dungeon_id="crypt"))
# Save, write it out, read it back, and continue from where the party stood.
document = save_game(session)
restored = load_game(json.loads(json.dumps(document)))
print(restored.mode.value)
# exploring
# Replay reaches the same session from the seed and the commands alone.
replayed = replay_game(7, starting_party, adventure, rules, session.command_log)
print(session_state(replayed) == session_state(session))
# True
MIGRATIONS
module-attribute
MIGRATIONS: dict[int, Callable[[dict], dict]] = {1: _migrate_1_to_2, 2: _migrate_2_to_3, 3: _migrate_3_to_4}
The steps that bring an old save payload forward, one schema version at a time.
MIGRATIONS[n] rewrites a payload written at schema version n into the shape version
n + 1 expects. load_game walks the chain for you, from
whatever version the document was stamped with up to
SCHEMA_VERSION, so a save from an older release loads
without any code of yours.
Read it when you want to know what an old save loses or gains on the way in, or to check
that a version you still have stored can be loaded at all: a version with no step in this
chain can't, and load_game raises
ContentValidationError naming the missing step.
Nothing here is a hook. Adding an entry doesn't extend the library, since the chain only ever
runs up to SCHEMA_VERSION.
load_game
load_game(document: Mapping[str, object]) -> GameSession
Restore a session from a save document.
Hand it what you read back from storage and you get a live
GameSession, standing where it stood when
save_game wrote it: same position, same clock, same hit
points, same RNG streams, so the next roll is the roll the saved game was about to make.
One thing doesn't come back. Listeners are your code, and a save can't store code, so the
restored session has none registered. Call
register_listener again for each
one, including the Interpreter if your game
uses it, before you execute another command. Each listener's own state was saved and is
waiting under its key.
An older save needs nothing from you. The document is checked, then walked forward
through MIGRATIONS one schema version at a time before
anything is rebuilt. An event in the transcript whose type this version of osrlib doesn't
recognize is kept as it was found and written back out unchanged on the next save, so a
log loses no entries by passing through an older library.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
document
|
Mapping[str, object]
|
A document produced by |
required |
Returns:
| Type | Description |
|---|---|
GameSession
|
The restored session, with no listeners registered. |
Raises:
| Type | Description |
|---|---|
ContentValidationError
|
If the envelope or the payload is malformed, if a logged command is of a type this version doesn't know, or if no migration step exists for the document's schema version. |
SaveVersionError
|
If a newer osrlib wrote the document. Tell the player to upgrade. There's nothing to repair in the file. |
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
from osrlib.persistence import load_game, save_game
rules = Ruleset()
roll = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
pc = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=rules, stream=roll)
party = Party(members=[pc.character])
level = LevelSpec(number=1, width=2, height=1, entrance=(0, 0), edges={"1,0:west": Edge(kind=EdgeKind.OPEN)})
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(party, adventure, seed=7)
document = save_game(session)
restored = load_game(document)
assert save_game(restored) == document
replay_game
replay_game(
seed: int,
party_document: Mapping[str, object],
adventure: Adventure,
ruleset: Ruleset,
commands: Sequence[Command | Mapping[str, object]],
*,
recorded_engine_version: str | None = None
) -> GameSession
Rebuild a session by running its recorded commands again from the seed.
Where load_game restores a stored state, this plays the
game a second time: a fresh session on the same master seed, then every command in the
log, in order. Each random draw comes from a seeded stream, so the rolls fall the same
way, and the session you get back matches the one a load of the same save produces.
Use it to audit a game, to reproduce a player's bug report from their save, or to check
that a rules change you made moved nothing it shouldn't have. Use load_game for
everything else, including resuming play, because a replay costs the whole game again and
gives you nothing a load doesn't.
Four of the five inputs come straight out of a save document's payload, under
master_seed, adventure, ruleset, and command_log. The fifth, party_document, is
the one you have to plan for. It must be the party as it stood before any session touched
it, because GameSession.new assigns member ids
itself, in party order, the same way both times. Take that document with
party_to_document when you roll the party,
and keep it beside your saves.
The replayed session gets no listeners, and needs none. Everything a listener did during the original game, whether an interpreter firing a trigger's consequences or your own code awarding a prize, it did by issuing a command the session accepted and logged. Those commands are in the log already, and re-executing them rebuilds every effect. A listener registered on a replay would issue them a second time and pull the game off course.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
seed
|
int
|
The master seed the original session ran under, from the save's |
required |
party_document
|
Mapping[str, object]
|
The starting party, stamped by
|
required |
adventure
|
Adventure
|
The adventure the game was played in. |
required |
ruleset
|
Ruleset
|
The ruleset the game was played under. A different one can change outcomes, and nothing here detects that. |
required |
commands
|
Sequence[Command | Mapping[str, object]]
|
The accepted commands, in order, either as
|
required |
recorded_engine_version
|
str | None
|
The |
None
|
Returns:
| Type | Description |
|---|---|
GameSession
|
The replayed session, in the state the original reached, with no listeners registered. |
Raises:
| Type | Description |
|---|---|
ReplayVersionError
|
If |
ContentValidationError
|
If a logged command is of a type this version doesn't know, or if a logged command is refused this time. The log contains only commands that were accepted the first time, so a refusal means the replay has diverged from the game it was meant to reproduce. |
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character, party_to_document
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
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
from osrlib.persistence import replay_game, session_state
rules = Ruleset()
roll = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
pc = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=rules, stream=roll)
starting_party = party_to_document([pc.character])
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0))
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(Party(members=[pc.character]), adventure, seed=7, ruleset=rules)
session.execute(EnterDungeon(dungeon_id="crypt"))
replayed = replay_game(7, starting_party, adventure, rules, session.command_log)
print(session_state(replayed) == session_state(session))
# True
save_game
save_game(session: GameSession, *, include_event_log: bool = True) -> dict
Serialize a session to a save document you can store.
This is how you write a game to disk: take the result, hand it to json.dumps, and put
it wherever you keep saves. Call it as often as you like. It reads the session and
changes nothing, so saving mid-encounter or mid-battle is as safe as saving in town.
The result is a stamped document of kind "save", described in
osrlib.versioning. The session state sits under payload, wrapped
in the schema and engine versions that tell a later
load_game what it's reading.
session_state gives you the payload without the
envelope, for when you're embedding it in a document of your own rather than storing it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
GameSession
|
The session to save. |
required |
include_event_log
|
bool
|
Whether to include the transcript. Pass False to leave the event log out and keep only the state and the accepted-command log, which is all a resume or a replay needs. |
True
|
Returns:
| Type | Description |
|---|---|
dict
|
A new dict of JSON-compatible values with |
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
from osrlib.persistence import save_game
rules = Ruleset()
roll = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
pc = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=rules, stream=roll)
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0))
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(Party(members=[pc.character]), adventure, seed=7, ruleset=rules)
document = save_game(session)
print(document["kind"], sorted(document))
# save ['engine_version', 'kind', 'payload', 'schema_version']
session_state
session_state(session: GameSession, *, include_event_log: bool = True) -> dict
Serialize a session's whole state, without the document envelope around it.
This is the payload save_game stamps, offered on its
own for when the envelope is in your way: embedding a session inside a larger document of
your own, comparing two sessions field by field, or inspecting what a session contains.
Call save_game instead whenever you mean to store the result, because a payload with no
envelope has no version stamps, and nothing can tell later which osrlib wrote it.
Nothing on the session changes, and the dicts and lists the session's own models produce
are fresh, so you can keep the result, edit it, and serialize it whenever you like. One
part is shared: an event log entry that arrived as a raw dict, which
load_game keeps for an event type this osrlib doesn't
recognize, goes into the payload by reference, and editing it edits the session's entry
too.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
GameSession
|
The session to serialize. It may be in any mode, mid-encounter or mid-battle included. |
required |
include_event_log
|
bool
|
Whether to include the transcript. Pass False to leave the event log out, which makes a long game's save much smaller. The accepted-command log is included either way, because a replay needs it. |
True
|
Returns:
| Type | Description |
|---|---|
dict
|
A new dict of JSON-compatible values, ready for |
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
from osrlib.persistence import session_state
rules = Ruleset()
roll = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
pc = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=rules, stream=roll)
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0))
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(Party(members=[pc.character]), adventure, seed=7, ruleset=rules)
state = session_state(session, include_event_log=False)
print(state["master_seed"], state["mode"], "event_log" in state)
# 7 town False