Skip to content

osrlib.core.classes

Class definitions, level progression, XP awards, and leveling up.

The seven Classic classes compile from the OSE SRD's class pages into ClassDefinition models, loaded frozen via load_classes. A definition is pure data: requirements, prime requisites, XP-modifier tiers, a per-level progression table (hit dice, THAC0, saves, and spell-slot capacity), armour and weapon policies, and structured ability tags — so an Advanced class is additive data rather than a code change. race is an open, validated identity string, populated from the class in Classic play — no rules procedure consumes it, so a new race is data too.

XP-modifier tiers are one uniform representation for every class: ordered tiers of {modifier_pct, minimum scores}, evaluated best-first — the first tier whose minimum scores all hold wins. osrlib reads this as the standard single-prime-requisite table's intended behavior: its penalty rows only make sense under first-match evaluation. Elf and halfling carry exactly their stated bonus tiers, which per RAW include no penalty rows, as a documented adaptation (see the adaptations register).

Saves, THAC0, and spell slots are always read from the progression row for the character's level, never stored as separate derived fields — ClassDefinition.row is the pure recompute-from-level lookup whose inverse is energy drain (drain_levels).

Advancement lives here too: apply_xp awards XP and levels a character up when a threshold is crossed, level_up performs the level-up roll directly, and drain_levels reverses it. Character creation itself lives in osrlib.core.character.

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 six d% roll-under thief skills; hear_noise is the seventh, rolled on 1d6.

ArmourPolicy

Bases: BaseModel

A class's armour policy: the allowed kinds plus whether shields are allowed.

kind instance-attribute

shields_allowed instance-attribute

shields_allowed: bool

ArmourPolicyKind

Bases: StrEnum

What armour a class may wear.

ANY class-attribute instance-attribute

ANY = 'any'

LEATHER_ONLY class-attribute instance-attribute

LEATHER_ONLY = 'leather_only'

NONE class-attribute instance-attribute

NONE = 'none'

ClassAbility

Bases: BaseModel

A structured class-ability tag plus the SRD prose it came from.

params carries the mechanizable numbers ({"range_feet": 60} for infravision); manual marks abilities that need referee judgment and stay prose. The combat, magic, and crawl procedures consume these tags.

tag instance-attribute

tag: str

name instance-attribute

name: str

prose instance-attribute

prose: str

manual class-attribute instance-attribute

manual: bool = False

params class-attribute instance-attribute

params: dict[str, int | str] = {}

ClassCatalog

Bases: BaseModel

The loaded class list, with id lookup.

classes instance-attribute

classes: tuple[ClassDefinition, ...]

get

get(class_id: str) -> ClassDefinition

Return the class definition for class_id.

Parameters:

Name Type Description Default
class_id str

The class id to look up, e.g. "fighter" — see the class id index.

required

Returns:

Type Description
ClassDefinition

The class definition.

Raises:

Type Description
ValueError

If no class has that id.

ClassDefinition

Bases: BaseModel

A character class, compiled from its SRD page.

