osrlib.core.character
The player character: the model, creating one step by step, and saving one to a document.
Start here when you need a party. create_character is
the entry point: hand it a name, a class id, an alignment, a
Ruleset, and a seeded random-number stream from
osrlib.core.rng, and it returns a first-level
Character together with the raw dice it rolled. Put the
characters you get into a Party to play them, or use them on their
own with the combat, magic, and item functions of the core kernel.
Character is a mutable pydantic model. Its derived values,
which are the ability modifiers, both armour classes, movement rate, and the language list, are
properties computed from stored state rather than stored fields, so they cannot fall out of step
with the state they come from. Validation on the model is structural: score ranges, a level
within the class's bounds, current hit points no higher than maximum. Whether a creation step was
legal is checked by the creation functions when the step happens, because a finished character
keeps no record of the choices that made it.
Creation follows the OSE SRD's Creating a Character steps as pure functions you drive one at a time
when a player is making the choices:
roll_ability_scores,
validate_class_choice,
apply_adjustment,
validate_starting_spells with
choose_starting_spells,
roll_hit_points,
validate_extra_languages,
roll_starting_gold, and then buying and equipping
gear through osrlib.core.items. Each validating step returns a list of
Rejection records rather than raising, so you can show the
player what went wrong and let them choose again. Creation emits no events: it happens before a
session starts, and the first events belong to play.
create_character runs the whole sequence in one call
when every choice is known upfront, and raises instead of returning rejections.
Advancement, which is leveling up, energy drain, and experience awards, lives in
osrlib.core.classes, the module that also defines the class a character
plays. Saving a character to disk goes through
to_document and
party_to_document, whose output
osrlib.persistence writes as part of a whole-game save.
Two stream keys are the naming convention every session adopts:
CHARACTER_CREATION_STREAM for creation draws
and ADVANCEMENT_STREAM for in-play level-up hit point
rolls. They stay separate so that a change to the creation rules never shifts advancement draws
already recorded in a save.
Typical usage:
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.party import Party
streams = RngStreams(master_seed=2)
stream = streams.get(CHARACTER_CREATION_STREAM)
result = create_character(
name="Rurik",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=Ruleset(),
stream=stream,
purchases=[("sword", 1), ("leather", 1)],
equip_ids=["sword", "leather"],
)
party = Party(members=[result.character])
print(party.members[0].name, party.members[0].max_hp, party.members[0].armour_class)
# Rurik 8 6
ABILITY_ROLL_ORDER
module-attribute
ABILITY_ROLL_ORDER = (
AbilityScore.STR,
AbilityScore.INT,
AbilityScore.WIS,
AbilityScore.DEX,
AbilityScore.CON,
AbilityScore.CHA,
)
The order roll_ability_scores draws the six abilities.
This is the SRD's listing order, and it is fixed. Read it when you are displaying rolled scores in the order the dice came up, or when you are reproducing a draw sequence by hand from a recorded seed. The order is part of what makes a seeded creation reproducible, so a caller cannot change it.
ADVANCEMENT_STREAM
module-attribute
ADVANCEMENT_STREAM = StreamName.ADVANCEMENT
The stream key every session uses for in-play advancement draws.
Pass streams.get(ADVANCEMENT_STREAM) as the stream argument of
level_up, apply_xp, and
drain_levels, which roll hit dice on it.
It is separate from
CHARACTER_CREATION_STREAM so that a change to
the creation rules, which would consume a different number of draws, never shifts advancement rolls
a save has already recorded.
CHARACTER_CREATION_STREAM
module-attribute
CHARACTER_CREATION_STREAM = StreamName.CHARACTER_CREATION
The stream key every session uses for creation draws.
A stream key names one independent random-number sequence inside an
RngStreams set. Pass
streams.get(CHARACTER_CREATION_STREAM) as the stream argument of
create_character and of the stepwise creation
functions, which draw ability scores, the first-level hit die, and starting gold from it in that
order.
Use a different key only when you want creation draws kept apart from the ones a session already
records, for example when you roll throwaway characters beside a live game. Pass your own key to
streams.get; do not change this constant, because a save replays every stream by the key that
produced it.
AbilityScoreRolls
Bases: BaseModel
The six rolled ability scores, with the individual dice kept so you can show them.
roll_ability_scores returns this, and
create_character includes it in its result. Frozen:
a roll is history and does not change.
scores
instance-attribute
scores: dict[AbilityScore, int]
The total of each ability's three dice, keyed by AbilityScore. All six
are present, each 3 through 18.
Character
Bases: BaseModel
A player character: the stored state of one played person, and the values derived from it.
Get one from create_character, from the stepwise
creation functions in this module, or from
from_document when reloading a save.
Construct one directly only when you already have every value, as in a test fixture.
Put characters in a Party to explore with them, hand one to the
combat functions in osrlib.core.combat as an attacker or a target, to
osrlib.core.spells as a caster, and to
level_up or apply_xp to
advance it. A MonsterInstance exposes the same
combatant surface, so those functions take either.
The model is mutable and validates on assignment: setting a field that breaks a rule raises rather than storing the bad value. Validation is structural only. It checks that the six scores are present and in range, that the level is within the class's maximum, and that current hit points do not exceed maximum hit points. It does not check that the character was created legally, because a finished character keeps no record of its own creation.
Nothing derived is stored. THAC0, attack bonus, saving throws, ability modifiers, both armour classes, and the language list are properties recomputed from the stored fields every time you read them, so a level change or a swapped piece of armour shows up immediately and nothing can fall out of step.
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
stream = RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM)
character = create_character(
name="Rurik",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=Ruleset(),
stream=stream,
).character
print(character.level, character.thac0, character.saves.death)
# 1 19 12
character.current_hp -= 3
print(character.current_hp, character.max_hp)
# 5 8
id
class-attribute
instance-attribute
id: str | None = None
The entity id, which a GameSession assigns from its
IdAllocator when the character joins. None until then, and events fall back
to the name while it is unset.
name
class-attribute
instance-attribute
The character's name. At least one character long.
class_id
instance-attribute
class_id: str
The class this character plays, like "fighter". Valid ids come from load_classes;
see the class id index. Read definition to get the
class itself.
race
class-attribute
instance-attribute
The character's people, like "human" or "dwarf", as a lowercase identifier. Creation copies it from the
class. No rules procedure reads it: racial abilities resolve through the class's ability tags instead, so a new race
needs no code.
level
class-attribute
instance-attribute
The experience level, 1 through the class's maximum.
xp
class-attribute
instance-attribute
Experience points accumulated. Advancement compares this against the thresholds in the class's progression table;
award XP with apply_xp rather than by assigning here.
scores
instance-attribute
scores: dict[AbilityScore, int]
The six ability scores, each 3 through 18, keyed by AbilityScore. All six
must be present.
alignment
instance-attribute
alignment: Alignment
Lawful, neutral, or chaotic. It also fixes
alignment_tongue.
extra_languages
class-attribute
instance-attribute
The extra language ids a high INT granted at creation, validated by
validate_extra_languages.
current_hp
class-attribute
instance-attribute
Current hit points, from 0 up to max_hp. Reaching 0 means the character has dropped. Death itself is the
dead condition, applied by kill, rather than a hit point value.
inventory
class-attribute
instance-attribute
Everything carried, worn, and wielded, plus the purse. See Inventory and the
buying and equipping functions in osrlib.core.items.
carrying_treasure
class-attribute
instance-attribute
carrying_treasure: bool = False
Whether the character is carrying enough treasure to slow them down. Basic encumbrance leaves the threshold to
the referee, so osrlib leaves it to you: set this flag and
movement_rate drops the rate a step. Ignored under the other
encumbrance modes.
conditions
class-attribute
instance-attribute
conditions: tuple[ActiveCondition, ...] = ()
The conditions in effect, like poisoned or paralyzed. Apply and clear them through
osrlib.core.effects; test one with has_condition.
stat_modifiers
class-attribute
instance-attribute
stat_modifiers: tuple[ActiveModifier, ...] = ()
Timed bonuses and penalties from spells and items, applied by osrlib.core.effects.
spell_book
class-attribute
instance-attribute
The spell ids an arcane caster can memorize from, in the order they were learned. Empty for clerics, whose spells come from their deity, and for non-casters.
memorized_spells
class-attribute
instance-attribute
memorized_spells: tuple[MemorizedSpell, ...] = ()
The prepared copies a caster may cast, as MemorizedSpell records in
memorization order. The order matters: casting spends the first matching copy, and energy drain forgets the newest
first. How many of each level fit is derived from the class's progression row, not stored, so leveling up and being
drained both recompute it.
definition
property
definition: ClassDefinition
The class this character plays, looked up from the loaded catalog by class_id.
Read it for anything that depends on the class: the progression table, the armour and weapon policies, the class abilities, the level titles. The catalog is loaded once and cached, so reading this property repeatedly costs nothing.
thac0
property
thac0: int
The number the character must roll to hit armour class 0, from the current level's row.
Descending armour class is the SRD's default presentation. Use
attack_bonus instead if your front end
shows ascending armour class. The two describe the same attack.
attack_bonus
property
attack_bonus: int
The bonus added to an attack roll under ascending armour class, from the current level's row.
The ascending presentation of thac0. Both come
from the same progression row, so they always agree.
saves
property
saves: SavingThrows
The five saving throw targets for the current level.
Roll a d20 against the relevant field and succeed on that number or higher. Leveling and energy drain both change this the moment they change the level, because it is read from the progression row rather than stored.
melee_modifier
property
melee_modifier: int
The STR modifier added to melee attack rolls and to melee damage, from −3 to +3.
open_doors_chance
property
open_doors_chance: int
The chance in 6 of forcing a stuck door open, from STR.
Roll 1d6 and succeed on this number or less. Force-door attempts in a crawl go through
osrlib.crawl.exploration, which reads this for you.
missile_modifier
property
missile_modifier: int
The DEX modifier added to missile attack rolls, from −3 to +3. It does not change damage.
initiative_modifier
property
initiative_modifier: int
The DEX modifier to an individual initiative roll, from −2 to +2.
It applies only when the individual_initiative flag of the
Ruleset is on. Group initiative, the default, rolls once
per side and ignores it.
hit_point_modifier
property
hit_point_modifier: int
The CON modifier added to each Hit Die rolled, from −3 to +3.
Creation and level_up add it per die and floor the gain
at 1 hit point, so a poor CON never costs a character hit points outright.
magic_save_modifier
property
magic_save_modifier: int
The WIS modifier applied to saving throws against magical effects, from −3 to +3.
npc_reaction_modifier
property
npc_reaction_modifier: int
The CHA modifier applied to NPC reaction rolls, from −2 to +2.
Reaction rolls during a crawl read it for you; see
osrlib.crawl.encounter.
alignment_tongue
property
alignment_tongue: str
The secret language shared by everyone of this alignment, as a language id.
Every character speaks the tongue of their own alignment and no other. The id is
alignment_ followed by the alignment's wire value, so a lawful character speaks
"alignment_lawful". These are not entries in the language catalog that
load_languages returns. They are derived here, so changing
a character's alignment changes the tongue in the same moment.
languages
property
Every language the character speaks, as ids, in a fixed order.
The alignment tongue comes first, then the languages the class grants with Common at the
front, then the extras a high INT bought at creation. Compare against another speaker's
list to decide whether two people can talk to each other. The ids other than the alignment
tongue are catalog ids from load_languages; see
the language id index.
armour_class
property
armour_class: int
The character's armour class in the descending presentation, where lower is better.
Unarmoured is 9. Worn armour sets the base, shields and always-active magic items subtract
their bonus, and the DEX modifier subtracts on top. Equipping or removing armour changes
this at once, because it is computed from the inventory rather than stored. Use
armour_class_ascending if your
front end shows ascending armour class.
armour_class_ascending
property
armour_class_ascending: int
The character's armour class in the ascending presentation, where higher is better.
Unarmoured is 10, and every bonus adds. It describes the same defense as
armour_class. The two presentations
always agree.
movement_rate
Return how far this character moves in one exploration turn, in feet.
What the rate depends on is the ruleset's encumbrance mode: nothing at all under none,
the worn armour category and the
carrying_treasure flag under basic, and the total
weight carried under detailed. A character loaded past the maximum cannot move and gets
0.
A party moves at its slowest living member's rate, so for a party call
Party.movement_rate instead of calling this
per member.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ruleset
|
Ruleset
|
The ruleset in play, whose encumbrance mode governs what counts. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The rate in feet per turn: 120, 90, 60, 30, or 0. |
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
ruleset = Ruleset()
stream = RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM)
character = create_character(
name="Rurik",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=ruleset,
stream=stream,
purchases=[("plate_mail", 1)],
equip_ids=["plate_mail"],
).character
print(character.movement_rate(ruleset))
# 60
to_document
Return this character as a JSON-ready document stamped with its schema and engine versions.
The stamp is what lets a later version of osrlib decide whether it can read the document.
Use this to store one character on its own, and use
party_to_document for a whole party, and the
save functions in osrlib.persistence to store a session, which already includes
its characters.
Read it back with
from_document.
Returns:
| Type | Description |
|---|---|
dict[str, object]
|
The stamped envelope, whose payload is the serialized character. Every value is a |
dict[str, object]
|
JSON type, so you can hand the result straight to |
dict[str, object]
|
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, Character, create_character
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
stream = RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM)
character = create_character(
name="Rurik",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=Ruleset(),
stream=stream,
).character
document = character.to_document()
print(sorted(document))
# ['engine_version', 'kind', 'payload', 'schema_version']
print(Character.from_document(document).name)
# Rurik
from_document
classmethod
Rebuild a character from a document written by to_document.
Fields in the payload that this version does not recognize are ignored, so a document written by a later release that only added fields still loads. A document whose schema version is newer than this release understands is refused instead, because the meaning of what it does know may have changed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
document
|
Mapping[str, object]
|
The stamped envelope, as returned by
|
required |
Returns:
| Type | Description |
|---|---|
Character
|
The character, with every derived value recomputed from the loaded state. |
Raises:
| Type | Description |
|---|---|
ContentValidationError
|
If the envelope is not a character document, or the payload does not validate as a character. |
SaveVersionError
|
If the document's schema version is newer than this library understands. |
CharacterCreationResult
Bases: BaseModel
What create_character returns: the character and its dice.
The rolls are here so a front end can show a player how their character came out rather than only the finished numbers. Frozen.
character
instance-attribute
character: Character
The finished Character, at first level with its purchases bought and its
equipment worn.
ability_rolls
instance-attribute
ability_rolls: AbilityScoreRolls
The ability scores as rolled, before any adjustment the caller asked for. Compare against character.scores to
show what the adjustment moved.
hit_point_roll
instance-attribute
hit_point_roll: HitPointRoll
The first-level hit die, or dice if the re-roll option was on.
gold_roll
instance-attribute
gold_roll: RollResult
The 3d6 × 10 starting money roll. Its total is the gold the character began with, before the purchases were
paid for.
HitPointRoll
Bases: BaseModel
A first-level hit point roll: every die that was thrown, and the total it came to.
roll_hit_points returns this. Frozen.
rolls
instance-attribute
Every raw die result, in the order thrown. With the hp_reroll_at_first_level option on, the rejected 1s and 2s
are here too, and the last entry is the die that stood.
choose_starting_spells
choose_starting_spells(
character: Character, definition: ClassDefinition, catalog: SpellCatalog, spell_ids: Sequence[str]
) -> list[Rejection]
Write an arcane caster's starting spell book onto the character.
Use it when you are creating a character step by step and the player has picked their first
spell. It checks the pick with
validate_starting_spells and refuses a
character whose book is already written, then stores the ids on
spell_book. Nothing is written when anything is refused,
so the character is never left half-changed.
create_character does this for you when you pass
starting_spell_ids.
A written book is not yet a memorized spell. To prepare spells for casting, go through
osrlib.core.spells, which fills the character's memorization slots from
the book.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
character
|
Character
|
The character to write to. Mutated in place when the pick is legal. |
required |
definition
|
ClassDefinition
|
The character's class. |
required |
catalog
|
SpellCatalog
|
The spell catalog from |
required |
spell_ids
|
Sequence[str]
|
The chosen spell ids; see the spell id index. |
required |
Returns:
| Type | Description |
|---|---|
list[Rejection]
|
One |
list[Rejection]
|
written. |
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import (
CHARACTER_CREATION_STREAM,
choose_starting_spells,
create_character,
)
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_classes, load_spells
magic_user = load_classes().get("magic_user")
stream = RngStreams(master_seed=3).get(CHARACTER_CREATION_STREAM)
character = create_character(
name="Miri",
class_id="magic_user",
alignment=Alignment.NEUTRAL,
ruleset=Ruleset(),
stream=stream,
starting_spell_ids=["sleep"],
).character
rejections = choose_starting_spells(character, magic_user, load_spells(), ["magic_missile"])
print([rejection.code for rejection in rejections])
# ['magic.book.already_chosen']
print(character.spell_book)
# ('sleep',)
create_character
create_character(
*,
name: str,
class_id: str,
alignment: Alignment,
ruleset: Ruleset,
stream: RngStream,
adjustment: AbilityAdjustment | None = None,
starting_spell_ids: Sequence[str] = (),
extra_languages: Sequence[str] = (),
purchases: Sequence[tuple[str, int]] = (),
equip_ids: Sequence[str] = ()
) -> CharacterCreationResult
Create a first-level character, making every choice you pass in one call.
This is where a new caller starts. Give it a name, a class, an alignment, a ruleset, and a
seeded stream, and it rolls a whole character: ability scores, hit points, starting gold, and
the gear you asked it to buy. Put the characters you get into a
Party and you have something to play with.
To get a stream, build an RngStreams set from a seed and ask it
for the creation stream: RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM). The same
seed always produces the same character, which is what makes a game replayable and a test
repeatable. Pass the same stream to several calls to roll a whole party from one seed, and each
call continues where the last one left off.
Every decision is yours to supply, because the same call has to serve a player picking from a
menu and a script rolling a hundred characters. Drive the stepwise functions in this module
instead when a person is choosing as they go: those hand back
Rejection records you can show, where this function
raises on the first illegal choice and reports nothing more.
The steps run in the SRD's order: roll the six scores, check them against the class requirements, apply the ability adjustment, write the spell book, roll hit points, check the extra languages, roll starting gold, then buy and equip. Draws come off the stream in that order, so scores are always drawn first and gold last. Writing the spell book and checking languages consume no draws.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The character's name. |
required |
class_id
|
str
|
The class to play, like |
required |
alignment
|
Alignment
|
Lawful, neutral, or chaotic. |
required |
ruleset
|
Ruleset
|
The ruleset in play. Its |
required |
stream
|
RngStream
|
The stream for the creation draws, conventionally
|
required |
adjustment
|
AbilityAdjustment | None
|
An optional trade of points between abilities, built as an
|
None
|
starting_spell_ids
|
Sequence[str]
|
The spells written in an arcane caster's book, from
|
()
|
extra_languages
|
Sequence[str]
|
The extra languages a high INT bought, from
|
()
|
purchases
|
Sequence[tuple[str, int]]
|
What to buy from the starting gold, as |
()
|
equip_ids
|
Sequence[str]
|
Which of the bought items to wear or wield, in order. An item must have been bought first, and the class's armour and weapon policies must allow it. |
()
|
Returns:
| Type | Description |
|---|---|
CharacterCreationResult
|
The finished character together with the dice creation rolled, so you can show a player how |
CharacterCreationResult
|
they came out. |
Raises:
| Type | Description |
|---|---|
ValueError
|
On the first illegal decision: an unknown class, spell, language, or item id; scores that miss the class requirements; an adjustment the class forbids; a spell book of the wrong size; more languages than the INT allows; a purchase the starting gold cannot cover; or an item the class may not equip. Drive the stepwise functions yourself when you need to know which choices failed and why. |
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.party import Party
streams = RngStreams(master_seed=2)
stream = streams.get(CHARACTER_CREATION_STREAM)
result = create_character(
name="Rurik",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=Ruleset(),
stream=stream,
purchases=[("sword", 1), ("leather", 1)],
equip_ids=["sword", "leather"],
)
character = result.character
print(character.name, character.level, character.max_hp, character.armour_class)
# Rurik 1 8 6
print(character.inventory.worn_armour.template.id, character.inventory.purse.gp)
# leather 30
print(result.gold_roll.total, result.hit_point_roll.rolls)
# 60 (7,)
party = Party(members=[character])
print(party.movement_rate(Ruleset()))
# 90
party_from_document
Rebuild the characters in a document written by party_to_document.
Each character is validated on the way in, so a malformed member fails the whole load rather than producing a half-built roster.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
document
|
Mapping[str, object]
|
The stamped envelope, as returned by
|
required |
Returns:
| Type | Description |
|---|---|
list[Character]
|
The characters, in the order they were stored. |
Raises:
| Type | Description |
|---|---|
ContentValidationError
|
If the envelope is not a party document, if its payload contains no
|
SaveVersionError
|
If the document's schema version is newer than this library understands. |
party_to_document
Return a group of characters as one JSON-ready document, stamped with its versions.
Use this to store a roster you build once and reuse, like a set of pre-generated characters
a front end offers at the start of a game. It writes the characters and nothing else: marching
order, shared light sources, and the rest of a playing party's state belong to
Party and are saved with the session by
osrlib.persistence.
Read it back with
party_from_document, which returns the
characters in the order you passed them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
characters
|
Sequence[Character]
|
The characters to store, in the order you want them back. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, object]
|
The stamped envelope, whose payload contains the serialized characters under |
dict[str, object]
|
|
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import (
CHARACTER_CREATION_STREAM,
create_character,
party_from_document,
party_to_document,
)
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
stream = RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM)
roster = [
create_character(
name=name,
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=Ruleset(),
stream=stream,
).character
for name in ("Rurik", "Alia")
]
document = party_to_document(roster)
print([member.name for member in party_from_document(document)])
# ['Rurik', 'Alia']
roll_ability_scores
roll_ability_scores(stream: RngStream) -> AbilityScoreRolls
Roll 3d6 for each of the six abilities, in the SRD's order: STR, INT, WIS, DEX, CON, CHA.
This is the first step of creating a character by hand. Show the result to the player, then
pass the scores to validate_class_choice to
find out which classes they qualify for. If the player wants to trade points between abilities,
run apply_adjustment after the class is chosen, the
order the SRD sets.
Call create_character instead when the choices are
already known and you only want the finished character.
The draw order never changes. It is what makes a seeded creation reproducible, so the same stream at the same position always yields the same six scores.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stream
|
RngStream
|
The stream to draw from, conventionally
|
required |
Returns:
| Type | Description |
|---|---|
AbilityScoreRolls
|
The six scores and the three dice behind each. |
Examples:
from osrlib.core.character import CHARACTER_CREATION_STREAM, roll_ability_scores
from osrlib.core.rng import RngStreams
stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
rolled = roll_ability_scores(stream)
print({ability.value: score for ability, score in rolled.scores.items()})
# {'str': 14, 'int': 6, 'wis': 9, 'dex': 10, 'con': 11, 'cha': 11}
roll_hit_points
roll_hit_points(definition: ClassDefinition, con_modifier: int, ruleset: Ruleset, stream: RngStream) -> HitPointRoll
Roll a first-level character's hit points: the class hit die plus the CON modifier, at least 1.
Call it after the class is chosen and the scores are adjusted, so the CON modifier you pass is
the final one. Get that modifier from
load_ability_tables().hit_point_modifier(score), or read
hit_point_modifier off a character that
already exists.
Use level_up for hit points gained later. This function is
for first level only, and it is the only one that honours the re-roll option.
With hp_reroll_at_first_level on in the ruleset, a die showing 1 or 2 is thrown again, and
again, until it shows 3 or more. The test is on the raw die, before the CON modifier, and every
throw is kept in the result.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
definition
|
ClassDefinition
|
The character's class, whose progression row gives the hit die. |
required |
con_modifier
|
int
|
The CON hit point modifier for the final scores. It may be negative, and the total is floored at 1 either way. |
required |
ruleset
|
Ruleset
|
The ruleset in play, read for the |
required |
stream
|
RngStream
|
The stream to draw from, conventionally
|
required |
Returns:
| Type | Description |
|---|---|
HitPointRoll
|
Every die thrown and the hit points to start with. |
Examples:
from osrlib.core.character import CHARACTER_CREATION_STREAM, roll_hit_points
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_classes
fighter = load_classes().get("fighter")
stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
print(roll_hit_points(fighter, 0, Ruleset(), stream))
# rolls=(2,) hit_points=2
rerolling = Ruleset(hp_reroll_at_first_level=True)
stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
print(roll_hit_points(fighter, 0, rerolling, stream))
# rolls=(2, 8) hit_points=8
roll_starting_gold
roll_starting_gold(stream: RngStream) -> RollResult
Roll a new character's starting money: 3d6 × 10 gold pieces.
This is the last draw of creation. Put the total into the character's purse
(character.inventory.purse.gp), then spend it with
validate_purchase and
purchase from the equipment catalog.
create_character does all of that for you when you
pass purchases.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stream
|
RngStream
|
The stream to draw from, conventionally
|
required |
Returns:
| Type | Description |
|---|---|
RollResult
|
The |
RollResult
|
pieces, between 30 and 180. |
Examples:
validate_class_choice
validate_class_choice(scores: dict[AbilityScore, int], definition: ClassDefinition) -> list[Rejection]
Check whether a set of rolled scores meets a class's minimum requirements.
Call it after roll_ability_scores and before the
ability adjustment, which is the order the SRD sets: a player picks a class they qualify for,
then trades points. Run it over every class in
load_classes().classes to build the list of classes to offer.
It reports rather than raises, so you can show a player why a class is closed to them. Each failure names the ability, the minimum the class requires, and the score that fell short. The demi-human classes are the ones with requirements: the dwarf and the halfling require CON 9, the elf requires INT 9, and the halfling also requires DEX 9.
Checking before adjustment is safe for the Classic classes, because the adjustment step can only lower STR, INT, and WIS and never below 9, which is every requirement's minimum. A class added later with a higher minimum on a lowerable ability would need a second check after the adjustment.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scores
|
dict[AbilityScore, int]
|
The rolled scores, before any adjustment. |
required |
definition
|
ClassDefinition
|
The class the player chose, from
|
required |
Returns:
| Type | Description |
|---|---|
list[Rejection]
|
One |
list[Rejection]
|
class is open to these scores. |
Examples:
from osrlib.core.abilities import AbilityScore
from osrlib.core.character import validate_class_choice
from osrlib.data import load_classes
scores = dict.fromkeys(AbilityScore, 12)
scores[AbilityScore.CON] = 7
rejections = validate_class_choice(scores, load_classes().get("dwarf"))
print([(rejection.code, rejection.params) for rejection in rejections])
# [('creation.class.requirements_not_met', {'class': 'dwarf', 'ability': 'con', 'minimum': 9, 'score': 7})]
print(validate_class_choice(scores, load_classes().get("fighter")))
# []
validate_extra_languages
validate_extra_languages(definition: ClassDefinition, int_score: int, choices: Sequence[str]) -> list[Rejection]
Check the extra languages a high INT lets a character pick.
A character speaks their alignment tongue and whatever their class grants for free. An INT of
13 or more buys extra languages on top, one to three of them. An INT of 12 or less buys none.
Ask
load_ability_tables().additional_languages(int_score)
how many the player may take, offer the choosable entries from
load_languages, then check the picks here before storing them
on extra_languages.
A pick is refused when it is not a choosable language, when it repeats another pick, when the class already grants it, or when the player took more than the score allows. Each refusal names the language, so you can show the player which pick to change.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
definition
|
ClassDefinition
|
The chosen class. Its own languages may not be taken again as extras. |
required |
int_score
|
int
|
The final INT score, after any adjustment. |
required |
choices
|
Sequence[str]
|
The chosen language ids, from |
required |
Returns:
| Type | Description |
|---|---|
list[Rejection]
|
One |
list[Rejection]
|
stands. |
Examples:
from osrlib.core.character import validate_extra_languages
from osrlib.data import load_ability_tables, load_classes
fighter = load_classes().get("fighter")
print(load_ability_tables().additional_languages(13))
# 1
print(validate_extra_languages(fighter, 13, ["elvish"]))
# []
rejections = validate_extra_languages(fighter, 9, ["elvish"])
print([(rejection.code, rejection.params) for rejection in rejections])
# [('creation.languages.too_many', {'allowed': 0, 'chosen': 1})]
validate_starting_spells
validate_starting_spells(
definition: ClassDefinition, catalog: SpellCatalog, spell_ids: Sequence[str]
) -> list[Rejection]
Check a starting spell book against what the class may have at first level.
An arcane caster, which in the Classic classes means the magic-user and the elf, begins play
with as many spells written in the book as they can memorize, so at first level that is exactly
one first-level spell. Offer the player the first-level spells of their class's list from
load_spells, check the pick here, then write it with
choose_starting_spells. Whether the player or
the referee picks is the game's decision. This function only says whether a pick is legal.
A pick is refused when the spell id is unknown, when it repeats, when it belongs to the other spell list, or when the number chosen at any level does not match the capacity exactly. Too few is refused as well as too many, because the SRD gives a starting book a fixed size. A cleric starts with no book at all, since clerical spells come from a deity rather than from writing, so any pick for a cleric or for a non-caster is refused outright.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
definition
|
ClassDefinition
|
The character's class. |
required |
catalog
|
SpellCatalog
|
The spell catalog from |
required |
spell_ids
|
Sequence[str]
|
The chosen spell ids; see the spell id index. |
required |
Returns:
| Type | Description |
|---|---|
list[Rejection]
|
One |
list[Rejection]
|
legal. |
Examples:
from osrlib.core.character import validate_starting_spells
from osrlib.data import load_classes, load_spells
catalog = load_spells()
magic_user = load_classes().get("magic_user")
print(validate_starting_spells(magic_user, catalog, ["sleep"]))
# []
rejections = validate_starting_spells(magic_user, catalog, ["sleep", "magic_missile"])
print([(rejection.code, rejection.params) for rejection in rejections])
# [('magic.book.capacity_mismatch', {'spell_level': 1, 'capacity': 1, 'chosen': 2})]
cleric = load_classes().get("cleric")
print([rejection.code for rejection in validate_starting_spells(cleric, catalog, ["cure_light_wounds"])])
# ['magic.book.not_arcane']