diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa9878..8ab1a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/osrlib/core/classes.py b/src/osrlib/core/classes.py index 394f16c..a34b3f3 100644 --- a/src/osrlib/core/classes.py +++ b/src/osrlib/core/classes.py @@ -66,6 +66,7 @@ "detection_chance", "detection_check", "drain_levels", + "level_title", "level_up", "thief_skill_check", "xp_modifier_pct", @@ -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. diff --git a/src/osrlib/core/spells.py b/src/osrlib/core/spells.py index 705c46a..cee9266 100644 --- a/src/osrlib/core/spells.py +++ b/src/osrlib/core/spells.py @@ -131,6 +131,7 @@ "forget_excess_memorized", "memorize_spells", "minimum_caster_level", + "open_book_capacity", "pop_mirror_image", "turn_undead", "validate_cast", @@ -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: @@ -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( diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 30b8a02..360f611 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -56,6 +56,7 @@ "GrantItem", "IdentifyItem", "InspectTreasure", + "LearnSpell", "LightSource", "ListenAtDoor", "MoveParty", @@ -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). @@ -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" @@ -1729,6 +1768,7 @@ def _expression_must_parse(cls, value: str) -> str: UnequipItem, Rest, PrepareSpells, + LearnSpell, CastSpell, UseItem, UseStairs, diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index ed4f08b..86a271c 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -29,6 +29,7 @@ "BattleRoundEvent", "BattleStartedEvent", "CRAWL_EVENT_CLASSES", + "CharacterLeveledUpEvent", "CurseRevealedEvent", "DetectionRolledEvent", "DiceRolledEvent", @@ -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.""" @@ -759,6 +788,7 @@ class DiceRolledEvent(Event): FlagSetEvent, MonstersSpawnedEvent, XpAwardedEvent, + CharacterLeveledUpEvent, TimeAdvancedEvent, GameOverEvent, DiceRolledEvent, diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index e60615e..7347ee6 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -58,6 +58,7 @@ from osrlib.core.spells import ( MAGIC_STREAM, CastContext, + add_spell_to_book, cast_from_scroll, cast_spell, caster_profile, @@ -76,6 +77,7 @@ ForceDoor, GiveItems, InspectTreasure, + LearnSpell, LightSource, ListenAtDoor, MoveParty, @@ -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: @@ -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, diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index cf9730d..dc12e56 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -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 ( @@ -71,6 +71,7 @@ ) from osrlib.crawl.dungeon import DungeonState, edge_ref from osrlib.crawl.events import ( + CharacterLeveledUpEvent, DiceRolledEvent, DoorEvent, FlagSetEvent, @@ -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. @@ -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]: @@ -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 @@ -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]]: diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index d76a42c..3b08698 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -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: ( diff --git a/tests/goldens/phase5_milestone.json b/tests/goldens/phase5_milestone.json index a53d351..9c853bc 100644 --- a/tests/goldens/phase5_milestone.json +++ b/tests/goldens/phase5_milestone.json @@ -3031,6 +3031,18 @@ "modified_award": 1201, "visibility": "player" }, + { + "character_id": "character-0003", + "code": "session.level.gained", + "con_applied": true, + "event_type": "leveled_up", + "hp_gained": 4, + "hp_roll": 4, + "level_after": 2, + "level_before": 1, + "title": "Footpad", + "visibility": "player" + }, { "award": 1092, "character_id": "character-0004", @@ -3199,7 +3211,7 @@ }, "master_seed": 21, "party_document": { - "engine_version": "1.3.0", + "engine_version": "1.4.0", "kind": "party", "payload": { "characters": [ @@ -3737,6 +3749,7 @@ "character-0001 gains 1146 XP (base 1092), now level 1.", "character-0002 gains 873 XP (base 1092), now level 1.", "character-0003 gains 1201 XP (base 1092), now level 2.", + "character-0003 advances to level 2 (Footpad): +4 hp (rolled 4).", "character-0004 gains 1201 XP (base 1092), now level 1.", "Flag quest.idol = 'recovered'.", "character-0001 gains 630 XP (base 600), now level 1.", diff --git a/tests/test_award.py b/tests/test_award.py index 9535ba3..3f5616c 100644 --- a/tests/test_award.py +++ b/tests/test_award.py @@ -113,6 +113,36 @@ def test_snapshot_resets_on_the_next_departure(self): session.execute(EnterDungeon(dungeon_id="delve")) assert session.treasure_snapshot_cp is not None + def test_the_return_award_reports_each_level_gained(self): + session = build_session() + session.execute(EnterDungeon(dungeon_id="delve")) + session.defeated_monsters.append( + DefeatedMonsterRecord(monster_id="monster-0001", template_id="goblin", outcome="slain", xp=8000) + ) + result = go_home(session) + events = list(result.events) + # The 2000 XP share levels the fighter (threshold 2000), thief (1200), and + # cleric (1500, +5% from WIS 13); the magic-user (2500) stays at level 1. + levelers = {"character-0001", "character-0002", "character-0003"} + for member in session.party.living_members(): + awarded_index = next( + index + for index, event in enumerate(events) + if event.code == "session.xp.awarded" and event.character_id == member.id + ) + member_level_events = [ + event for event in events if event.code == "session.level.gained" and event.character_id == member.id + ] + if member.id in levelers: + assert member.level == 2 + assert len(member_level_events) == 1 + follower = events[awarded_index + 1] + assert follower.code == "session.level.gained" + assert follower.character_id == member.id + else: + assert member.level == 1 + assert member_level_events == [] + class TestImmediateTiming: def test_treasure_awards_at_acquisition_and_town_awards_nothing(self): @@ -138,6 +168,34 @@ def test_treasure_awards_at_acquisition_and_town_awards_nothing(self): assert not any(event.code == "session.xp.adventure_award" for event in result.events) assert [m.xp - x for m, x in zip(session.party.members, before, strict=True)] == gained + def test_immediate_award_reports_the_level(self): + ruleset = Ruleset(xp_award_timing=XpAwardTiming.IMMEDIATE) + session = build_session(seed=9, ruleset=ruleset) + session.execute(EnterDungeon(dungeon_id="delve")) + from osrlib.crawl import exploration + from osrlib.crawl.dungeon import DropPile + + ref = exploration._cell_ref(session) + session.dungeon_state.piles[ref] = DropPile(coins=Coins(gp=4800)) + result = session.execute(TakeTreasure(feature_id="pile")) + assert result.accepted + events = list(result.events) + # The 1200 XP share levels the thief alone; the level event follows their award. + thief = session.member("character-0002") + assert thief.level == 2 + awarded_index = next( + index + for index, event in enumerate(events) + if event.code == "session.xp.awarded" and event.character_id == thief.id + ) + leveled = events[awarded_index + 1] + assert leveled.code == "session.level.gained" + assert leveled.character_id == thief.id + assert leveled.level_before == 1 + assert leveled.level_after == 2 + level_events = [event for event in events if event.code == "session.level.gained"] + assert [event.character_id for event in level_events] == [thief.id] + def test_no_double_award_across_the_timings(self): # on_return: the same 200 gp taken and returned awards once, at return. session = build_session(seed=9) diff --git a/tests/test_commands.py b/tests/test_commands.py index 6e75883..04d2190 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -54,6 +54,7 @@ def sample_command(command_class): "UnequipItem": dict(character_id="pc-1", item_id="sword"), "Rest": dict(kind="turn"), "PrepareSpells": dict(character_id="pc-1"), + "LearnSpell": dict(character_id="pc-1", spell_id="sleep"), "CastSpell": dict(character_id="pc-1", spell_id="light", mode="illuminate"), "UseStairs": dict(), "EnterDungeon": dict(dungeon_id="delve"), @@ -126,6 +127,10 @@ def test_referee_commands_are_legal_everywhere(self): def test_battle_round_is_battle_only(self): assert ResolveBattleRound.allowed_modes == frozenset({SessionMode.BATTLE}) + def test_learn_spell_is_a_field_command(self): + learn_spell = next(cls for cls in ALL_COMMAND_CLASSES if cls.__name__ == "LearnSpell") + assert learn_spell.allowed_modes == frozenset({SessionMode.TOWN, SessionMode.EXPLORING}) + def test_turn_undead_is_encounter_only(self): # The one aggressive act with a pre-battle procedure of its own (pinned): # in battle it is a declaration kind, and exploration has no candidates. diff --git a/tests/test_events_kernel.py b/tests/test_events_kernel.py index 5c9ea18..4401ffa 100644 --- a/tests/test_events_kernel.py +++ b/tests/test_events_kernel.py @@ -117,6 +117,15 @@ def sample_event(event_class, code): "FlagSetEvent": dict(key="portcullis_open", value=True), "MonstersSpawnedEvent": dict(template_id="goblin", monster_ids=("monster-0001",)), "XpAwardedEvent": dict(character_id="pc-1", award=100, modified_award=110, level_after=2), + "CharacterLeveledUpEvent": dict( + character_id="pc-1", + level_before=1, + level_after=2, + hp_gained=5, + hp_roll=4, + con_applied=True, + title="Warrior", + ), "TimeAdvancedEvent": dict(n=2, unit="turn", rounds_total=120), "GameOverEvent": dict(reason="tpk"), "DiceRolledEvent": dict(expression="2d6", total=7, rolls=(3, 4)), diff --git a/tests/test_memorization.py b/tests/test_memorization.py index 0559410..6312392 100644 --- a/tests/test_memorization.py +++ b/tests/test_memorization.py @@ -2,6 +2,7 @@ import pytest +from crawl_fixtures import build_adventure, build_party from osrlib.core.alignment import Alignment from osrlib.core.character import ( choose_starting_spells, @@ -9,6 +10,7 @@ validate_starting_spells, ) from osrlib.core.classes import apply_xp, drain_levels +from osrlib.core.effects import ActiveCondition, Condition from osrlib.core.rng import RngStreams from osrlib.core.ruleset import Ruleset from osrlib.core.spells import ( @@ -16,7 +18,10 @@ add_spell_to_book, caster_profile, memorize_spells, + open_book_capacity, ) +from osrlib.crawl.commands import AwardXP, LearnSpell +from osrlib.crawl.session import GameSession from osrlib.data import load_classes, load_spells MASTER_SEED = 20_260_703 @@ -27,6 +32,11 @@ def streams(): return RngStreams(master_seed=MASTER_SEED) +@pytest.fixture +def session(): + return GameSession.new(build_party(), build_adventure(), seed=MASTER_SEED) + + def make_character(streams, class_id, *, level=1, starting_spells=()): # Creation seed-searches independently (the demihuman classes have score # requirements); leveling draws from the shared fixture streams. @@ -125,6 +135,53 @@ def test_duplicates_wrong_list_and_non_arcane_rejected(self, streams): ) +class TestOpenBookCapacity: + def test_counts_per_level_for_a_leveled_magic_user(self, streams): + magic_user, definition = make_character(streams, "magic_user", level=3, starting_spells=("sleep",)) + # Level 3 slots are (2, 1, 0, ...); the book holds one first-level spell. + assert open_book_capacity(magic_user, definition, load_spells()) == (1, 1, 0, 0, 0, 0) + + def test_the_floor_holds_an_over_capacity_book_at_zero(self, streams): + magic_user, definition = make_character(streams, "magic_user", level=2, starting_spells=("sleep",)) + add_spell_to_book(magic_user, definition, load_spells(), "charm_person") + drain_levels(magic_user, definition, xp_policy="level_minimum", stream=streams.get("advancement")) + # Level 1 capacity is one; the drained book holds two — floored at zero, never negative. + assert open_book_capacity(magic_user, definition, load_spells())[0] == 0 + + def test_non_arcane_classes_answer_the_empty_tuple(self, streams): + cleric, definition = make_character(streams, "cleric") + assert open_book_capacity(cleric, definition, load_spells()) == () + + +class TestLearnSpellCommand: + def test_learning_grows_the_book_in_zero_time(self, session): + session.execute(AwardXP(character_id="character-0004", amount=2500)) # level 2: one open slot + before_rounds = session.clock.rounds + result = session.execute(LearnSpell(character_id="character-0004", spell_id="charm_person")) + assert result.accepted + assert session.member("character-0004").spell_book == ("sleep", "charm_person") + assert [event.code for event in result.events] == ["magic.book.added"] + assert session.command_log[-1] == LearnSpell(character_id="character-0004", spell_id="charm_person") + assert session.clock.rounds == before_rounds + + def test_duplicate_and_capacity_rejected(self, session): + result = session.execute(LearnSpell(character_id="character-0004", spell_id="sleep")) + assert result.rejections[0].code == "magic.book.duplicate" + result = session.execute(LearnSpell(character_id="character-0004", spell_id="charm_person")) + assert result.rejections[0].code == "magic.book.capacity_exceeded" + + def test_non_arcane_member_rejected(self, session): + result = session.execute(LearnSpell(character_id="character-0003", spell_id="cure_light_wounds")) + assert result.rejections[0].code == "magic.book.not_arcane" + + def test_incapacitated_member_rejected(self, session): + member = session.member("character-0004") + member.current_hp = 0 + member.conditions = (ActiveCondition(condition=Condition.DEAD, effect_id=None),) + result = session.execute(LearnSpell(character_id="character-0004", spell_id="charm_person")) + assert result.rejections[0].code == "session.command.member_incapacitated" + + class TestMemorizeSpells: def test_divine_free_choice_from_the_list(self, streams): cleric, definition = make_character(streams, "cleric", level=4) diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 8d4b392..346dedc 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -12,6 +12,7 @@ EnterDungeon, ForceDoor, GrantItem, + LearnSpell, LightSource, MoveParty, RollDice, @@ -147,6 +148,21 @@ def test_adjudication_roll_survives_save_load_and_replay(self): replayed = replay_game(SEED, party_document, build_adventure(), Ruleset(), accepted) assert session_state(replayed) == session_state(session) + def test_replay_reproduces_a_learned_spell(self): + session = GameSession.new(build_party(), build_adventure(), seed=SEED) + commands = [ + AwardXP(character_id="character-0004", amount=2500), # level 2 opens a book slot + LearnSpell(character_id="character-0004", spell_id="charm_person"), + ] + accepted = [command for command in commands if session.execute(command).accepted] + assert session.member("character-0004").spell_book == ("sleep", "charm_person") + restored = load_game(json.loads(json.dumps(save_game(session)))) + assert restored.member("character-0004").spell_book == ("sleep", "charm_person") + party_document = party_to_document(build_party().members) + replayed = replay_game(SEED, party_document, build_adventure(), Ruleset(), accepted) + assert replayed.member("character-0004").spell_book == ("sleep", "charm_person") + assert session_state(replayed) == session_state(session) + def test_replay_under_a_different_engine_version_raises(self): party_document = party_to_document(build_party().members) with pytest.raises(ReplayVersionError): diff --git a/tests/test_session.py b/tests/test_session.py index bc71c15..2e5ecc4 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -140,10 +140,33 @@ def test_award_xp_levels_up(self): session = make_session() result = session.execute(AwardXP(character_id="character-0001", amount=2500)) assert result.accepted + awarded, leveled = result.events + assert awarded.code == "session.xp.awarded" + assert leveled.code == "session.level.gained" + assert session.member("character-0001").level == 2 + assert awarded.level_after == 2 + + def test_award_xp_emits_the_level_event_fields(self): + from osrlib.data import load_classes + + session = make_session() + result = session.execute(AwardXP(character_id="character-0001", amount=2500)) + leveled = result.events[1] + assert leveled.character_id == "character-0001" + assert leveled.level_before == 1 + assert leveled.level_after == 2 + assert leveled.hp_gained >= 1 + assert leveled.hp_roll is not None + assert isinstance(leveled.con_applied, bool) + assert leveled.title == load_classes().get("fighter").level_titles[1] + + def test_award_below_threshold_emits_no_level_event(self): + session = make_session() + result = session.execute(AwardXP(character_id="character-0001", amount=100)) + assert result.accepted (event,) = result.events assert event.code == "session.xp.awarded" - assert session.member("character-0001").level == 2 - assert event.level_after == 2 + assert session.member("character-0001").level == 1 def test_award_xp_clamps_one_level_per_award(self): session = make_session()