Frozen SRD data: play never mutates a class definition. race is an open, validated identity string — no rules procedure consumes it (racial mechanics resolve through structured ability tags), so Advanced races are additive data, not code. requirements are minimum scores checked at class choice; may_not_lower carries adjustment-step restrictions (the thief's STR). level_titles[i] is the title at level i + 1; the SRD's title lists run only through name level, so they are shorter than the progression and levels beyond them have no title entry.

id instance-attribute

id: str

name instance-attribute

name: str

race class-attribute instance-attribute

race: str = Field(pattern='^[a-z][a-z0-9_]*$')

requirements class-attribute instance-attribute

requirements: dict[AbilityScore, int] = {}

prime_requisites instance-attribute

prime_requisites: tuple[AbilityScore, ...]

xp_tiers instance-attribute

xp_tiers: tuple[XpTier, ...]

hit_die instance-attribute

hit_die: int

max_level class-attribute instance-attribute

max_level: int = Field(ge=1)

armour instance-attribute

armour: ArmourPolicy

weapons instance-attribute

weapons: WeaponPolicy

languages instance-attribute

languages: tuple[str, ...]

may_not_lower class-attribute instance-attribute

may_not_lower: tuple[AbilityScore, ...] = ()

abilities class-attribute instance-attribute

abilities: tuple[ClassAbility, ...] = ()

thief_skills class-attribute instance-attribute

thief_skills: tuple[ThiefSkillRow, ...] = ()

level_titles class-attribute instance-attribute

level_titles: tuple[str, ...] = ()

progression instance-attribute

progression: tuple[ProgressionRow, ...]

overrides_applied class-attribute instance-attribute

overrides_applied: tuple[str, ...] = ()

row

row(level: int) -> ProgressionRow

Return the progression row for level — the pure recompute-from-level lookup.

Saves, THAC0, attack bonus, and spell slots are read from here, never stored: energy drain is this function's inverse, not a redesign.

Parameters:

Name Type Description Default
level int

The character level, 1 through the class maximum.

required

Returns:

Type Description
ProgressionRow

The progression row.

Raises:

Type Description
ValueError

If level is outside the class's range.

DetectionResult

Bases: BaseModel

An X-in-6 detection check's outcome.

roll is None for a zero chance: no die is consumed, since there is nothing to roll under — for example, a non-dwarf searching for construction tricks simply fails without a roll.

chance instance-attribute

chance: int

roll class-attribute instance-attribute

roll: int | None = None

passed instance-attribute

passed: bool

DrainResult

Bases: BaseModel

The outcome of energy drain.

hp_rolls are the raw hit dice rolled for the drained levels (empty above name level, where the loss is the flat-bonus delta). slain marks the terminal case: a person drained of all levels dies, and spawn_consequence carries the SRD's spawn prose as a structured-but-manual field — the kernel kills, the game narrates.

levels_lost instance-attribute

levels_lost: int

new_level class-attribute instance-attribute

new_level: int = Field(ge=0)

hp_rolls class-attribute instance-attribute

hp_rolls: tuple[int, ...] = ()

hp_lost instance-attribute

hp_lost: int

xp_after class-attribute instance-attribute

xp_after: int | None = None

slain class-attribute instance-attribute

slain: bool = False

events class-attribute instance-attribute

events: tuple[Event, ...] = ()

HitDice

Bases: BaseModel

A progression row's Hit Dice: count dice of die sides plus a flat bonus.

Above name level the SRD notates flat bonuses with an asterisk (9d8+2*) meaning CON modifiers no longer apply — the asterisk is data, carried as con_applies.

count class-attribute instance-attribute

count: int = Field(ge=1)

die instance-attribute

die: int

bonus class-attribute instance-attribute

bonus: int = Field(default=0, ge=0)

con_applies class-attribute instance-attribute

con_applies: bool = True

LevelUpResult

Bases: BaseModel

The outcome of gaining one level.

While HD count still grows, hp_roll is the raw die (CON applies when con_applied, minimum 1 hp gained); above name level the gain is the flat-bonus delta with no roll and no CON.

new_level instance-attribute

new_level: int

hp_roll instance-attribute

hp_roll: int | None

hp_gained instance-attribute

hp_gained: int

con_applied instance-attribute

con_applied: bool

ProgressionRow

Bases: BaseModel

One level of a class progression table, exactly as the SRD prints it.

THAC0 is dual-format in the SRD (19 [0]); both the descending value and the bracketed attack bonus are carried. spell_slots[i] is the number of memorizable spells of spell level i + 1; the tuple is empty for non-casters.

level class-attribute instance-attribute

level: int = Field(ge=1)

xp class-attribute instance-attribute

xp: int = Field(ge=0)

hit_dice instance-attribute

hit_dice: HitDice

thac0 class-attribute instance-attribute

thac0: int = Field(ge=2, le=20)

attack_bonus class-attribute instance-attribute

attack_bonus: int = Field(ge=0)

saves instance-attribute

saves: SavingThrows

spell_slots class-attribute instance-attribute

spell_slots: tuple[int, ...] = ()

SavingThrows

Bases: BaseModel

The five save values: death/poison, wands, paralysis/petrify, breath, spells/rods/staves.

death class-attribute instance-attribute

death: int = Field(ge=2, le=20)

wands class-attribute instance-attribute

wands: int = Field(ge=2, le=20)

paralysis class-attribute instance-attribute

paralysis: int = Field(ge=2, le=20)

breath class-attribute instance-attribute

breath: int = Field(ge=2, le=20)

spells class-attribute instance-attribute

spells: int = Field(ge=2, le=20)

SkillCheckResult

Bases: BaseModel

A thief skill check's outcome.

chance is the effective target after modifiers (the pick-pockets ≥1%-failure cap applied). noticed is set only for pick pockets: a roll of more than twice the effective chance means the attempted theft is noticed (RAW) — what the victim does about it is a game procedure.

skill instance-attribute

skill: str

roll instance-attribute

roll: int

chance instance-attribute

chance: int

passed instance-attribute

passed: bool

noticed class-attribute instance-attribute

noticed: bool | None = None

ThiefSkillRow

Bases: BaseModel

One level of the thief skill table.

Skills are d% roll-under percentages except hear_noise, an X-in-6 upper bound (the SRD's 1–2 is stored as 2). Pick pockets can exceed 100 at high level; the over-100 arithmetic belongs to the skill-check procedure.

level class-attribute instance-attribute

level: int = Field(ge=1)

climb_sheer_surfaces class-attribute instance-attribute

climb_sheer_surfaces: int = Field(ge=0)

find_remove_treasure_traps class-attribute instance-attribute

find_remove_treasure_traps: int = Field(ge=0)

hear_noise class-attribute instance-attribute

hear_noise: int = Field(ge=1, le=6)

hide_in_shadows class-attribute instance-attribute

hide_in_shadows: int = Field(ge=0)

move_silently class-attribute instance-attribute

move_silently: int = Field(ge=0)

open_locks class-attribute instance-attribute

open_locks: int = Field(ge=0)

pick_pockets class-attribute instance-attribute

pick_pockets: int = Field(ge=0)

WeaponPolicy

Bases: BaseModel

A class's weapon policy.

weapon_ids is the explicit allow list (cleric: the five blunt weapons) or the forbidden list (dwarf and halfling: long_bow, two_handed_sword), and is empty for any. manual_notes keeps referee-judgment stature prose (the dwarf's "small or normal sized", the halfling's "appropriate to stature") that cannot be mechanized. The policy governs the weapons list only; gear combat facets are exempt (see validate_equip).

kind instance-attribute

weapon_ids class-attribute instance-attribute

weapon_ids: tuple[str, ...] = ()

manual_notes class-attribute instance-attribute

manual_notes: tuple[str, ...] = ()

WeaponPolicyKind

Bases: StrEnum

How a class's weapon list is expressed.

ANY class-attribute instance-attribute

ANY = 'any'

ALLOWED class-attribute instance-attribute

ALLOWED = 'allowed'

FORBIDDEN class-attribute instance-attribute

FORBIDDEN = 'forbidden'

XpAwardResult

Bases: BaseModel

The outcome of applying an XP award.

modified_award is the award after the class XP-modifier percentage, floored. clamped reports the one-level-per-award rule firing: XP that would reach two or more levels above the starting level stops at 1 XP below the second level's threshold.

award instance-attribute

award: int

modifier_pct instance-attribute

modifier_pct: int

modified_award instance-attribute

modified_award: int

xp_before instance-attribute

xp_before: int

xp_after instance-attribute

xp_after: int

level_before instance-attribute

level_before: int

level_after instance-attribute

level_after: int

clamped instance-attribute

clamped: bool

level_up instance-attribute

level_up: LevelUpResult | None

XpTier

Bases: BaseModel

One XP-modifier tier: the modifier applies when every minimum holds.

modifier_pct instance-attribute

modifier_pct: int

minimums instance-attribute

minimums: dict[AbilityScore, int]

apply_xp

apply_xp(character: Character, definition: ClassDefinition, award: int, stream: RngStream) -> XpAwardResult

Apply an XP award: class modifier, the one-level-per-award rule, and leveling.

The class XP-modifier percentage applies first, with the result floored. Then the rule exactly as written: XP that would reach two or more levels above the starting level is clamped to 1 XP below the second level's threshold, and the character gains one level. At the class's maximum level no further levels are gained but XP keeps accumulating, unclamped — there is no next threshold to hold the character under.

Parameters:

Name Type Description Default
character Character

The character receiving the award; mutated in place.

required
definition ClassDefinition

The character's class definition.

required
award int

The unmodified XP award. Non-negative.

required
stream RngStream

The RNG stream for a level-up hit die roll.

required

Returns:

Type Description
XpAwardResult

The award outcome, including the level-up result when one occurred.

Raises:

Type Description
ValueError

If the definition doesn't match the character's class or the award is negative.

detection_chance

detection_chance(character: Character, definition: ClassDefinition, kind: str) -> int

Resolve a character's X-in-6 chance for one detection kind.

Precedence, applied in this order: listening uses the thief's hear_noise row when present, else the class's listening_at_doors param, else the universal 1-in-6; secret doors use detect_secret_doors (elf 2) else 1; room traps use detect_room_traps (dwarf 2) else 1; construction tricks use detect_construction_tricks (dwarf 2) else zero — the OSE SRD grants the perception to dwarves alone, and "as a dwarf you can sense" has no baseline for others, unlike the universal 1-in-6 search chances the SRD states for all PCs.

Parameters:

Name Type Description Default
character Character

The detecting character.

required
definition ClassDefinition

The character's class definition.

required
kind str

One of "listening", "secret_doors", "room_traps", or "construction".

required

Returns:

Type Description
int

The X-in-6 chance (0 means no chance at all).

Raises:

Type Description
ValueError

If the kind is unknown.

detection_check

detection_check(chance_in_six: int, *, stream: RngStream) -> DetectionResult

Roll the shared X-in-6 detection check: searching, listening, demi-human tags.

A zero (or negative) chance consumes no draw and fails: the OSE SRD grants construction-trick perception to dwarves alone, and a character without it has nothing to roll under.

Parameters:

Name Type Description Default
chance_in_six int

The X-in-6 chance, from detection_chance or a class tag.

required
stream RngStream

The RNG stream, conventionally the crawl's "exploration" stream.

required

Returns:

Type Description
DetectionResult

The check outcome.

drain_levels

drain_levels(
    character: Character,
    definition: ClassDefinition,
    *,
    levels: int = 1,
    xp_policy: str,
    stream: RngStream,
    spawn_consequence: str | None = None
) -> DrainResult

Drain experience levels — the inverse of level_up.

Saves, THAC0, and spell slots need no reversal because they derive from row; only stored state reverses. Per level drained, mirroring level_up exactly in reverse: above name level subtract the flat-bonus delta (no roll, no CON); otherwise roll the class hit die plus the CON modifier (minimum 1 per die) and subtract it from max and current hit points — rolling the lost die is osrlib's RAW-faithful reading of "loses one Hit Die of hit points" that keeps the model stateless.

Floors: drain never reduces max HP below 1 or current HP below 1 while the character retains a level — death by drain happens only by losing the last level ("a person drained of all levels"), the terminal state. XP is set once after all levels drain, by policy: halfway is the floored midpoint of the former and new levels' thresholds (the wight); level_minimum is the new level's threshold exactly (wraith, spectre, vampire).

Parameters:

Name Type Description Default
character Character

The drained character; mutated in place.

required
definition ClassDefinition

The character's class definition.

required
levels int

How many levels the drain removes (the spectre and vampire drain two, applying the procedure twice).

1
xp_policy str

"halfway" or "level_minimum" — per-monster data from the energy_drain tag.

required
stream RngStream

The RNG stream for the lost hit die rolls, conventionally ADVANCEMENT_STREAM — the same subsystem as the gains it reverses.

required
spawn_consequence str | None

The monster's spawn prose, carried on the drain event.

None

Returns:

Type Description
DrainResult

The drain outcome, including the terminal death when all levels are lost.

Raises:

Type Description
ValueError

If the definition doesn't match the character's class, levels is not positive, or the policy is unknown.

level_up

level_up(character: Character, definition: ClassDefinition, stream: RngStream) -> LevelUpResult

Advance a character one level, rolling hit points per the SRD.

While the HD count still grows, the gain is a new hit die roll plus the CON modifier, minimum 1. Above name level the gain is the flat-bonus delta between the progression rows — no roll, no CON. Both max and current hit points increase by the gain: leveling adds hit points but heals no damage already taken. Saves, THAC0, and spell slots are never stored — read them from ClassDefinition.row.

Parameters:

Name Type Description Default
character Character

The character to advance; mutated in place.

required
definition ClassDefinition

The character's class definition.

required
stream RngStream

The RNG stream for the hit die roll, conventionally ADVANCEMENT_STREAM.

required

Returns:

Type Description
LevelUpResult

The level-up outcome, including the raw hit die roll if one was made.

Raises:

Type Description
ValueError

If the definition doesn't match the character's class, or the character is already at the class's maximum level.

thief_skill_check

thief_skill_check(
    character: Character, definition: ClassDefinition, skill: str, *, modifier_pct: int = 0, stream: RngStream
) -> SkillCheckResult

Roll one thief skill check — an à la carte plain result, no events.

The six percentile skills roll d% with success on a result less than or equal to the level row's chance; hear_noise rolls 1d6 against its X-in-6 bound. The crawl procedures emit the events (and hide the referee-rolled outcomes); this function just resolves the dice.

Pick pockets, per its RAW bullet: the caller folds the victim's over-5th-level penalty into modifier_pct (−5% per victim level above 5th — the kernel never sees the victim), the effective chance caps at 99 ("always at least a 1% chance of failure"), and a roll of more than twice the effective chance sets noticed.

Parameters:

Name Type Description Default
character Character

The rolling thief.

required
definition ClassDefinition

The character's class definition; must carry a thief skill table.

required
skill str

A percentile skill name from PERCENTILE_THIEF_SKILLS, or "hear_noise".

required
modifier_pct int

A percentage adjustment to the percentile chance (ignored for hear_noise).

0
stream RngStream

The RNG stream, conventionally the crawl's "exploration" stream.

required

Returns:

Type Description
SkillCheckResult

The check outcome.

Raises:

Type Description
ValueError

If the class has no thief skills or the skill name is unknown — gating who may attempt a skill is the caller's validation.

xp_modifier_pct

xp_modifier_pct(definition: ClassDefinition, scores: dict[AbilityScore, int]) -> int

Return the class XP-modifier percentage for a score set.

Tiers are evaluated best-first; the first tier whose minimums all hold wins. osrlib reads this as the standard table's intended behavior — its penalty rows only make sense under first-match evaluation. With no matching tier the modifier is zero, which is how the multi-prime-requisite classes carry no penalties per RAW.

Parameters:

Name Type Description Default
definition ClassDefinition

The class definition.

required
scores dict[AbilityScore, int]

The character's final ability scores.

required

Returns:

Type Description
int

The XP modifier as a signed percentage (+10, -20, 0).