osrlib.core.classes
Class definitions, level progression, XP awards, and leveling up.
load_classes gives you the catalog of playable classes as frozen
ClassDefinition models, compiled from the OSE SRD's class
pages. Look one up by id with ClassCatalog.get; see
the class id index for the ids. Hand the definition you get to
create_character, and afterwards read it back off a
character through definition.
A definition is a frozen template, and a character is the mutable state of one person playing it. Nothing in play ever writes to a definition. It contains the ability requirements, the prime requisites, the experience-modifier tiers, a row per level with hit dice, THAC0, saving throws, and spell capacity, the armour and weapon policies, and the class's abilities as tags the rules procedures read. All of it is data, so a class the SRD did not print is a data file rather than a code change.
Nothing a character derives from its class is stored on the character. Read the progression row for
the current level with ClassDefinition.row and you
always get the values that match, which is why leveling up and being drained of levels both need
only change the level.
Advancement lives here. apply_xp is the one you usually want: it
applies the class's experience modifier, adds the award, and levels the character up when a
threshold is crossed. level_up does the level gain on its own
when the game hands out a level directly, and drain_levels
reverses it for the undead that drain levels. Creating a character in the first place is
osrlib.core.character.
Two other procedures read class data, so they live here too:
thief_skill_check rolls the thief's skills, and
detection_check with
detection_chance rolls the chance-in-6 checks for
listening at doors, finding secret doors, and spotting traps.
Typical usage:
from osrlib.core.alignment import Alignment
from osrlib.core.character import ADVANCEMENT_STREAM, CHARACTER_CREATION_STREAM, create_character
from osrlib.core.classes import apply_xp, level_title
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_classes
streams = RngStreams(master_seed=2)
fighter = load_classes().get("fighter")
character = create_character(
name="Rurik",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=Ruleset(),
stream=streams.get(CHARACTER_CREATION_STREAM),
).character
result = apply_xp(character, fighter, 2500, streams.get(ADVANCEMENT_STREAM))
print(result.level_before, result.level_after, character.max_hp)
# 1 2 10
print(level_title(fighter, character.level), character.thac0, character.saves.death)
# Warrior 19 12
PERCENTILE_THIEF_SKILLS
module-attribute
PERCENTILE_THIEF_SKILLS = (
"climb_sheer_surfaces",
"find_remove_treasure_traps",
"hide_in_shadows",
"move_silently",
"open_locks",
"pick_pockets",
)
The names of the six thief skills rolled on percentile dice.
Pass any of these as the skill argument of
thief_skill_check, which rolls d% and succeeds on a
result at or under the level's chance. The thief's seventh skill, "hear_noise", is not here
because it rolls 1d6 instead. That function takes it too.
Iterate this tuple to show a thief's whole percentile skill list, reading each level's numbers off
ThiefSkillRow by the same names.
ArmourPolicy
Bases: BaseModel
What armour and shields a class may use.
Read it as ClassDefinition.armour;
validate_equip checks against it. The magic-user wears
nothing, the thief wears leather only, and everyone else wears anything. Frozen.
kind
instance-attribute
kind: ArmourPolicyKind
Which armour the class may wear; see ArmourPolicyKind.
shields_allowed
instance-attribute
shields_allowed: bool
Whether the class may carry a shield. A class that can wear no armour cannot carry one either.
ArmourPolicyKind
Bases: StrEnum
What armour a class is allowed to wear.
Read it as ArmourPolicy.kind.
validate_equip enforces it when a character tries to put
something on. The wire values are "any", "leather_only", and "none".
ANY
class-attribute
instance-attribute
Any armour, which is what the cleric, dwarf, elf, fighter, and halfling wear.
LEATHER_ONLY
class-attribute
instance-attribute
Leather armour and nothing heavier, which is the thief's limit.
ClassAbility
Bases: BaseModel
One thing a class can do, as a tag the rules read plus the SRD text it came from.
Read them off ClassDefinition.abilities. The combat,
magic, and exploration procedures look for the tags they recognize and read the numbers out of
params, so a class ability is data rather than a branch in the code. Frozen.
Some abilities cannot be reduced to a number. Those are marked manual, and a front end shows the prose to the referee rather than acting on it.
tag
instance-attribute
tag: str
The identifier the rules match on, like "infravision", "detect_secret_doors", or "back_stab".
prose
instance-attribute
prose: str
The SRD's own description, which is what to show a player or referee.
manual
class-attribute
instance-attribute
manual: bool = False
True when nothing in osrlib acts on this ability and the prose is the whole of it.
ClassCatalog
Bases: BaseModel
Every playable class, as returned by load_classes.
Look a class up by id with get, or iterate classes
to build a menu of what a player may choose. The catalog is loaded once and cached, so calling
the loader again is free. Frozen.
classes
instance-attribute
classes: tuple[ClassDefinition, ...]
The class definitions, in the order the data file lists them. Ids are unique.
get
get(class_id: str) -> ClassDefinition
Return the class with class_id.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
class_id
|
str
|
The id to look up, like |
required |
Returns:
| Type | Description |
|---|---|
ClassDefinition
|
The class definition. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If no class has that id. The message names the id you passed. |
Examples:
ClassDefinition
Bases: BaseModel
A playable character class: the template a Character plays.
Get one from load_classes().get(class_id); see
the class id index for the ids. A character stores only the id, and
Character.definition looks the definition back
up, so read it from there rather than keeping a copy alongside.
It is frozen, and play never writes to it: a definition is shared by every character of that
class, while the mutable state of one played person lives on the
Character. Everything here is data compiled from the SRD's
class pages, which is why adding a class means adding data rather than code.
race
class-attribute
instance-attribute
The people this class belongs to, as a lowercase identifier, like "human" or "dwarf". Creation copies it onto
the character. No rule reads it: what a people can do comes through abilities instead.
requirements
class-attribute
instance-attribute
requirements: dict[AbilityScore, int] = {}
The lowest ability scores a character needs to take this class, checked by
validate_class_choice. Empty for the human classes. The demi-human
classes each require a 9 in one or two abilities.
prime_requisites
instance-attribute
prime_requisites: tuple[AbilityScore, ...]
The abilities that set the experience modifier. One for most classes, two for the elf and the halfling.
xp_tiers
instance-attribute
The experience-modifier bands, best first; see xp_modifier_pct, which
reads them.
max_level
class-attribute
instance-attribute
The highest level this class reaches. The human classes reach 14, and the demi-human classes stop lower.
armour
instance-attribute
armour: ArmourPolicy
What armour and shields the class may use; see ArmourPolicy.
weapons
instance-attribute
weapons: WeaponPolicy
What weapons the class may wield; see WeaponPolicy.
languages
instance-attribute
The language ids the class speaks for free, Common first. A character's full list, including the alignment tongue
and any extras, is Character.languages.
may_not_lower
class-attribute
instance-attribute
may_not_lower: tuple[AbilityScore, ...] = ()
Abilities the creation-time adjustment may not take points from, which for the thief is STR.
abilities
class-attribute
instance-attribute
abilities: tuple[ClassAbility, ...] = ()
What the class can do, as tags the rules read; see ClassAbility.
thief_skills
class-attribute
instance-attribute
thief_skills: tuple[ThiefSkillRow, ...] = ()
A row per level of the thief's seven skills, empty for every class but the thief; see
ThiefSkillRow.
level_titles
class-attribute
instance-attribute
The title a character has at each level, with the first entry being level 1. The SRD prints titles only up to
name level, so this is shorter than the progression, and
level_title returns None past the end rather than raising.
progression
instance-attribute
progression: tuple[ProgressionRow, ...]
A row per level from 1 to max_level, in order. Read one with
row rather than indexing.
overrides_applied
class-attribute
instance-attribute
The names of the compile-time corrections applied to this class's SRD page. Provenance for anyone checking the data against the SRD. Nothing in play reads it.
row
row(level: int) -> ProgressionRow
Return what this class grants at level.
This is where a character's THAC0, attack bonus, saving throws, hit dice, and spell capacity come from. Nothing derived is stored on a character, so reading the row for the current level always gives values that match it, and changing the level is all that leveling up or being drained has to do.
Read the convenience properties on
Character instead when you have a character in hand:
character.thac0, character.saves, and the rest call this for you. Call it directly to
look ahead, like asking what the next level costs in experience.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
level
|
int
|
The level to read, from 1 through |
required |
Returns:
| Type | Description |
|---|---|
ProgressionRow
|
The progression row for that level. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
DetectionResult
Bases: BaseModel
How a chance-in-6 detection check came out.
Returned by detection_check. Frozen.
roll
class-attribute
instance-attribute
roll: int | None = None
The 1d6 result, or None when the chance was zero and no die was thrown. A character with no chance at all, such
as anyone but a dwarf looking for a shift in the stonework, fails without rolling.
DrainResult
Bases: BaseModel
What an energy drain took from a character.
Returned by drain_levels. Frozen.
levels_lost
instance-attribute
levels_lost: int
How many levels the drain removed. When the drain killed the character, the level that killed them is counted here.
new_level
class-attribute
instance-attribute
The level the character now has, or 0 when the drain killed them.
hp_rolls
class-attribute
instance-attribute
The raw hit dice thrown for the levels lost, in order.
A level whose row carries no extra hit die throws nothing, so this is shorter than levels_lost when the drain
crossed such a level, and empty when every level it took was one of them.
xp_after
class-attribute
instance-attribute
xp_after: int | None = None
The experience the character is left with, or None when the drain killed them.
slain
class-attribute
instance-attribute
slain: bool = False
True when the drain took the character's last level and killed them.
events
class-attribute
instance-attribute
What to publish: a LevelDrainedEvent and, depending on the outcome, a
hit point report, the death events, and any spells forgotten because the character's capacity shrank. Feed them to
your event sink in order.
HitDice
Bases: BaseModel
How many hit dice a class rolls at one level, and of what size.
Read it off ProgressionRow.hit_dice.
level_up and
drain_levels compare this level's row against the next
one to decide whether a level change rolls a die or moves a flat bonus. Frozen.
A class stops gaining dice at name level and gains a flat number of hit points per level after
that. The SRD marks those levels with an asterisk, as in 9d8+2*, meaning the CON modifier no
longer applies to the gain.
count
class-attribute
instance-attribute
How many dice are rolled. At least 1.
die
instance-attribute
die: int
The size of each die, which is the class's hit die: d4 for the magic-user and thief, d6 for the cleric, elf, and halfling, d8 for the dwarf and fighter.
bonus
class-attribute
instance-attribute
Flat hit points added on top of the dice, which is how levels past name level grow. Never negative.
con_applies
class-attribute
instance-attribute
con_applies: bool = True
Whether the CON modifier applies to a die gained at this level.
The SRD clears it at the levels it marks with an asterisk, as in 9d8+2*. It is read separately from whether a die
is rolled at all, which depends on count rising from the row below.
LevelUpResult
Bases: BaseModel
What happened when a character gained a level.
Returned by level_up, and set on
XpAwardResult.level_up when an experience award caused
the gain. Show it to tell a player what their new level got them. Frozen.
hp_roll
instance-attribute
hp_roll: int | None
The raw hit die that was thrown, or None when the new level added no hit die and the gain was the difference
between the two rows' flat bonuses.
hp_gained
instance-attribute
hp_gained: int
The hit points added to both maximum and current. At least 1 while dice are still being rolled, however poor the die and the CON modifier were together.
con_applied
instance-attribute
con_applied: bool
Whether the CON modifier counted toward the gain.
It follows the new progression row's con_applies, which the SRD clears at the levels it marks with an asterisk,
and it is always False when no die was rolled. Read it rather than working it out from the level, because the two
are separate settings in the data.
ProgressionRow
Bases: BaseModel
Everything a class is at one level: the experience it costs, and what it grants.
Get one from ClassDefinition.row for the level you
care about. This is where a character's THAC0, attack bonus, saving throws, and spell capacity
come from, recomputed from the level every time rather than stored, which is why
level_up and
drain_levels need only change the level. Frozen.
level
class-attribute
instance-attribute
The level this row describes, counting from 1.
xp
class-attribute
instance-attribute
The experience points needed to reach this level. Level 1 is 0, and the numbers rise from there.
hit_dice
instance-attribute
hit_dice: HitDice
The dice this level's hit points are rolled on; see HitDice.
thac0
class-attribute
instance-attribute
The number needed to hit armour class 0 under descending armour class.
attack_bonus
class-attribute
instance-attribute
The same attack, expressed as the bonus added to the roll under ascending armour class.
saves
instance-attribute
saves: SavingThrows
The five saving throw targets at this level; see SavingThrows.
SavingThrows
Bases: BaseModel
The five saving throw target numbers.
Roll 1d20 against the field that matches the threat and succeed on that number or higher, so
lower is better. Read a character's current set from
Character.saves or a monster's from
MonsterInstance.saves. The saving-throw procedures in
osrlib.core.combat read them for you. Frozen.
death
class-attribute
instance-attribute
Against death rays and poison, the deadliest category.
wands
class-attribute
instance-attribute
Against the effects of magic wands.
paralysis
class-attribute
instance-attribute
Against paralysis and turning to stone.
breath
class-attribute
instance-attribute
Against a dragon's or other creature's breath attack.
SkillCheckResult
Bases: BaseModel
How a thief skill check came out.
Returned by thief_skill_check. Frozen.
roll
instance-attribute
roll: int
The die result: d% for the six percentile skills, 1d6 for hear_noise.
chance
instance-attribute
chance: int
The number the roll had to come in at or under, after any modifier you passed. Pick pockets is capped here at 99, so a theft always has some chance of failing.
noticed
class-attribute
instance-attribute
noticed: bool | None = None
For pick pockets only: True when the roll came in at more than twice the chance, which means the victim noticed
the attempt. None for every other skill. What a noticed thief then faces is the game's business, not the kernel's.
ThiefSkillRow
Bases: BaseModel
A thief's seven skill chances at one level.
Read the row for a thief's level out of
ClassDefinition.thief_skills, or let
thief_skill_check find it and roll for you. Frozen.
Six of the seven are percentages rolled on d%, succeeding at or under the number.
hear_noise is the odd one out: it is a chance in 6 rolled on 1d6, and the SRD's "1-2" is
stored here as 2. Pick pockets passes 100 at high level, and the check caps the effective
chance at 99 so a theft is never certain.
level
class-attribute
instance-attribute
The thief level this row describes.
climb_sheer_surfaces
class-attribute
instance-attribute
Percent chance to climb a sheer surface. It starts high, at 87 for a first-level thief, because a thief can climb from the start.
find_remove_treasure_traps
class-attribute
instance-attribute
Percent chance to find or disarm a trap on a treasure container, which is not the same as spotting a trap in a room.
hear_noise
class-attribute
instance-attribute
Chance in 6 of hearing something through a door, rolled on 1d6.
hide_in_shadows
class-attribute
instance-attribute
Percent chance to go unseen while staying still in shadow.
move_silently
class-attribute
instance-attribute
Percent chance to move without being heard.
open_locks
class-attribute
instance-attribute
Percent chance to pick a lock, which needs thieves' tools.
WeaponPolicy
Bases: BaseModel
What weapons a class may wield.
Read it as ClassDefinition.weapons;
validate_equip checks against it. It governs weapons
only. A piece of gear a character swings in a pinch, like a torch, is not on the weapons
list and is not refused by it. Frozen.
kind
instance-attribute
kind: WeaponPolicyKind
Whether weapon_ids is the permitted list, the refused list, or unused; see
WeaponPolicyKind.
weapon_ids
class-attribute
instance-attribute
The weapon ids the policy names, from load_equipment; see
the equipment id index. The cleric's five blunt weapons are an example of a permitted list, and
the long bow and two-handed sword the dwarf and halfling are refused are an example of the other. Empty when the
class may use anything.
WeaponPolicyKind
Bases: StrEnum
Whether a class's weapon list names what it may use or what it may not.
Read it as WeaponPolicy.kind. "any" lists nothing and
permits everything, "allowed" lists the only weapons permitted, and "forbidden" lists the
only ones refused. The wire values are those three strings.
ANY
class-attribute
instance-attribute
Any weapon. The class lists none, because none are refused.
ALLOWED
class-attribute
instance-attribute
Only the listed weapons, which is how the cleric is limited to blunt weapons.
XpAwardResult
Bases: BaseModel
What happened when a character received an experience award.
Returned by apply_xp. It contains enough to show a player the
whole story: what the award was, what their class made of it, and whether it took them up a
level. Frozen.
modifier_pct
instance-attribute
modifier_pct: int
The class's experience modifier for this character's scores, as a signed percentage; see
xp_modifier_pct.
xp_after
instance-attribute
xp_after: int
The character's experience after it, which is what is now stored.
level_after
instance-attribute
level_after: int
The level they have after it. At most one higher, because a single award never grants two levels.
clamped
instance-attribute
clamped: bool
True when the award was cut back to keep the character below the level after next. An award big enough to jump two levels stops 1 experience point short of the second threshold, and the rest is lost.
level_up
instance-attribute
level_up: LevelUpResult | None
What the level gain granted, or None when no level was gained; see
LevelUpResult.
XpTier
Bases: BaseModel
One band of the class's experience-modifier table: a percentage, and the scores that earn it.
A class rewards a character whose prime requisite is high and penalizes one whose prime
requisite is low, by adjusting every experience award up or down.
xp_modifier_pct walks a class's tiers in order and
returns the first one whose minimums the character meets, so read the tiers rather than this
model on its own. Frozen.
modifier_pct
instance-attribute
modifier_pct: int
The adjustment as a signed percentage, like 10 for a tenth more experience or -20 for a fifth less.
minimums
instance-attribute
minimums: dict[AbilityScore, int]
The lowest score in each named ability that earns this tier. Every entry must hold for the tier to apply. At least one ability is named.
apply_xp
apply_xp(character: Character, definition: ClassDefinition, award: int, stream: RngStream) -> XpAwardResult
Give a character experience points, and level them up if the award takes them over a threshold.
This is how characters advance. Split the experience a party earned among its members however
your game divides it, then call this once per member. It applies the class's modifier, stores
the new total, and calls level_up when the character has
crossed the next threshold, all in one step, so you never have to check thresholds yourself.
A single award never grants two levels. An award large enough to reach the level after next is cut back to one point short of that second threshold, and the excess is lost, so a character who kills a dragon at first level ends up at second and has to earn the rest. The result says when that happened.
At the class's maximum level the character stops gaining levels but keeps accumulating experience, uncut, because there is no further threshold to keep them under.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
character
|
Character
|
The character receiving the award. Mutated in place: experience, and on a level gain the level and hit points too. |
required |
definition
|
ClassDefinition
|
The character's class. It must be the character's own class. |
required |
award
|
int
|
The experience to award, before the class modifier. Not negative. |
required |
stream
|
RngStream
|
The stream for a hit die if the award levels the character up, conventionally
|
required |
Returns:
| Type | Description |
|---|---|
XpAwardResult
|
The whole story of the award: what it was, what the class made of it, and what the |
XpAwardResult
|
character gained. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import (
ADVANCEMENT_STREAM,
CHARACTER_CREATION_STREAM,
create_character,
)
from osrlib.core.classes import apply_xp
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_classes
streams = RngStreams(master_seed=2)
fighter = load_classes().get("fighter")
character = create_character(
name="Rurik",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=Ruleset(),
stream=streams.get(CHARACTER_CREATION_STREAM),
).character
result = apply_xp(character, fighter, 10000, streams.get(ADVANCEMENT_STREAM))
print(result.modified_award, result.xp_after, result.level_after, result.clamped)
# 10000 3999 2 True
detection_chance
detection_chance(character: Character, definition: ClassDefinition, kind: str) -> int
Return this character's chance in 6 at one kind of search.
Call it before detection_check, which rolls against
the number it gives you. It reads the class's abilities and, for a thief listening, the level's
skill row, so it answers for whoever is searching without you having to know which classes are
good at what.
Listening at doors takes the thief's hear_noise chance when the character is a thief, else
the class's own listening ability, else the 1 in 6 anyone gets. Searching for a secret door
takes the class's detect_secret_doors ability, which the elf has at 2, else 1. Looking for a
trap in a room takes detect_room_traps, which the dwarf has at 2, else 1. Noticing a shift
in the stonework takes detect_construction_tricks, which the dwarf has at 2, and everyone
else gets zero: the SRD gives this perception to dwarves and states no chance for anyone else,
unlike the searches it opens to every character.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
character
|
Character
|
The character searching. Their level chooses a thief's skill row. |
required |
definition
|
ClassDefinition
|
The character's class. |
required |
kind
|
str
|
What they are searching for: |
required |
Returns:
| Type | Description |
|---|---|
int
|
The chance in 6. Zero means the character cannot do it at all, and |
int
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.classes import detection_chance
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_classes
dwarf = load_classes().get("dwarf")
character = create_character(
name="Thora",
class_id="dwarf",
alignment=Alignment.LAWFUL,
ruleset=Ruleset(),
stream=RngStreams(master_seed=1).get(CHARACTER_CREATION_STREAM),
).character
print(detection_chance(character, dwarf, "room_traps"))
# 2
print(detection_chance(character, dwarf, "secret_doors"))
# 1
detection_check
detection_check(chance_in_six: int, *, stream: RngStream) -> DetectionResult
Roll a chance-in-6 check: 1d6, succeeding at or under the chance.
This is the one roll behind searching a wall for a secret door, listening at a door, spotting
a trap in a room, and a dwarf noticing that the stonework is wrong. Get the chance from
detection_chance, which works out what this
character's chance at this kind of search is, then pass it here.
A chance of zero, or below, fails without throwing a die and takes no draw from the stream. Anyone but a dwarf looking for a shift in the stonework has no chance at all, and rolling for them would both mislead the player and shift every later draw.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chance_in_six
|
int
|
The chance to roll at or under, usually from
|
required |
stream
|
RngStream
|
The stream to draw from, conventionally the one a crawl names
|
required |
Returns:
| Type | Description |
|---|---|
DetectionResult
|
The roll and whether it passed, with |
Examples:
from osrlib.core.classes import detection_check
from osrlib.core.rng import RngStreams, StreamName
stream = RngStreams(master_seed=4).get(StreamName.EXPLORATION)
result = detection_check(2, stream=stream)
print(result.roll, result.passed)
# 2 True
nothing = detection_check(0, stream=stream)
print(nothing.roll, nothing.passed)
# None False
drain_levels
drain_levels(
character: Character,
definition: ClassDefinition,
*,
levels: int = 1,
xp_policy: str,
stream: RngStream,
spawn_consequence: str | None = None
) -> DrainResult
Take experience levels away from a character, undoing what level_up did.
Call it when an undead creature that drains levels lands a hit: the wight takes one level, the
spectre and the vampire take two. Read the monster's energy_drain ability with
MonsterTemplate.ability for the number of
levels and the experience policy, then pass them here. The attack itself resolves in
osrlib.core.combat. This is the consequence.
Each level is taken exactly as it was given, reading the same two settings
level_up reads. When the level being lost had more hit dice
than the level below it, the character throws that die and loses the result plus their CON
modifier, at least 1, with CON counting only when the row it came from says it does. When the
two rows have the same number of dice, the loss is the difference between their flat bonuses
and no die is thrown. Rolling the die back is what lets the model stay stateless: a character
keeps no record of which dice built their hit points, so the drain rolls a fresh one. THAC0,
saving throws, and spell capacity need nothing done to them, because they are read from the
level.
A character never drops below 1 maximum or 1 current hit point while they still have a level.
Death comes only from losing the last one, which is the SRD's person drained of all levels: the
result reports slain, the events include the death, and any spells that no longer fit the
shrunken capacity are forgotten newest first.
Experience is rewritten once, after every level is taken. Under "halfway" the character keeps
the midpoint between the threshold they had reached and the one they fell back to. Under
"level_minimum" they keep exactly the new level's threshold.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
character
|
Character
|
The character being drained. Mutated in place: level, experience, and both hit point totals change. |
required |
definition
|
ClassDefinition
|
The character's class. It must be the character's own class. |
required |
levels
|
int
|
How many levels to take. The procedure runs once per level. |
1
|
xp_policy
|
str
|
|
required |
stream
|
RngStream
|
The stream for the hit dice thrown back, conventionally
|
required |
spawn_consequence
|
str | None
|
What the victim becomes, in the monster's own words, put on the drain event for a front end to show. Nothing acts on it. |
None
|
Returns:
| Type | Description |
|---|---|
DrainResult
|
What was lost, and the events to publish. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import (
ADVANCEMENT_STREAM,
CHARACTER_CREATION_STREAM,
create_character,
)
from osrlib.core.classes import apply_xp, drain_levels
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_classes
streams = RngStreams(master_seed=2)
fighter = load_classes().get("fighter")
character = create_character(
name="Rurik",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=Ruleset(),
stream=streams.get(CHARACTER_CREATION_STREAM),
).character
advancement = streams.get(ADVANCEMENT_STREAM)
apply_xp(character, fighter, 2500, advancement)
print(character.level, character.xp, character.max_hp)
# 2 2500 10
result = drain_levels(character, fighter, levels=1, xp_policy="halfway", stream=advancement)
print(result.levels_lost, result.new_level, result.hp_lost, result.slain)
# 1 1 9 False
print(character.level, character.xp, character.max_hp)
# 1 1000 1
level_title
level_title(definition: ClassDefinition, level: int) -> str | None
Return what a character of this class and level is called, like "Veteran".
Use it wherever you show a character's standing: a sheet, a party roster, the line a front end prints when someone levels up.
The SRD prints titles only up to name level, the level at which a character may build a
stronghold, so a character past that has no title and this returns None. Show the class name
instead when it does.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
definition
|
ClassDefinition
|
The character's class. |
required |
level
|
int
|
The level to name, 1 or higher. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The title, or |
Examples:
level_up
level_up(character: Character, definition: ClassDefinition, stream: RngStream) -> LevelUpResult
Raise a character one level and roll the hit points that come with it.
Use apply_xp for ordinary play, which awards experience and
calls this when a threshold is crossed. Call this directly when a level is granted outright
rather than earned: building a character above first level, a referee's ruling, restoring a
level a wight took.
Two things about the new level's progression row decide what the gain is, and they are read
separately. Whether a die is rolled depends on the row having more hit dice than the row below
it: when it does, the character rolls one, and when it does not, the gain is the difference
between the two rows' flat bonuses and no die is thrown. Whether the CON modifier counts
depends on the new row's con_applies, which the SRD clears at the levels it marks with an
asterisk. A rolled die with CON cleared gains the raw die alone, and
con_applied on the result says which way it
went.
For the classes osrlib ships, both settings change over at name level, so a character rolls with CON up to name level and takes a flat gain without CON after it. A class added as data can set them independently, which is why the result reports them rather than leaving you to work one out from the other.
A rolled gain is floored at 1 hit point, however poor the die and the CON modifier are together. Both maximum and current hit points rise by the gain, so a level heals nothing: a wounded character is still wounded, with a higher ceiling.
Nothing else needs updating. THAC0, saving throws, and spell capacity are read from the progression row for the new level, so they change on their own.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
character
|
Character
|
The character to advance. Mutated in place: its level, maximum hit points, and current hit points all change. |
required |
definition
|
ClassDefinition
|
The character's class. It must be the character's own class. |
required |
stream
|
RngStream
|
The stream for the hit die, conventionally
|
required |
Returns:
| Type | Description |
|---|---|
LevelUpResult
|
What the level gained, including the raw die when one was thrown. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import (
ADVANCEMENT_STREAM,
CHARACTER_CREATION_STREAM,
create_character,
)
from osrlib.core.classes import level_up
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_classes
streams = RngStreams(master_seed=2)
fighter = load_classes().get("fighter")
character = create_character(
name="Rurik",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=Ruleset(),
stream=streams.get(CHARACTER_CREATION_STREAM),
).character
result = level_up(character, fighter, streams.get(ADVANCEMENT_STREAM))
print(result.new_level, result.hp_roll, result.hp_gained)
# 2 1 2
print(character.level, character.max_hp, character.thac0)
# 2 10 19
thief_skill_check
thief_skill_check(
character: Character, definition: ClassDefinition, skill: str, *, modifier_pct: int = 0, stream: RngStream
) -> SkillCheckResult
Roll one of a thief's skills and return how it came out.
Call it when a thief tries something their skills cover: climbing a wall, listening at a door,
lifting a purse. It rolls and reports, nothing more. It emits no events and hides nothing, so
a front end that shows players only what their characters would know must decide for itself
what to reveal. Inside a crawl, the commands in
osrlib.crawl.exploration call it and emit the events for you.
The six skills in
PERCENTILE_THIEF_SKILLS roll d% and succeed at
or under the chance for the thief's level. "hear_noise" rolls 1d6 against a chance in 6
instead, and ignores modifier_pct.
Pick pockets has two rules of its own. Stealing from someone above fifth level is harder, by
5% per level above the fifth, and you fold that into modifier_pct yourself, because the
kernel never sees the victim. The chance then caps at 99, so a theft is never certain, and a
roll of more than twice the chance means the victim noticed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
character
|
Character
|
The thief making the attempt. Their level chooses the row. |
required |
definition
|
ClassDefinition
|
The character's class, which must have a thief skill table. |
required |
skill
|
str
|
One of the names in
|
required |
modifier_pct
|
int
|
A percentage added to the chance before rolling, negative to make the
attempt harder. Ignored for |
0
|
stream
|
RngStream
|
The stream to draw from, conventionally the one a crawl names
|
required |
Returns:
| Type | Description |
|---|---|
SkillCheckResult
|
The roll, the chance it was measured against, and whether it passed. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the class has no thief skills, or the skill name is not one this function knows. Deciding who is allowed to try a skill is yours to do before calling. |
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.classes import thief_skill_check
from osrlib.core.rng import RngStreams, StreamName
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_classes
thief = load_classes().get("thief")
character = create_character(
name="Nim",
class_id="thief",
alignment=Alignment.NEUTRAL,
ruleset=Ruleset(),
stream=RngStreams(master_seed=4).get(CHARACTER_CREATION_STREAM),
).character
stream = RngStreams(master_seed=1).get(StreamName.EXPLORATION)
result = thief_skill_check(character, thief, "climb_sheer_surfaces", stream=stream)
print(result.roll, result.chance, result.passed)
# 65 87 True
stream = RngStreams(master_seed=9).get(StreamName.EXPLORATION)
theft = thief_skill_check(character, thief, "pick_pockets", stream=stream)
print(theft.roll, theft.chance, theft.passed, theft.noticed)
# 100 20 False True
xp_modifier_pct
xp_modifier_pct(definition: ClassDefinition, scores: dict[AbilityScore, int]) -> int
Return how much a class adjusts this character's experience awards, as a percentage.
A class rewards a high prime requisite and penalizes a low one by changing every experience
award. apply_xp calls this for you, so call it yourself only
to show a player the number on a character sheet, or to let them see what raising a score at
creation would buy them.
The tiers are stored best first, and the first one whose minimums the character meets wins. A character who meets none gets no adjustment, which is how the elf and the halfling end up with a bonus band and no penalty band, as the SRD prints them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
definition
|
ClassDefinition
|
The character's class. |
required |
scores
|
dict[AbilityScore, int]
|
The character's final ability scores, after any creation-time adjustment. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The adjustment as a signed percentage: |
int
|
for no change. |
Examples:
from osrlib.core.abilities import AbilityScore
from osrlib.core.classes import xp_modifier_pct
from osrlib.data import load_classes
fighter = load_classes().get("fighter")
scores = dict.fromkeys(AbilityScore, 12)
scores[AbilityScore.STR] = 16
print(xp_modifier_pct(fighter, scores))
# 10
scores[AbilityScore.STR] = 5
print(xp_modifier_pct(fighter, scores))
# -20