osrlib.core.abilities
What a character's six ability scores are worth, and what you can do with them.
Start with load_ability_tables, which gives you an
AbilityTables with the SRD's printed tables.
Hand any of its accessors a score from 3 to 18 and get the number the rules apply: the
melee modifier for strength, the armour class modifier for dexterity, the hit points per
Hit Die for constitution, and so on. A Character
exposes the ones it needs as properties, so you call the accessors yourself when you're
working outside a character.
Two dice functions live here as well.
ability_check rolls the SRD's generic check for
a task the rules don't otherwise cover, and
open_doors_check rolls a strength-based
attempt to force a stuck door. Both take an
RngStream.
The rest of the module is character creation's third step, where a player trades points
between abilities before play.
validate_adjustment says whether a
proposed trade is legal and apply_adjustment
carries it out. create_character runs the
whole creation sequence, so reach for these two when you're building a character step by
step and letting a player choose.
Scores run 3 to 18. That's what 3d6 can roll, what the SRD's tables list, and what the adjustment step has to stay inside.
Typical usage:
from osrlib.core.abilities import ability_check
from osrlib.core.rng import RngStreams
from osrlib.data import load_ability_tables
tables = load_ability_tables()
# What a strength of 16 is worth.
assert tables.melee_modifier(16) == 2
assert tables.open_doors_chance(16) == 4
# A check against a dexterity of 13, on a stream you supply.
check = ability_check(13, RngStreams(master_seed=3).get("exploration"))
assert (check.roll, check.success) == (18, False)
ADJUSTMENT_FLOOR
module-attribute
The lowest a score may be traded down to during character creation, which is 9.
The floor stops a player from emptying one ability to buy up another. It applies only to
the trade in validate_adjustment. A score
rolled below 9 is legal, and it cannot be lowered further.
MAX_SCORE
module-attribute
The highest an ability score can be, which is 18.
Eighteen is what three dice showing 6 add up to, the top row of every table here, and the
ceiling the creation-time trade may not push a score past. The accessors raise
ValueError above it, because the tables print no row to read.
MIN_SCORE
module-attribute
The lowest an ability score can be, which is 3.
Three is what three dice showing 1 add up to, and the lowest row of every table here.
Below it there's no rule to apply, so the accessors and the check functions raise
ValueError rather than guess. Use it to bound a slider, or to check a score your own
code produced.
AbilityAdjustment
Bases: BaseModel
A proposed trade of ability points, made once while a character is being created.
Build one from what the player chose, check it with
validate_adjustment, and carry it out
with apply_adjustment. The exchange rate
is two points down for one point up, and the points bought can only go into the
class's prime requisites, the abilities the class is built around.
An adjustment with nothing in it is legal and changes nothing, which is what you build for a player who keeps the scores as rolled.
Examples:
from osrlib.core.abilities import AbilityAdjustment, AbilityScore
# Four points out of intelligence and wisdom buys two points of strength.
adjustment = AbilityAdjustment(
lowered={AbilityScore.INT: 2, AbilityScore.WIS: 2},
raised={AbilityScore.STR: 2},
)
assert sum(adjustment.raised.values()) == sum(adjustment.lowered.values()) // 2
lowered
class-attribute
instance-attribute
lowered: dict[AbilityScore, int] = {}
How much to take off each ability, as a positive number.
Only strength, intelligence, and wisdom may appear, each amount must be even, and no
score may end below ADJUSTMENT_FLOOR.
raised
class-attribute
instance-attribute
raised: dict[AbilityScore, int] = {}
How much to add to each ability, as a positive number.
Only the class's prime requisites may appear, the amounts must add up to half the
points taken off, and no score may end above
MAX_SCORE.
AbilityCheckResult
Bases: BaseModel
How an ability check turned out, with the die kept so you can show it.
ability_check returns one.
AbilityScore
Bases: StrEnum
Which of the six abilities a score belongs to.
Use these as the keys of a score dictionary, which is how every function here and in
osrlib.core.character passes a character's abilities
around. All six keys are expected to be present.
The lowercase values serialize into characters and saved games. Changing one is a
schema_version bump, the version stamp that marks a serialized model's shape.
STR
class-attribute
instance-attribute
Strength: melee attack and damage, and the chance to force a stuck door open.
INT
class-attribute
instance-attribute
Intelligence: how many extra languages a character speaks, and whether they can read and write.
WIS
class-attribute
instance-attribute
Wisdom: the modifier on saving throws against magical effects.
DEX
class-attribute
instance-attribute
Dexterity: armour class, missile attacks, and initiative under the individual-initiative rule.
CON
class-attribute
instance-attribute
Constitution: the hit points added to each Hit Die rolled.
AbilityTables
Bases: BaseModel
The SRD's ability tables, and the accessors that read a score out of them.
Get one from load_ability_tables, which loads the
tables that ship with the package and caches them, so calling it repeatedly costs
nothing. Then call the accessor for the column you want, passing a score from
MIN_SCORE to
MAX_SCORE. A score outside that range raises
ValueError, because a score outside it is a mistake in your code rather than an
outcome the rules allow.
A Character reads these tables for you and offers
the results as properties, so use the accessors when you have a bare score and no
character.
The fields contain the raw rows. Read them to draw a table, and use the accessors to play.
Examples:
from osrlib.data import load_ability_tables
tables = load_ability_tables()
assert tables.melee_modifier(18) == 3
assert tables.hit_point_modifier(3) == -3
# The rows are there when you want to show the whole table.
assert (tables.strength[1].min_score, tables.strength[1].max_score) == (4, 5)
strength
instance-attribute
strength: tuple[StrengthRow, ...]
The strength table's rows, lowest score first, together covering 3 to 18 with no gaps.
intelligence
instance-attribute
intelligence: tuple[IntelligenceRow, ...]
The intelligence table's rows, lowest score first, together covering 3 to 18 with no gaps.
wisdom
instance-attribute
The wisdom table's rows, lowest score first, together covering 3 to 18 with no gaps.
dexterity
instance-attribute
dexterity: tuple[DexterityRow, ...]
The dexterity table's rows, lowest score first, together covering 3 to 18 with no gaps.
constitution
instance-attribute
constitution: tuple[ConstitutionRow, ...]
The constitution table's rows, lowest score first, together covering 3 to 18 with no gaps.
charisma
instance-attribute
charisma: tuple[CharismaRow, ...]
The charisma table's rows, lowest score first, together covering 3 to 18 with no gaps.
prime_requisite
instance-attribute
prime_requisite: tuple[PrimeRequisiteRow, ...]
The prime requisite experience table's rows, lowest score first, together covering 3 to 18 with no gaps.
melee_modifier
Return what a strength score adds to melee attack rolls and melee damage.
Missile attacks take the dexterity modifier instead. See
missile_modifier.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
A strength score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The modifier, from −3 at a score of 3 to +3 at 18. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
open_doors_chance
Return the chance in 6 that a strength score forces a stuck door open.
Pass the result to
open_doors_check, which rolls against
it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
A strength score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The chance in 6, from 1 at a score of 3 to 5 at 18. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
additional_languages
Return how many languages beyond the native ones an intelligence score grants.
The choices themselves are checked by
validate_extra_languages,
which reads this same allowance.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
An intelligence score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The count, 0 below a score of 13 and up to 3 at 18. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
literacy
Return how well an intelligence score lets a character read and write.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
An intelligence score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
Literacy
|
The |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
magic_save_modifier
Return what a wisdom score adds to saving throws against magical effects.
It applies to magical effects only, not to a saving throw against a trap or a dragon's breath.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
A wisdom score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The modifier, from −3 at a score of 3 to +3 at 18. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
ac_modifier
Return what a dexterity score is worth to armour class.
The number is a bonus: it's subtracted from a descending armour class, where
lower is better, and added to an ascending one.
Character.armour_class applies
it for you.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
A dexterity score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The bonus, from −3 at a score of 3 to +3 at 18. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
missile_modifier
Return what a dexterity score adds to missile attack rolls.
It changes the attack roll only. Missile damage takes no ability modifier.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
A dexterity score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The modifier, from −3 at a score of 3 to +3 at 18. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
initiative_modifier
Return what a dexterity score adds to an individual initiative roll.
It is read only when the individual_initiative flag on
Ruleset is on. With the flag off, initiative is
rolled once for a whole side and no ability modifier applies.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
A dexterity score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The modifier, from −2 at a score of 3 to +2 at 18. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
hit_point_modifier
Return what a constitution score adds to every Hit Die a character rolls.
It applies at creation and again at each level gained. However negative it is, a die never ends up granting fewer than 1 hit point.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
A constitution score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The modifier, from −3 at a score of 3 to +3 at 18. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
npc_reaction_modifier
Return what a charisma score adds to a monster or NPC reaction roll.
Add it to the 2d6 total before reading the result with
reaction_result, which clamps a modified
total into the printed outer bands.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
A charisma score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The modifier, from −2 at a score of 3 to +2 at 18. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
max_retainers
Return how many hired followers a charisma score lets a character keep at once.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
A charisma score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The count, from 1 at a score of 3 to 7 at 18. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
retainer_loyalty
Return the loyalty a retainer of a character with this charisma score starts with.
Loyalty is the number a retainer's nerve is tested against when the party asks something risky of them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
A charisma score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The loyalty score, from 4 at a charisma of 3 to 10 at 18. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
prime_requisite_xp_modifier_pct
Return the percentage a single prime requisite score changes earned experience by.
A prime requisite is the ability a class is built around. This table covers a
class with exactly one. A class with more than one has its own tiers on its
class definition, and
xp_modifier_pct picks the right source
for you.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
A prime requisite score from 3 to 18. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The percentage, from −20 at a score of 3 to +10 at 16 or above. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
CharismaRow
Bases: ScoreBand
One row of the charisma table, with the three things charisma grants.
npc_reactions
instance-attribute
npc_reactions: int
What to add to a monster or NPC reaction roll, which decides how a meeting starts.
max_retainers
class-attribute
instance-attribute
How many hired followers the character may have at once.
ConstitutionRow
DexterityRow
Bases: ScoreBand
One row of the dexterity table, with the three things dexterity grants.
IntelligenceRow
Bases: ScoreBand
One row of the intelligence table, covering language and literacy.
additional_languages
class-attribute
instance-attribute
How many languages beyond the character's native ones they may choose at creation.
literacy
instance-attribute
literacy: Literacy
How well the character reads and writes. See Literacy.
broken_speech
class-attribute
instance-attribute
broken_speech: bool = False
True only at intelligence 3, where the character speaks their native language brokenly.
Literacy
Bases: StrEnum
How well a character reads and writes, which their intelligence score sets.
Read it off AbilityTables.literacy, passing the
character's intelligence score. It matters whenever the party finds something written, a scroll
or a map or an inscription. No rule in osrlib reads it, so what a character who cannot read may
not do is your game's decision.
ILLITERATE
class-attribute
instance-attribute
Cannot read or write, at intelligence 5 or below.
BASIC
class-attribute
instance-attribute
Partial literacy, at intelligence 6 to 8: the step the SRD prints between illiterate and literate.
OpenDoorsResult
Bases: BaseModel
How an attempt to force a door turned out, with the die kept so you can show it.
open_doors_check returns one.
PrimeRequisiteRow
Bases: ScoreBand
One row of the prime requisite table, which sets how fast a character earns experience.
A prime requisite is the ability a class is built around: wisdom for a cleric, strength for a fighter. This table applies to a class with a single prime requisite. A class with more than one has its own tiers on its class definition.
xp_modifier_pct
instance-attribute
xp_modifier_pct: int
The percentage added to or taken off experience earned, from −20 at a score of 3 to +10 at 16 or above.
ScoreBand
Bases: BaseModel
The run of scores one table row covers, such as 4 to 5.
The SRD prints its modifier tables in bands rather than one row per score, and osrlib
keeps them that way. Every row model below is a band with the row's own columns added.
You'll meet these when you read a whole table off
AbilityTables, for instance to draw the table
on screen. To look up a single score, call an accessor instead and skip the rows
entirely.
StrengthRow
Bases: ScoreBand
One row of the strength table, with the two things strength grants.
melee
instance-attribute
melee: int
What to add to a melee attack roll and to melee damage. Negative at a low score.
open_doors
class-attribute
instance-attribute
The chance in 6 of forcing a stuck door open.
Pass it to open_doors_check, which rolls
the die against it.
WisdomRow
ability_check
ability_check(score: int, stream: RngStream, modifier: int = 0) -> AbilityCheckResult
Roll an ability check: 1d20, equal-or-under the score succeeds.
Use this for a task the rules don't cover with a procedure of their own: shoving a
boulder, spotting a change in the stonework, holding a rope. Pick the ability that
fits and set the difficulty with modifier. For the things the rules do cover, call
the function that covers them:
open_doors_check for a stuck door, the
saving throws in osrlib.core.combat for magic and traps, and
the thief skills on the class definition for a thief's own work.
The roll is a d20, and the check succeeds on a modified roll at or under the score, so a higher score succeeds more often. The SRD leaves the difficulty to the referee and gives two numbers for it: "a –4 modifier for an easy task or +4 for a difficult task". A natural 1 always succeeds and a natural 20 always fails, which is the opposite way round from an attack roll.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
score
|
int
|
The ability score to check against, from 3 to 18. |
required |
stream
|
RngStream
|
The stream the d20 draws from. |
required |
modifier
|
int
|
The difficulty, added to the roll. Positive makes the check harder. |
0
|
Returns:
| Type | Description |
|---|---|
AbilityCheckResult
|
The outcome, with the raw d20 kept for display. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
from osrlib.core.abilities import ability_check
from osrlib.core.rng import RngStreams
stream = RngStreams(master_seed=3).get("exploration")
# An 18 against a score of 13 fails, as a natural 20 would have.
check = ability_check(13, stream)
assert (check.roll, check.success) == (18, False)
# A difficult task: +4 on the roll, so a 5 is still under 13.
harder = ability_check(13, stream, modifier=4)
assert (harder.roll, harder.success) == (5, True)
apply_adjustment
apply_adjustment(
scores: dict[AbilityScore, int],
adjustment: AbilityAdjustment,
prime_requisites: tuple[AbilityScore, ...],
may_not_lower: tuple[AbilityScore, ...] = (),
) -> dict[AbilityScore, int]
Carry out a points trade and return the adjusted scores.
Call validate_adjustment first and pass
the same arguments on. This function validates again and refuses rather than produce a
score set the rules forbid, so an exception here means your code let an illegal trade
through, not that the player chose badly. It either applies the whole trade or changes
nothing.
Feed the result to create_character as the
scores to build the character from.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scores
|
dict[AbilityScore, int]
|
The rolled scores. Left as they are, because the adjusted set comes back as a new dictionary. |
required |
adjustment
|
AbilityAdjustment
|
The trade to carry out. |
required |
prime_requisites
|
tuple[AbilityScore, ...]
|
The chosen class's prime requisites. |
required |
may_not_lower
|
tuple[AbilityScore, ...]
|
Abilities this class forbids lowering. |
()
|
Returns:
| Type | Description |
|---|---|
dict[AbilityScore, int]
|
A new score dictionary with the points moved. All six abilities are present, and |
dict[AbilityScore, int]
|
the ones the trade didn't touch keep their rolled values. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the trade breaks any rule
|
Examples:
from osrlib.core.abilities import AbilityAdjustment, AbilityScore, apply_adjustment
scores = {
AbilityScore.STR: 9,
AbilityScore.INT: 13,
AbilityScore.WIS: 12,
AbilityScore.DEX: 11,
AbilityScore.CON: 14,
AbilityScore.CHA: 10,
}
adjustment = AbilityAdjustment(
lowered={AbilityScore.INT: 2, AbilityScore.WIS: 2},
raised={AbilityScore.STR: 2},
)
adjusted = apply_adjustment(scores, adjustment, (AbilityScore.STR,))
assert adjusted[AbilityScore.STR] == 11
assert adjusted[AbilityScore.INT] == 11
assert adjusted[AbilityScore.CON] == 14
# The rolled scores are untouched.
assert scores[AbilityScore.STR] == 9
open_doors_check
open_doors_check(chance: int, stream: RngStream) -> OpenDoorsResult
Roll one character's attempt to force a stuck door open.
Get the chance from
AbilityTables.open_doors_chance
for the character's strength. A d6 at or under the chance opens the door. The function
rolls and reports, and nothing else: opening the door, and whatever the attempt costs
the party, are yours to apply.
In a running game ForceDoor applies those
consequences for you. It marks the party as having made noise, opens the door on a
success and springs any trap rigged to it, and on a failure alerts the area beyond, all
with the events to match. Call this function when you're working outside a session.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chance
|
int
|
The chance in 6, from 0 to 6. |
required |
stream
|
RngStream
|
The stream the d6 draws from. |
required |
Returns:
| Type | Description |
|---|---|
OpenDoorsResult
|
The outcome, with the raw d6 kept for display. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
from osrlib.core.abilities import open_doors_check
from osrlib.core.rng import RngStreams
from osrlib.data import load_ability_tables
chance = load_ability_tables().open_doors_chance(16)
assert chance == 4
result = open_doors_check(chance, RngStreams(master_seed=3).get("exploration"))
assert (result.roll, result.success) == (5, False)
validate_adjustment
validate_adjustment(
scores: dict[AbilityScore, int],
adjustment: AbilityAdjustment,
prime_requisites: tuple[AbilityScore, ...],
may_not_lower: tuple[AbilityScore, ...] = (),
) -> list[Rejection]
Check a proposed points trade against the rules, and say what is wrong with it.
Call this before apply_adjustment and show
the player what came back. An empty list means the trade is legal. The two functions
take the same four arguments, so you can pass the same ones straight on.
The rules it enforces, from the SRD's third character-creation step and osrlib's readings of it in the adaptations register, the page that lists every place osrlib commits to one reading of the rules:
- Only strength, intelligence, and wisdom may be lowered.
- A prime requisite of the chosen class may not be lowered, and neither may an ability the class forbids lowering, which is why a thief may not lower strength.
- Each score comes down by an even amount, since the two-for-one trade is worked out per score and an odd amount would strand half a point.
- The points bought add up to half the points sold, and go only into the class's prime requisites.
- No score is lowered below
ADJUSTMENT_FLOORor raised aboveMAX_SCORE.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scores
|
dict[AbilityScore, int]
|
The rolled scores, with all six abilities present. |
required |
adjustment
|
AbilityAdjustment
|
The trade the player proposed. |
required |
prime_requisites
|
tuple[AbilityScore, ...]
|
The chosen class's prime requisites, from
|
required |
may_not_lower
|
tuple[AbilityScore, ...]
|
Abilities this class forbids lowering, also from the class definition. |
()
|
Returns:
| Type | Description |
|---|---|
list[Rejection]
|
One |
list[Rejection]
|
trade is legal. A single proposal can break several rules at once, so expect more |
list[Rejection]
|
than one. |
Examples:
from osrlib.core.abilities import AbilityAdjustment, AbilityScore, validate_adjustment
scores = {
AbilityScore.STR: 9,
AbilityScore.INT: 13,
AbilityScore.WIS: 12,
AbilityScore.DEX: 11,
AbilityScore.CON: 14,
AbilityScore.CHA: 10,
}
# Four points down, two points up, into the fighter's prime requisite.
legal = AbilityAdjustment(
lowered={AbilityScore.INT: 2, AbilityScore.WIS: 2},
raised={AbilityScore.STR: 2},
)
assert validate_adjustment(scores, legal, (AbilityScore.STR,)) == []
# Buying two points for two is not the rate.
greedy = AbilityAdjustment(lowered={AbilityScore.INT: 2}, raised={AbilityScore.STR: 2})
assert [rejection.code for rejection in validate_adjustment(scores, greedy, (AbilityScore.STR,))] == [
"creation.adjustment.points_mismatch"
]