Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added

- `CharacterLeveledUpEvent` (`session.level.gained`, player visibility) — fires immediately after a member's `XpAwardedEvent` whenever an XP award crosses a level threshold, on every award surface: the end-of-adventure award, the immediate timing, and the referee's `AwardXP`. It carries the levels before and after, the hit points gained, the raw hit die roll (`None` past name level, where the gain is the flat-bonus delta), whether the CON modifier applied, and the class's level title at the new level (`None` past the printed title list). Previously a level gain surfaced only as the `level_after` field on the XP award, so a front end had no event to announce the moment on — and no hit-point or title facts to announce it with.
- `LearnSpell`, a command that adds a spell to an arcane caster's spell book in-session — leveling's new capacity or a mentor's teaching made concrete, driving the core `add_spell_to_book` through the command loop so the addition is validated, logged, and replayed like any other command. Legal in town and while exploring, and no game time passes: the fiction around the learning (the mentor's week, a copied scroll's costs) belongs to the game. The per-spell-level capacity, the duplicate/wrong-list/non-arcane gates, and the book-never-shrinks rule are exactly the core function's, and a successful learning emits the existing `SpellBookUpdatedEvent`.
- `open_book_capacity` — the per-spell-level count of open spell-book slots: the progression row's capacity minus the spells held at that level, floored at zero (a drained caster's book may sit over capacity and simply reads as no openings), `()` for a class with no arcane book. `add_spell_to_book`'s capacity check now consults it, so the rule has one home a front end can share when rendering what a caster may still learn. Alongside it, `level_title` answers a class's level title at a given level, `None` beyond the printed list — the level event reads it.

## [1.4.0] - 2026-07-28

### Added
Expand Down
19 changes: 19 additions & 0 deletions src/osrlib/core/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"detection_chance",
"detection_check",
"drain_levels",
"level_title",
"level_up",
"thief_skill_check",
"xp_modifier_pct",
Expand Down Expand Up @@ -414,6 +415,24 @@ def xp_modifier_pct(definition: ClassDefinition, scores: dict[AbilityScore, int]
return 0


def level_title(definition: ClassDefinition, level: int) -> str | None:
"""Return the class's level title at `level`, or `None` beyond the printed list.

`level_titles[i]` is the title at level `i + 1`; the SRD's title lists run only
through name level, so levels past the list have no title.

Args:
definition: The [`ClassDefinition`][osrlib.core.classes.ClassDefinition].
level: The character level, 1 or greater.

Returns:
The title, or `None` when the class's title list doesn't reach `level`.
"""
if 1 <= level <= len(definition.level_titles):
return definition.level_titles[level - 1]
return None


def level_up(character: Character, definition: ClassDefinition, stream: RngStream) -> LevelUpResult:
"""Advance a character one level, rolling hit points per the SRD.

Expand Down
40 changes: 36 additions & 4 deletions src/osrlib/core/spells.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
"forget_excess_memorized",
"memorize_spells",
"minimum_caster_level",
"open_book_capacity",
"pop_mirror_image",
"turn_undead",
"validate_cast",
Expand Down Expand Up @@ -597,6 +598,37 @@ def accepted(self) -> bool:
return not self.rejections


def open_book_capacity(caster: Any, definition: ClassDefinition, catalog: SpellCatalog) -> tuple[int, ...]:
"""Per-spell-level open spell-book slots: row capacity minus spells held, floored at zero.

Entry `i` is the number of spells of spell level `i + 1` the caster's book can
still take: the current progression row's slot count at that level minus the
spells already held there, never below zero. The zero floor carries the
book-never-shrinks rule: a drained caster's book may sit over capacity, and an
over-full level simply reads as no openings until capacity catches up. Non-arcane
classes keep no book, so their answer is the empty tuple.

Args:
caster: The caster: a [`Character`][osrlib.core.character.Character]; its
`spell_book` and `level` are read.
definition: The caster's [`ClassDefinition`][osrlib.core.classes.ClassDefinition].
catalog: The loaded spell catalog, from [`load_spells`][osrlib.data.load_spells].

Returns:
One open-slot count per spell level of the progression row, or `()` for
a class with no arcane book.
"""
profile = caster_profile(definition)
if profile is None or profile.kind != "arcane":
return ()
slots = definition.row(caster.level).spell_slots
held: dict[int, int] = {}
for held_id in caster.spell_book:
spell_level = catalog.get(held_id).level
held[spell_level] = held.get(spell_level, 0) + 1
return tuple(max(0, capacity - held.get(index + 1, 0)) for index, capacity in enumerate(slots))


def add_spell_to_book(
caster: Any, definition: ClassDefinition, catalog: SpellCatalog, spell_id: str
) -> SpellBookResult:
Expand Down Expand Up @@ -635,10 +667,10 @@ def add_spell_to_book(
)
if spell_id in caster.spell_book:
return SpellBookResult(rejections=(Rejection(code="magic.book.duplicate", params={"spell": spell_id}),))
slots = definition.row(caster.level).spell_slots
capacity = slots[template.level - 1] if template.level <= len(slots) else 0
held = sum(1 for held_id in caster.spell_book if catalog.get(held_id).level == template.level)
if held >= capacity:
open_slots = open_book_capacity(caster, definition, catalog)
if template.level > len(open_slots) or open_slots[template.level - 1] == 0:
slots = definition.row(caster.level).spell_slots
capacity = slots[template.level - 1] if template.level <= len(slots) else 0
return SpellBookResult(
rejections=(
Rejection(
Expand Down
42 changes: 41 additions & 1 deletion src/osrlib/crawl/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"GrantItem",
"IdentifyItem",
"InspectTreasure",
"LearnSpell",
"LightSource",
"ListenAtDoor",
"MoveParty",
Expand Down Expand Up @@ -866,6 +867,41 @@ class PrepareSpells(Command):
selections: tuple[MemorizedSpell, ...] = ()


class LearnSpell(Command):
"""Add a spell to an arcane caster's spell book — leveling or mentoring made concrete.

Legal in town and while exploring. The book holds, per spell level, at most
the caster's current slot count at that level, and it never shrinks — a
drained caster's book may sit over capacity, taking nothing more until
capacity catches up. No game time passes: the fiction around the learning
(the mentor's week, a copied scroll's costs) belongs to the game.

Modes:
`town`, `exploring`

Rejections:
- `session.command.wrong_mode` — an encounter or battle is underway, or the
game is over.
- `session.command.unknown_member` — `character_id` names no party member.
- `session.command.member_incapacitated` — the member cannot act.
- `magic.book.not_arcane` — the class keeps no spell book.
- `magic.book.unknown_spell` — `spell_id` names no spell.
- `magic.book.wrong_list` — the spell is off the caster's spell list.
- `magic.book.duplicate` — the book already holds the spell.
- `magic.book.capacity_exceeded` — no open slot at the spell's level.

Events:
[`SpellBookUpdatedEvent`][osrlib.core.events.SpellBookUpdatedEvent] with
the added spell. No game time passes.
"""

allowed_modes: ClassVar[frozenset[SessionMode]] = _FIELD_MODES

command_type: Literal["learn_spell"] = "learn_spell"
character_id: str
spell_id: str


class CastSpell(Command):
"""Cast a memorized spell outside battle (one round).

Expand Down Expand Up @@ -1479,7 +1515,10 @@ class AwardXP(Command):

Events:
[`XpAwardedEvent`][osrlib.crawl.events.XpAwardedEvent] with the award, the
modified award, and the level after.
modified award, and the level after; when the award crosses a level
threshold, a
[`CharacterLeveledUpEvent`][osrlib.crawl.events.CharacterLeveledUpEvent]
follows with the levels, the hit points gained, and the new title.
"""

command_type: Literal["award_xp"] = "award_xp"
Expand Down Expand Up @@ -1729,6 +1768,7 @@ def _expression_must_parse(cls, value: str) -> str:
UnequipItem,
Rest,
PrepareSpells,
LearnSpell,
CastSpell,
UseItem,
UseStairs,
Expand Down
30 changes: 30 additions & 0 deletions src/osrlib/crawl/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"BattleRoundEvent",
"BattleStartedEvent",
"CRAWL_EVENT_CLASSES",
"CharacterLeveledUpEvent",
"CurseRevealedEvent",
"DetectionRolledEvent",
"DiceRolledEvent",
Expand Down Expand Up @@ -674,6 +675,34 @@ class XpAwardedEvent(Event):
level_after: int


class CharacterLeveledUpEvent(Event):
"""One character gained a level — the award's threshold crossing made visible.

Fires immediately after the member's own
[`XpAwardedEvent`][osrlib.crawl.events.XpAwardedEvent] whenever an XP award
crosses a level threshold, whichever surface awarded it (the end-of-adventure
award, the immediate timing, or the referee's
[`AwardXP`][osrlib.crawl.commands.AwardXP]). While the Hit Dice count still
grows, `hp_roll` is the raw die; past name level the gain is the flat-bonus
delta with no die, so `hp_roll` is `None` and `con_applied` is false. `title`
is the class's level title at `level_after`, `None` past the printed title
list (the SRD's lists run only through name level).
"""

allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.level.gained"})

event_type: Literal["leveled_up"] = "leveled_up"
code: str = "session.level.gained"
visibility: Visibility = Visibility.PLAYER
character_id: str
level_before: int
level_after: int
hp_gained: int
hp_roll: int | None
con_applied: bool
title: str | None


class TimeAdvancedEvent(Event):
"""The clock advanced (referee bookkeeping); `rounds_total` is the new position."""

Expand Down Expand Up @@ -759,6 +788,7 @@ class DiceRolledEvent(Event):
FlagSetEvent,
MonstersSpawnedEvent,
XpAwardedEvent,
CharacterLeveledUpEvent,
TimeAdvancedEvent,
GameOverEvent,
DiceRolledEvent,
Expand Down
12 changes: 12 additions & 0 deletions src/osrlib/crawl/exploration.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from osrlib.core.spells import (
MAGIC_STREAM,
CastContext,
add_spell_to_book,
cast_from_scroll,
cast_spell,
caster_profile,
Expand All @@ -76,6 +77,7 @@
ForceDoor,
GiveItems,
InspectTreasure,
LearnSpell,
LightSource,
ListenAtDoor,
MoveParty,
Expand Down Expand Up @@ -2292,6 +2294,15 @@ def _handle_prepare_spells(session, command: PrepareSpells) -> tuple[list[Reject
return [], events


def _handle_learn_spell(session, command: LearnSpell) -> tuple[list[Rejection], list[Event]]:
member, rejections = _member_able(session, command.character_id)
if rejections:
return rejections, []
definition = load_classes().get(member.class_id)
result = add_spell_to_book(member, definition, load_spells(), command.spell_id)
return list(result.rejections), list(result.events)


def _handle_cast_spell(session, command: CastSpell) -> tuple[list[Rejection], list[Event]]:
member, rejections = _member_able(session, command.character_id)
if rejections:
Expand Down Expand Up @@ -3364,6 +3375,7 @@ def _temple_cleric(spell) -> Character:
UnequipItem: _handle_unequip_item,
Rest: _handle_rest,
PrepareSpells: _handle_prepare_spells,
LearnSpell: _handle_learn_spell,
CastSpell: _handle_cast_spell,
UseItem: _handle_use_item,
UseStairs: _handle_use_stairs,
Expand Down
61 changes: 35 additions & 26 deletions src/osrlib/crawl/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

from osrlib.core.alignment import Alignment
from osrlib.core.character import ADVANCEMENT_STREAM, Character
from osrlib.core.classes import apply_xp
from osrlib.core.classes import XpAwardResult, apply_xp, level_title
from osrlib.core.clock import ROUNDS_PER_DAY, ROUNDS_PER_TURN, GameClock, TimeUnit
from osrlib.core.effects import EFFECTS_STREAM, EffectsLedger
from osrlib.core.events import (
Expand Down Expand Up @@ -71,6 +71,7 @@
)
from osrlib.crawl.dungeon import DungeonState, edge_ref
from osrlib.crawl.events import (
CharacterLeveledUpEvent,
DiceRolledEvent,
DoorEvent,
FlagSetEvent,
Expand Down Expand Up @@ -175,6 +176,36 @@ def _member_id(member: Character) -> str:
return member.id


def _xp_award_events(member: Character, result: XpAwardResult) -> list[Event]:
"""`XpAwardedEvent` plus, when the award crossed a threshold, the level event.

Every `apply_xp` call site reports through here, so the ordering — the level
event immediately after the same member's award event — is structural rather
than repeated at each surface.
"""
events: list[Event] = [
XpAwardedEvent(
character_id=_member_id(member),
award=result.award,
modified_award=result.modified_award,
level_after=result.level_after,
)
]
if result.level_up is not None:
events.append(
CharacterLeveledUpEvent(
character_id=_member_id(member),
level_before=result.level_before,
level_after=result.level_after,
hp_gained=result.level_up.hp_gained,
hp_roll=result.level_up.hp_roll,
con_applied=result.level_up.con_applied,
title=level_title(member.definition, result.level_after),
)
)
return events


class Listener(Protocol):
"""The extension-point protocol: games register listeners on the session.

Expand Down Expand Up @@ -656,14 +687,7 @@ def award_adventure_xp(self) -> list[Event]:
if share > 0:
for member in survivors:
result = apply_xp(member, member.definition, share, self.streams.get(ADVANCEMENT_STREAM))
events.append(
XpAwardedEvent(
character_id=_member_id(member),
award=result.award,
modified_award=result.modified_award,
level_after=result.level_after,
)
)
events.extend(_xp_award_events(member, result))
return events

def award_immediate_xp(self, amount: int) -> list[Event]:
Expand All @@ -681,14 +705,7 @@ def award_immediate_xp(self, amount: int) -> list[Event]:
events: list[Event] = []
for member in survivors:
result = apply_xp(member, member.definition, share, self.streams.get(ADVANCEMENT_STREAM))
events.append(
XpAwardedEvent(
character_id=_member_id(member),
award=result.award,
modified_award=result.modified_award,
level_after=result.level_after,
)
)
events.extend(_xp_award_events(member, result))
return events

# ------------------------------------------------------------------ death records
Expand Down Expand Up @@ -814,15 +831,7 @@ def _handle_award_xp(session: GameSession, command: AwardXP) -> tuple[list[Rejec
except ValueError:
return [Rejection(code="session.command.unknown_member", params={"character": command.character_id})], []
result = apply_xp(member, member.definition, command.amount, session.streams.get(ADVANCEMENT_STREAM))
events: list[Event] = [
XpAwardedEvent(
character_id=_member_id(member),
award=result.award,
modified_award=result.modified_award,
level_after=result.level_after,
)
]
return [], events
return [], _xp_award_events(member, result)


def _handle_set_flag(session: GameSession, command: SetFlag) -> tuple[list[Rejection], list[Event]]:
Expand Down
7 changes: 7 additions & 0 deletions src/osrlib/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,13 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str:
"session.xp.awarded": lambda event: (
f"{event.character_id} gains {event.modified_award} XP (base {event.award}), now level {event.level_after}."
),
"session.level.gained": lambda event: (
f"{event.character_id} advances to level {event.level_after}"
+ (f" ({event.title})" if event.title is not None else "")
+ f": +{event.hp_gained} hp"
+ (f" (rolled {event.hp_roll})" if event.hp_roll is not None else "")
+ "."
),
"session.time.advanced": lambda event: f"Time advances {event.n} {event.unit}(s), to round {event.rounds_total}.",
"session.game_over": lambda event: f"The game is over: {event.reason}.",
"adjudication.dice_rolled": lambda event: (
Expand Down
Loading
Loading