osrlib.core.clock
How much game time has passed, counted in rounds.
GameClock is the entry point: you create one, call
advance whenever the party spends time, and
read rounds, turns, and days off it. Each advance returns the turn and day
boundaries it crossed, as BoundaryCrossing
values, so you can run whatever your game does at the end of a turn or a day: burn a
torch down, check for wandering monsters, eat a day's rations.
In an ordinary game you never build a clock yourself. A
GameSession keeps one and advances it as commands
consume time, and
EffectsLedger.advance takes the session's
clock when it ages poison, light, and the rest. Build your own when you use the kernel
without a session and still want time to pass.
B/X measures time in three units: the round of 10 seconds, the turn of 10 minutes, and the day. The clock keeps one integer count of rounds, the finest unit, so the arithmetic is exact and the whole clock saves as one number. An advance that lands exactly on a boundary counts as crossing it: a torch lit at turn 0 burns out when the clock reaches turn 6, not turn 7.
Typical usage:
from osrlib.core.clock import GameClock, TimeUnit
clock = GameClock()
# Six turns of searching: one crossing per turn, none for a day.
crossings = clock.advance(6, TimeUnit.TURN)
assert [(crossing.unit, crossing.index) for crossing in crossings] == [
(TimeUnit.TURN, 1),
(TimeUnit.TURN, 2),
(TimeUnit.TURN, 3),
(TimeUnit.TURN, 4),
(TimeUnit.TURN, 5),
(TimeUnit.TURN, 6),
]
assert (clock.rounds, clock.turns, clock.days) == (360, 6, 0)
# Ten rounds of combat cross nothing: the clock is mid-turn.
assert clock.advance(10) == []
assert clock.rounds == 370
ROUNDS_PER_DAY
module-attribute
ROUNDS_PER_DAY = ROUNDS_PER_TURN * TURNS_PER_DAY
How many rounds make up one day, which is 8640.
It's the product of the two ratios above rather than a separate number, so it can never disagree with them.
ROUNDS_PER_TURN
module-attribute
How many rounds make up one exploration turn, which is ten minutes of story time.
The round is the combat unit and the turn is the exploration unit, and this is the
conversion between them. Use it to say how long something lasts in the other unit: a
torch that burns for six turns burns for 6 * ROUNDS_PER_TURN rounds.
Don't reassign it to play at a different scale. Every duration the SRD prints is quoted in turns or rounds at this ratio, so changing it rescales them all at once, with nothing to show for it. To work in a unit of your own, advance the clock by rounds and convert the result yourself.
SECONDS_PER_ROUND
module-attribute
How many seconds of story time one combat round takes.
Nothing in the rules reads this. It's here so you can put a wall-clock duration on the screen, or pace an animation, without writing 10 into your own code. The B/X rules give no unit finer than the round, so there's nothing below it to count.
TURNS_PER_DAY
module-attribute
How many exploration turns make up one day.
That's a full 24 hours of turns, not only the ones spent underground. The day boundary is where the rules put daily events, such as eating and preparing spells.
BoundaryCrossing
Bases: BaseModel
One turn or day boundary that a clock advance passed.
advance returns a list of these, oldest
first. Walk the list and do whatever your game owes the end of a turn or the end of
a day, in the order the boundaries arrived. There are no round crossings: every
round is a round boundary, so the count of them is the advance itself.
The model is frozen, so you can keep a crossing as a record of when something happened.
Examples:
from osrlib.core.clock import GameClock, TimeUnit
# A day boundary is also a turn boundary, and the turn is reported first.
clock = GameClock(rounds=8580)
crossings = clock.advance(1, TimeUnit.TURN)
assert [(crossing.unit, crossing.index, crossing.round) for crossing in crossings] == [
(TimeUnit.TURN, 144, 8640),
(TimeUnit.DAY, 1, 8640),
]
unit
instance-attribute
unit: TimeUnit
Which kind of boundary this is, TimeUnit.TURN or TimeUnit.DAY.
A crossing is never TimeUnit.ROUND. An advance of n rounds crosses n round
boundaries, so listing them would only repeat the number you passed in.
index
class-attribute
instance-attribute
Which boundary of that kind, counted from the start of the game.
The first turn of play is 1 and the sixth is 6, so the ordinal matches the way the rules count durations ("burns for six turns").
GameClock
Bases: BaseModel
How much time has passed in the game, as a count of rounds.
Call GameClock() to start a game at time zero, or GameClock(rounds=n) to resume
one. A GameSession makes its own and keeps it
on session.clock, so reach for the constructor only when you're running the kernel
without a session.
Unlike most models in the kernel this one is mutable:
advance moves the same clock forward rather
than returning a new one, because everything that spends time shares one clock. Pass
the clock itself to anything that consumes time, not a copy, or their views of the
game's time drift apart. Assigning rounds directly works and is validated, but
skips the boundary report, so prefer advance.
The clock saves as one integer, and GameClock.model_validate(document) reads it
back.
Examples:
from osrlib.core.clock import GameClock, TimeUnit
clock = GameClock()
crossings = clock.advance(2, TimeUnit.TURN)
assert clock.turns == 2
assert [crossing.index for crossing in crossings if crossing.unit is TimeUnit.TURN] == [1, 2]
# The whole clock round-trips as one field.
assert GameClock.model_validate(clock.model_dump()).rounds == 120
rounds
class-attribute
instance-attribute
Rounds elapsed since the start of the game.
The clock's only stored value. turns and days are read off it. Advancing is what
normally changes it, and it can never go below zero.
turns
property
turns: int
Return the number of whole exploration turns elapsed, rounding down.
Read it for a "how long have we been down here" display. A turn in progress doesn't count until it finishes, so a clock at round 59 still reads 0 turns.
days
property
days: int
Return the number of whole days elapsed, rounding down.
Read it to tell how many days of rations the party has eaten. A day in progress doesn't count until it finishes.
advance
advance(n: int, unit: TimeUnit = ROUND) -> list[BoundaryCrossing]
Advance the clock and report the turn and day boundaries crossed.
Call this whenever the party spends time, then act on the crossings you get back. The clock moves in place, and the return value is the report rather than a new clock.
If you're aging effects as well as counting time, call
EffectsLedger.advance instead and
hand it this clock: it advances the clock for you and returns the events that
expiring and ticking effects produced. Advancing the clock here does nothing to
effects on its own.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
How many units to advance. Must be non-negative. Zero is legal and crosses nothing. |
required |
unit
|
TimeUnit
|
The unit to advance in. |
ROUND
|
Returns:
| Type | Description |
|---|---|
list[BoundaryCrossing]
|
Every turn and day boundary in the advanced span, in chronological order, |
list[BoundaryCrossing]
|
with a coinciding turn boundary before its day boundary. A boundary the |
list[BoundaryCrossing]
|
advance lands on exactly is included. The position you started from isn't, |
list[BoundaryCrossing]
|
because the advance that reached it already reported it. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
from osrlib.core.clock import GameClock, TimeUnit
clock = GameClock()
# A round of combat crosses nothing.
assert clock.advance(1) == []
# Searching a room takes a turn, and the turn boundary comes back.
crossings = clock.advance(1, TimeUnit.TURN)
assert [(crossing.unit, crossing.index) for crossing in crossings] == [(TimeUnit.TURN, 1)]
assert clock.rounds == 61
TimeUnit
Bases: StrEnum
The unit an amount of game time is counted in.
Pass one to advance to say what your number
means, and read one off a BoundaryCrossing to
see which kind of boundary you crossed. The rounds each unit is worth are
ROUNDS_PER_TURN and
ROUNDS_PER_DAY.
ROUND
class-attribute
instance-attribute
The combat unit, ten seconds of story time.
One attack, one spell, or one move in a fight takes a round.
TURN
class-attribute
instance-attribute
The exploration unit, ten minutes of story time.
One move through the dungeon, one search of a room, or one attempt to listen at a door takes a turn.