osrlib.core.rng
Where every random number in osrlib comes from: named, seeded, repeatable streams.
RngStreams is the entry point. Make one from a master
seed, the single integer a whole game's randomness is derived from, ask it for a stream
by name with get, and pass that stream to whatever
you call: roll, the combat and treasure functions, character
creation. A GameSession builds its own container
from the seed you give it and hands out the right stream for each rule, so during
ordinary play you never touch this module.
A stream is named by a plain string, and each name draws its own independent sequence.
Rolling a hundred treasure hoards doesn't change what the next attack rolls. Add a new
draw to one subsystem and no other subsystem's results move, which is what lets a saved
game replay and a bug reproduce.
StreamName names every stream a running session draws
from, and each *_STREAM constant in the library is one of its members. The RNG
streams reference says what each one covers. Standalone code isn't bound
to those names. A name is a label, and all that matters is that you ask for the same one
each time.
Two draws made with the same master seed and the same stream name come out the same, in
this release and in every later one. The generator is PCG64, in the
pcg_setseq_128_xsl_rr_64 form with 128 bits of state and a 64-bit output, the same
generator numpy calls PCG64 rather than its PCG64DXSM. Each
next_uint64 advances the state first and then
takes the output from the new state, following the C implementation numpy follows.
Streams are forked from the master seed as
SHA-256(master_seed_bytes + b":" + stream_key_utf8), with the master seed written as 16
bytes, most significant first. Reimplementing any of that, even in a way that looks
equivalent, shifts draws and breaks saved games.
Nothing in osrlib reaches for Python's random module or keeps a generator of its own.
If you're writing a rule of your own to run beside osrlib's, take a stream as an
argument the same way.
Typical usage:
from osrlib.core.dice import roll
from osrlib.core.rng import RngStreams
streams = RngStreams(master_seed=42)
# Each name is its own sequence, so one subsystem's draws never move another's.
attack = roll("1d20", streams.get("combat"))
gold = roll("2d6×100", streams.get("treasure"))
assert attack.total == 14
assert gold.total == 600
# Same seed, same name, same draws.
assert roll("1d20", RngStreams(master_seed=42).get("combat")).total == 14
RngStream
One named sequence of random numbers, which you pass to whatever needs to roll.
Get one from RngStreams.get rather than building
it, unless you're writing a test or using a single stream on its own. Every function
in the kernel that rolls anything takes one of these, and it's the only source of
randomness in the library.
Drawing advances the stream, so two calls give two different results and the order of
your calls is part of what the seed determines. Pass the stream itself, never a copy.
Draw with randbelow for a bounded number, or
let roll do it from a dice expression.
Constructing one directly runs PCG64's own initialization from an
(initstate, initseq) pair, which is what
derive_init_pair produces from a master seed and
a name.
Examples:
from osrlib.core.rng import RngStreams
stream = RngStreams(master_seed=42).get("combat")
assert stream.randbelow(20) + 1 == 14
Start the stream from a PCG64 init pair.
The initialization is PCG's own: set the state to 0, set the increment to
(initseq << 1) | 1, step, add initstate, step, everything modulo 2**128. It
drops the top bit of initseq, which is what the reference implementation does
and not a defect to work around.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
initstate
|
int
|
The 128-bit init state, from 0 up to but not including 2**128. |
required |
initseq
|
int
|
The 128-bit sequence selector, in the same range. Two streams with the same init state and different selectors draw different sequences. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If either argument is outside the allowed range. |
from_seed_material
classmethod
Build the named stream for a master seed, in one step.
Use this when you want a single stream and no container.
RngStreams is the better choice when you want
several, because it remembers each one and can save them all together.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
master_seed
|
int
|
The game's master seed, from 0 up to but not including 2**128. |
required |
key
|
str
|
The stream's name. |
required |
Returns:
| Type | Description |
|---|---|
RngStream
|
A stream at the start of its sequence. The same seed and name always give a |
RngStream
|
stream that draws the same numbers. |
Examples:
restore
classmethod
restore(snapshot: RngStreamState) -> RngStream
Rebuild a stream at the position a snapshot recorded.
Use it when you're loading a game you saved yourself.
load_game restores a session's streams for you.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
snapshot
|
RngStreamState
|
A position from
|
required |
Returns:
| Type | Description |
|---|---|
RngStream
|
A stream whose next draw is the one the saved stream would have made. |
export_state
export_state() -> RngStreamState
Record where the stream has reached, so you can come back to it.
Pair it with restore. To save a whole
game's worth of streams at once, call
RngStreams.export_states instead.
Exporting draws nothing and leaves the stream where it was.
Returns:
| Type | Description |
|---|---|
RngStreamState
|
A frozen record of the position, ready to serialize. |
next_uint64
next_uint64() -> int
Draw the next raw 64-bit number from the stream.
This is the generator's own output, with no bound applied. For a die or any
bounded value, call randbelow instead:
taking a remainder of this number yourself biases the result, and it draws a
different count of raw numbers than osrlib does, which puts the stream out of step
with a replay.
The stream advances first and then produces the output from its new state, following the reference C implementation.
Returns:
| Type | Description |
|---|---|
int
|
A number from 0 up to but not including 2**64, each equally likely. |
randbelow
Draw a number from 0 up to but not including n, each equally likely.
This is the bounded draw everything in osrlib is built on. For a die, add 1:
stream.randbelow(6) + 1 is a d6. For a dice expression, call
roll instead and let it do the arithmetic.
How many raw numbers a single call consumes varies. The method takes the top bits
of a raw draw and throws the candidate away if it lands at or above n, which is
what keeps every result equally likely. A bound that's a power of two never throws
anything away, and bounds of 3, 6, 10, 12, 20, and 100 sometimes do, so a stream's
position after a roll depends on which values came up rather than on how many
times you called. randbelow(1) returns 0 and still uses a draw.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
The bound, which the result stays below. Must be positive. |
required |
Returns:
| Type | Description |
|---|---|
int
|
A number from 0 up to but not including |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
RngStreamState
Bases: BaseModel
Where a stream had got to, in a form you can write to disk.
RngStream.export_state returns one and
RngStream.restore takes it back, so a game
saved halfway through a dungeon resumes on the very next draw rather than starting
the sequence over. save_game and
load_game do this for every stream a session has
touched, so you only build one of these yourself when you're saving a game without a
session.
The two numbers are the generator's internals. Read them if you're comparing implementations. Don't compute them.
Examples:
from osrlib.core.rng import RngStream, RngStreams
stream = RngStreams(master_seed=42).get("combat")
stream.randbelow(20)
# Save the position, draw on, and a restored copy continues from the save.
snapshot = stream.export_state()
expected = stream.randbelow(20)
assert RngStream.restore(snapshot).randbelow(20) == expected
state
class-attribute
instance-attribute
The generator's 128-bit state: how far along the sequence the stream has got.
RngStreams
RngStreams(master_seed: int)
All of a game's random number streams, forked from one master seed.
Make one with the seed you want the game to run on, then call
get for each stream you need. A stream is built
the first time you ask for it and kept, so asking again gives you the same stream at
the position you left it. Which streams exist is up to you, and a name you've never
used gets a stream of its own the first time you ask.
Keep one container for the whole game and pass streams out of it. Two containers on the same seed draw the same numbers as each other, which means handing out streams from a second container quietly repeats rolls the first one already made.
A GameSession keeps one of these, so you only
build your own when you're running the kernel without a session. Save a game's worth
of positions with export_states and put
them back with restore_states. The RNG
streams reference lists the names a session uses.
Examples:
from osrlib.core.rng import RngStreams
streams = RngStreams(master_seed=42)
combat = streams.get("combat")
# The same name gives back the same stream, mid-sequence.
assert streams.get("combat") is combat
assert combat.randbelow(20) + 1 == 14
Create the container for a master seed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
master_seed
|
int
|
The game's master seed, from 0 up to but not including 2**128. Any integer in range works. Record the one you used if you want to replay the game. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
master_seed
property
master_seed: int
Return the master seed every stream in this container is forked from.
Read it to record what a game was seeded with, so you can rebuild the same container later. It cannot be changed: a container's seed is fixed when you make it.
get
Return the stream with this name, making it the first time you ask.
Pass what comes back to whatever draws.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
str
|
The stream's name, such as |
required |
Returns:
| Type | Description |
|---|---|
RngStream
|
The stream for that name, at whatever position it has reached. Asking twice |
RngStream
|
gives the same stream, not a copy. |
Examples:
export_states
export_states() -> dict[str, RngStreamState]
Record where every stream you've used has reached, keyed by name.
Write the result into your save file alongside the master seed, and put it back
with restore_states when you load.
save_game does this for a session, so call it
yourself only when you're saving a game you built without one.
A name you've never asked for is left out. A stream like that has drawn nothing, and it rebuilds itself from the master seed the first time you use it.
Returns:
| Type | Description |
|---|---|
dict[str, RngStreamState]
|
One position per stream that has been used, in sorted name order so two saves |
dict[str, RngStreamState]
|
of the same game are byte for byte the same. |
Examples:
restore_states
restore_states(states: dict[str, RngStreamState]) -> None
Put saved stream positions back, so a loaded game draws on from where it stopped.
Call it on a container built with the same master seed the game was saved under.
A stream named in states is replaced. One that isn't is left alone, and a stream
that has never been used rebuilds itself from the seed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
states
|
dict[str, RngStreamState]
|
Positions from
|
required |
Examples:
from osrlib.core.rng import RngStreams
streams = RngStreams(master_seed=42)
streams.get("combat").randbelow(20)
saved = streams.export_states()
expected = streams.get("combat").randbelow(20)
# A fresh container on the same seed picks up the next draw, not the first.
loaded = RngStreams(master_seed=42)
loaded.restore_states(saved)
assert loaded.get("combat").randbelow(20) == expected
StreamName
Bases: StrEnum
Every stream name a running session draws from.
A member is its own string, so StreamName.COMBAT and "combat" are the same stream to
RngStreams.get and the same key in a save file. Draw with a
member rather than a string you type out. A mistyped name raises no error:
get forks a stream of its own for it and draws plausible
numbers from that stream instead of the one you meant.
Each public *_STREAM constant takes its value from the member named beside it, so the constant
and the member are one object. The RNG streams reference says what each stream
covers and which rules draw on it.
A rule you write yourself isn't limited to these names. Any string you hand
RngStreams.get gets a stream of its own. These are the names
a session replays.
Examples:
from osrlib.core.rng import RngStreams, StreamName
streams = RngStreams(master_seed=42)
# A member is its own string, so the member and the written-out key are one stream.
assert StreamName.COMBAT == "combat"
assert streams.get(StreamName.COMBAT) is streams.get("combat")
CHARACTER_CREATION
class-attribute
instance-attribute
Creation draws: ability scores, the first-level hit die, and starting gold. Its public name is
CHARACTER_CREATION_STREAM.
ADVANCEMENT
class-attribute
instance-attribute
Hit dice rolled when a character gains or loses a level. Its public name is
ADVANCEMENT_STREAM.
COMBAT
class-attribute
instance-attribute
Battle resolution: attacks, damage, saves, morale, initiative, reactions. Its public name is
COMBAT_STREAM.
EFFECTS
class-attribute
instance-attribute
Draws inside the effects engine: onsets, rolled durations, revival countdowns. Its public name
is EFFECTS_STREAM.
MONSTER_SPAWN
class-attribute
instance-attribute
Hit points rolled as a monster instance is spawned from its template. Its public name is
MONSTER_SPAWN_STREAM.
NPC_PARTY
class-attribute
instance-attribute
NPC adventuring parties: composition, class and level, scores, hit points, spells. Its public
name is NPC_PARTY_STREAM.
MAGIC
class-attribute
instance-attribute
Spell resolution: targeting, damage, forced saves, dispel survival, turning undead. Its public
name is MAGIC_STREAM.
TREASURE
class-attribute
instance-attribute
Treasure generation, from the presence roll to each coin, gem, and magic item. Its public name
is TREASURE_STREAM.
WANDERING
class-attribute
instance-attribute
Wandering monsters: the check die, the table roll, group counts, variant picks. Its public name
is WANDERING_STREAM.
ENCOUNTER
class-attribute
instance-attribute
Encounter setup: surprise, distance, reaction, distraction, commanded group counts. Its public
name is ENCOUNTER_STREAM.
EXPLORATION
class-attribute
instance-attribute
Exploration: forcing doors, listening, searching, traps, tinder, thief skills. Its public name
is EXPLORATION_STREAM.
MONSTER_ACTION
class-attribute
instance-attribute
A monster action policy's own draws: which action a group takes and at whom. Its public name is
MONSTER_ACTION_STREAM.
ADJUDICATION
class-attribute
instance-attribute
The referee's freeform dice, kept off every stream a rule draws from. Its public name is
ADJUDICATION_STREAM.
derive_init_pair
Work out the two numbers PCG64 needs to start a named stream.
You rarely call this. RngStreams.get calls it for
you, and RngStream.from_seed_material
wraps it in one step. Reach for it when you're checking osrlib's forking against
another implementation, or building the same stream in another language.
The seed material is SHA-256(master_seed_bytes + b":" + stream_key_utf8), with the
master seed written as exactly 16 bytes, most significant first. The digest's first
16 bytes become the init state and its last 16 the sequence selector, each read most
significant byte first.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
master_seed
|
int
|
The game's master seed, from 0 up to but not including 2**128. |
required |
key
|
str
|
The stream's name, such as |
required |
Returns:
| Type | Description |
|---|---|
int
|
The |
int
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples: