diff --git a/CHANGELOG.md b/CHANGELOG.md index 22dee8f..75c85dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- `FeatureSpec.magic_item_ids` — hand-placed magic items in authored treasure caches. A cache could always carry coins, mundane equipment ids, and named valuables, but a specific magic item — the sword +1 in the captain's strongbox — was unplaceable: magic entered play only through generated hoards, so an author wanting a particular item in a particular chest had no surface to say so. The new field names any id from the shipped magic-item catalog (see the magic item id index); the instance is created when the cache is emptied, its creation details (charges, quantities, generic armour's base type, scroll contents, sword sentience) rolling on the treasure stream at the tier in force at that moment, exactly as a generated hoard's would — authored content stays frozen, and a save made before the take carries no undiscoverable referee facts. Listing an id twice places two. The instantiation half of `generate_magic_item` is now its own public function, `instantiate_magic_item`, for any caller that needs a named item's details rolled without the table selection. `validate_adventure` resolves the new ids against the magic-item catalog (an unknown id is a hard content error before play), and `validate_content_pack` reports the same gap as the new `pack.feature.unknown_magic_item` finding. The field is additive with an empty default, so there is no schema bump: existing documents load unchanged, and a cache that places no magic items plays exactly as before, consuming no extra draws. + - `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. diff --git a/docs/getting-started/building-an-adventure.md b/docs/getting-started/building-an-adventure.md index f0f9b19..a8acc78 100644 --- a/docs/getting-started/building-an-adventure.md +++ b/docs/getting-started/building-an-adventure.md @@ -51,7 +51,7 @@ A [`KeyedEncounter`][osrlib.crawl.dungeon.KeyedEncounter] lists its monsters by Beyond encounters, an area (or the level itself) can carry: -- [`FeatureSpec`][osrlib.crawl.dungeon.FeatureSpec] — treasure caches with hand-placed items, coins, and named valuables ([`ValuableSpec`][osrlib.crawl.dungeon.ValuableSpec]), construction tricks, or custom content for your front end +- [`FeatureSpec`][osrlib.crawl.dungeon.FeatureSpec] — treasure caches with hand-placed items, magic items (any id from [the magic item id index][magic-items-index] — a `sword_plus_1` in a chest), coins, and named valuables ([`ValuableSpec`][osrlib.crawl.dungeon.ValuableSpec]), construction tricks, or custom content for your front end - [`TrapSpec`][osrlib.crawl.dungeon.TrapSpec] — room traps on areas, treasure traps on caches - [`AreaTreasureSpec`][osrlib.crawl.dungeon.AreaTreasureSpec] — generated treasure: explicit treasure type letters (see [the treasure type index][treasure-types-index]) or the level's unguarded band - [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] — stairs, trapdoors, and chutes between levels (these live on the level, not the area) diff --git a/src/osrlib/core/treasure.py b/src/osrlib/core/treasure.py index 0c66ac0..98ec862 100644 --- a/src/osrlib/core/treasure.py +++ b/src/osrlib/core/treasure.py @@ -10,7 +10,9 @@ [`generate_unguarded_treasure`][osrlib.core.treasure.generate_unguarded_treasure] for an unguarded cache, or at [`generate_magic_item`][osrlib.core.treasure.generate_magic_item] for a single magic -item rolled à la carte. +item rolled à la carte, or at +[`instantiate_magic_item`][osrlib.core.treasure.instantiate_magic_item] for a named +item's creation details alone. Every treasure-type entry parses on one fixed grammar: an optional `NN%: ` presence gate, then a coin quantity (dice with an optional `× K` multiplier folded into the @@ -74,6 +76,7 @@ "generate_treasure", "generate_treasure_entries", "generate_unguarded_treasure", + "instantiate_magic_item", "plan_treasure_ref", "roll_room_contents", ] @@ -743,6 +746,78 @@ def _generate_scroll_spells(template: Any, *, tier: str, stream: RngStream) -> d return {"spell_list": spell_list, "spells": tuple(spell_ids)} +def instantiate_magic_item( + item_id: str, + *, + tier: str, + stream: RngStream, + allocator: Any, + params: Mapping[str, Any] | None = None, +) -> MagicItemInstance: + """Instantiate one specific magic item template, rolling its creation details. + + The named-item half of [`generate_magic_item`][osrlib.core.treasure.generate_magic_item]: + no table selection, just the depth-first detail resolution for `item_id` — the + *Magic Armour Type* d8 for generic armour, charges (rolled at creation, + referee-only forever after), quantities, wish counts, scroll contents, the + energy-drain sword's total, and sword sentience last. This is how hand-placed + magic items ([`FeatureSpec.magic_item_ids`][osrlib.crawl.dungeon.FeatureSpec]) + become instances: the author names the item, the details roll on the treasure + stream when it enters play. + + Args: + item_id: An id from [`load_magic_items`][osrlib.data.load_magic_items] — + see [the magic item id index][magic-items-index]. + tier: `"basic"` or `"expert"` — the printed B or X columns, where a + detail differs by tier (scroll contents, some quantities). + stream: The treasure stream. + allocator: The id allocator (`magic-item` prefix). + params: A generation row's parameter overrides (`quantity_dice`, + `basic_quantity_fixed`, `wish_count_dice`); `None` for direct + placement, which resolves from the template alone. + + Returns: + The generated instance. + + Raises: + ValueError: If `tier` is unknown or `item_id` is not in the catalog. + """ + from osrlib.core.items import MagicItemCategory, MagicItemInstance + from osrlib.data import load_magic_items + + if tier not in ("basic", "expert"): + raise ValueError(f"tier must be 'basic' or 'expert', got {tier!r}") + row_params: Mapping[str, Any] = params if params is not None else {} + catalog = load_magic_items() + template = catalog.get(item_id) + instance = MagicItemInstance( + instance_id=allocator.allocate("magic-item"), + template_id=item_id, + base_item_id=template.base_item_id, + ) + if template.category is MagicItemCategory.ARMOUR and template.base_item_id is None: + instance.base_item_id = catalog.armour_type.base_for_roll(stream.randbelow(8) + 1) + if template.charges_dice is not None: + instance.charges_remaining = roll(template.charges_dice, stream).total + quantity_dice = row_params.get("quantity_dice", template.quantity_dice) + if quantity_dice is not None: + if tier == "basic" and "basic_quantity_fixed" in row_params: + instance.quantity = _int_param(row_params, "basic_quantity_fixed") + else: + instance.quantity = roll(str(quantity_dice), stream).total + wish_dice = row_params.get("wish_count_dice", template.params.get("wish_count_dice")) + if wish_dice is not None: + instance.state = {**instance.state, "wishes_remaining": roll(str(wish_dice), stream).total} + if "spell_count" in template.params: + instance.state = {**instance.state, **_generate_scroll_spells(template, tier=tier, stream=stream)} + if template.effect is not None and template.effect.kind == "on_hit_drain": + drains = roll(str(template.effect.params["total_drains_dice"]), stream).total + instance.state = {**instance.state, "drains_remaining": drains} + if template.category is MagicItemCategory.SWORD: + instance.sentience = _generate_sentience(stream=stream) + return instance + + def generate_magic_item( category: MagicItemType | None, *, @@ -775,7 +850,6 @@ def generate_magic_item( Raises: ValueError: If `tier` is unknown or `category` is excluded. """ - from osrlib.core.items import MagicItemCategory, MagicItemInstance from osrlib.data import load_magic_items, load_treasure_tables if tier not in ("basic", "expert"): @@ -794,36 +868,10 @@ def generate_magic_item( row = sub_table.row_for_basic(stream.randbelow(sub_table.basic_die) + 1) else: row = sub_table.row_for_expert(stream.randbelow(100) + 1) - instances: list[MagicItemInstance] = [] - for item_id in row.item_ids: - template = catalog.get(item_id) - instance = MagicItemInstance( - instance_id=allocator.allocate("magic-item"), - template_id=item_id, - base_item_id=template.base_item_id, - ) - if template.category is MagicItemCategory.ARMOUR and template.base_item_id is None: - instance.base_item_id = catalog.armour_type.base_for_roll(stream.randbelow(8) + 1) - if template.charges_dice is not None: - instance.charges_remaining = roll(template.charges_dice, stream).total - quantity_dice = row.params.get("quantity_dice", template.quantity_dice) - if quantity_dice is not None: - if tier == "basic" and "basic_quantity_fixed" in row.params: - instance.quantity = _int_param(row.params, "basic_quantity_fixed") - else: - instance.quantity = roll(str(quantity_dice), stream).total - wish_dice = row.params.get("wish_count_dice", template.params.get("wish_count_dice")) - if wish_dice is not None: - instance.state = {**instance.state, "wishes_remaining": roll(str(wish_dice), stream).total} - if "spell_count" in template.params: - instance.state = {**instance.state, **_generate_scroll_spells(template, tier=tier, stream=stream)} - if template.effect is not None and template.effect.kind == "on_hit_drain": - drains = roll(str(template.effect.params["total_drains_dice"]), stream).total - instance.state = {**instance.state, "drains_remaining": drains} - if template.category is MagicItemCategory.SWORD: - instance.sentience = _generate_sentience(stream=stream) - instances.append(instance) - return instances + return [ + instantiate_magic_item(item_id, tier=tier, stream=stream, allocator=allocator, params=row.params) + for item_id in row.item_ids + ] def generate_treasure_entries( diff --git a/src/osrlib/crawl/adventure.py b/src/osrlib/crawl/adventure.py index a702463..bbaebb6 100644 --- a/src/osrlib/crawl/adventure.py +++ b/src/osrlib/crawl/adventure.py @@ -15,9 +15,10 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator -from osrlib.core.items import EquipmentCatalog +from osrlib.core.items import EquipmentCatalog, MagicItemCatalog from osrlib.core.monsters import MonsterCatalog, MonsterTemplate from osrlib.crawl.dungeon import DungeonSpec, FeatureSpec, LevelSpec +from osrlib.data import load_magic_items from osrlib.errors import ContentValidationError __all__ = [ @@ -113,7 +114,12 @@ def _effective_monsters(adventure: Adventure, base: MonsterCatalog) -> tuple[Mon def _validate_feature( - feature: FeatureSpec, level: LevelSpec, owner: str, equipment: EquipmentCatalog, errors: list[str] + feature: FeatureSpec, + level: LevelSpec, + owner: str, + equipment: EquipmentCatalog, + magic: MagicItemCatalog, + errors: list[str], ) -> None: if feature.cell is not None and not level.in_bounds(feature.cell): errors.append(f"{owner}: feature {feature.id!r} cell {feature.cell} is out of bounds") @@ -122,6 +128,11 @@ def _validate_feature( equipment.get(item_id) except ValueError: errors.append(f"{owner}: feature {feature.id!r} references unknown item {item_id!r}") + for item_id in feature.magic_item_ids: + try: + magic.get(item_id) + except ValueError: + errors.append(f"{owner}: feature {feature.id!r} references unknown magic item {item_id!r}") def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment: EquipmentCatalog) -> None: @@ -129,7 +140,10 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment Checks: bundled monster ids colliding with the shipped catalog or each other; then, per level: area cells and features in bounds, feature ids unique, cache - item ids resolving against the equipment catalog, keyed-encounter template ids + item ids resolving against the equipment catalog, cache magic item ids + resolving against the shipped magic-item catalog + ([`load_magic_items`][osrlib.data.load_magic_items] — adventures bundle no + magic items, so validation loads it itself), keyed-encounter template ids (and any fixed spawn alignment) and inline wandering-table monster ids resolving against the effective catalog, transition destinations resolving to real cells, town travel entries naming real dungeons, and an entrance existing @@ -146,6 +160,7 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment ContentValidationError: Listing every dangling reference found. """ errors: list[str] = [] + magic = load_magic_items() effective, colliding = _effective_monsters(adventure, monsters) for monster_id in colliding: errors.append(f"bundled monster id {monster_id!r} collides with the catalog") @@ -189,11 +204,11 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment f"outside {keyed.template_id!r}'s options" ) for feature in area.features: - _validate_feature(feature, level, owner, equipment, errors) + _validate_feature(feature, level, owner, equipment, magic, errors) for feature in level.features: if feature.cell is None: errors.append(f"{owner}: level-scope feature {feature.id!r} needs a cell") - _validate_feature(feature, level, owner, equipment, errors) + _validate_feature(feature, level, owner, equipment, magic, errors) if level.wandering.table is not None: for row in level.wandering.table.rows: if row.entry.kind != "monster": diff --git a/src/osrlib/crawl/content_pack.py b/src/osrlib/crawl/content_pack.py index 09c2735..97fbf23 100644 --- a/src/osrlib/crawl/content_pack.py +++ b/src/osrlib/crawl/content_pack.py @@ -66,6 +66,7 @@ from osrlib.core.items import EquipmentCatalog from osrlib.core.monsters import MonsterCatalog, MonsterTemplate from osrlib.crawl.dungeon import AreaTreasureSpec, FeatureSpec, KeyedEncounter, TrapSpec, WanderingSpec +from osrlib.data import load_magic_items from osrlib.errors import ContentValidationError from osrlib.versioning import check_document, stamp_document @@ -233,7 +234,10 @@ def validate_content_pack( Checks every monster reference (keyed-encounter lines and wandering-table rows) against the union of the shipped catalog and the pack's bundled - monsters, and every feature's `item_ids` against the equipment catalog. + monsters, every feature's `item_ids` against the equipment catalog, and every + feature's `magic_item_ids` against the shipped magic-item catalog + ([`load_magic_items`][osrlib.data.load_magic_items] — packs bundle no magic + items, so the check loads it itself). Findings are data, not errors: a dangling reference is legal in a pack exactly as it is while editing an adventure, and a caller wanting a gate checks for a non-empty result. @@ -250,6 +254,7 @@ def validate_content_pack( """ known_monsters = {template.id for template in monsters.monsters} known_monsters.update(template.id for template in pack.monsters) + magic = load_magic_items() findings: list[PackFinding] = [] def check_items(features: tuple[FeatureSpec, ...], entry_id: str) -> None: @@ -265,6 +270,19 @@ def check_items(features: tuple[FeatureSpec, ...], entry_id: str) -> None: entry_id=entry_id, ) ) + for item_id in feature.magic_item_ids: + try: + magic.get(item_id) + except ValueError: + findings.append( + PackFinding( + code="pack.feature.unknown_magic_item", + message=( + f"entry {entry_id!r} feature {feature.id!r} references unknown magic item {item_id!r}" + ), + entry_id=entry_id, + ) + ) for section in pack.sections: for entry in section.entries: diff --git a/src/osrlib/crawl/dungeon.py b/src/osrlib/crawl/dungeon.py index f624c0f..65a24be 100644 --- a/src/osrlib/crawl/dungeon.py +++ b/src/osrlib/crawl/dungeon.py @@ -368,10 +368,16 @@ class FeatureSpec(BaseModel): Stairs are [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec]'s alone — no second home. Caches carry hand-placed contents — `item_ids` (any id from [`load_equipment`][osrlib.data.load_equipment], see - [the equipment id index][equipment-index]) and `coins` — plus an optional + [the equipment id index][equipment-index]), `magic_item_ids` (any id from + [`load_magic_items`][osrlib.data.load_magic_items], see + [the magic item id index][magic-items-index]), and `coins` — plus an optional treasure trap, so a cache's contents can be dropped, found, and recovered like - any other treasure. `cell` binds the feature to a cell; a feature listed on an - area with `cell=None` binds to the whole area. + any other treasure. Hand-placed magic items instantiate when the cache is + emptied: the author names the item, and its creation details (charges, + quantities, sword sentience) roll on the treasure stream via + [`instantiate_magic_item`][osrlib.core.treasure.instantiate_magic_item]. + `cell` binds the feature to a cell; a feature listed on an area with + `cell=None` binds to the whole area. """ model_config = ConfigDict(frozen=True) @@ -381,6 +387,7 @@ class FeatureSpec(BaseModel): description: str = "" cell: Position | None = None item_ids: tuple[str, ...] = () + magic_item_ids: tuple[str, ...] = () coins: Coins = Coins() valuables: tuple[ValuableSpec, ...] = () trap: TrapSpec | None = None diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index f0a6298..9f741b0 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -1590,6 +1590,7 @@ def _handle_take_treasure(session, command: TakeTreasure) -> tuple[list[Rejectio # back to the party rather than stranding the haul on a corpse. carriers = [member for member in carriers if not incapacitated(member)] or session.party.living_members() from osrlib.core.items import ValuableInstance + from osrlib.core.treasure import TREASURE_STREAM, instantiate_magic_item haul = DropPile( coins=feature.coins, @@ -1605,6 +1606,18 @@ def _handle_take_treasure(session, command: TakeTreasure) -> tuple[list[Rejectio ) for spec in feature.valuables ], + # Authored magic items instantiate on take as well: creation details roll + # on the treasure stream at the tier in force when the cache is emptied, + # exactly as a generated hoard's would. + magic_items=[ + instantiate_magic_item( + item_id, + tier=_treasure_tier(session), + stream=session.streams.get(TREASURE_STREAM), + allocator=session.allocator, + ) + for item_id in feature.magic_item_ids + ], ) state.emptied_caches.append(ref) events.extend(_take_haul(session, carriers, haul)) diff --git a/tests/test_content_pack.py b/tests/test_content_pack.py index 4d98eff..0353b39 100644 --- a/tests/test_content_pack.py +++ b/tests/test_content_pack.py @@ -183,3 +183,14 @@ def test_unknown_feature_item_is_a_finding(self): assert [finding.code for finding in findings] == ["pack.feature.unknown_item"] assert findings[0].entry_id == "cache" assert "'no-such-item'" in findings[0].message + + def test_unknown_feature_magic_item_is_a_finding(self): + entry = ContentPackEntry( + id="cache", + features=(FeatureSpec(id="chest", kind="treasure_cache", magic_item_ids=("sword_plus_1", "sword_plus_6")),), + ) + pack = make_pack(sections=(PackSection(id="level-1", entries=(entry,)),)) + findings = validate_content_pack(pack, MONSTERS, EQUIPMENT) + assert [finding.code for finding in findings] == ["pack.feature.unknown_magic_item"] + assert findings[0].entry_id == "cache" + assert "'sword_plus_6'" in findings[0].message diff --git a/tests/test_dungeon.py b/tests/test_dungeon.py index eba4a21..6068f7d 100644 --- a/tests/test_dungeon.py +++ b/tests/test_dungeon.py @@ -106,6 +106,33 @@ def test_unknown_monster_and_item(self): assert "gazebo" in str(excinfo.value) assert "vorpal_sword" in str(excinfo.value) + def test_placed_magic_item_ids_resolve_against_the_shipped_catalog(self): + adventure = build_adventure() + dungeon = adventure.dungeon("delve") + level = dungeon.level(1) + room = next(area for area in level.areas if area.id == "room_a") + chest = room.features[0].model_copy(update={"magic_item_ids": ("sword_plus_1", "wand_of_fear")}) + good_room = room.model_copy(update={"features": (chest,)}) + good_level = level.model_copy(update={"areas": (level.areas[0], good_room)}) + good = adventure.model_copy( + update={"dungeons": (dungeon.model_copy(update={"levels": (good_level, dungeon.levels[1])}),)} + ) + validate_adventure(good, load_monsters(), load_equipment()) + + def test_unknown_magic_item(self): + adventure = build_adventure() + dungeon = adventure.dungeon("delve") + level = dungeon.level(1) + room = next(area for area in level.areas if area.id == "room_a") + bad_chest = room.features[0].model_copy(update={"magic_item_ids": ("sword_plus_6",)}) + bad_room = room.model_copy(update={"features": (bad_chest,)}) + bad_level = level.model_copy(update={"areas": (level.areas[0], bad_room)}) + bad = adventure.model_copy( + update={"dungeons": (dungeon.model_copy(update={"levels": (bad_level, dungeon.levels[1])}),)} + ) + with pytest.raises(ContentValidationError, match="unknown magic item 'sword_plus_6'"): + validate_adventure(bad, load_monsters(), load_equipment()) + def test_dangling_transition_target(self): adventure = build_adventure() dungeon = adventure.dungeon("delve") diff --git a/tests/test_exploration.py b/tests/test_exploration.py index 37b1c9b..c6f3d41 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -655,6 +655,68 @@ def test_the_party_total_is_conserved_across_a_take(self): assert session.party_valuation_cp() == before + Coins(gp=100, ep=3).value_cp + (700 + 250) * 100 +class TestAuthoredMagicItems: + """Hand-placed `FeatureSpec.magic_item_ids`: named items instantiate when the cache empties.""" + + def build_at_magic_chest(self, seed: int = 5): + # The shared fixture's chest, with two named magic items placed and the + # needle trap removed so the take is roll-free up to instantiation. + adventure = build_adventure(wandering_chance=0) + dungeon = adventure.dungeon("delve") + level = dungeon.level(1) + room = next(area for area in level.areas if area.id == "room_a") + chest = room.features[0].model_copy(update={"magic_item_ids": ("sword_plus_1", "wand_of_fear"), "trap": None}) + patched_room = room.model_copy(update={"features": (chest,)}) + patched_level = level.model_copy(update={"areas": (level.areas[0], patched_room)}) + patched = adventure.model_copy( + update={"dungeons": (dungeon.model_copy(update={"levels": (patched_level, dungeon.levels[1])}),)} + ) + session = GameSession.new(build_party(), patched, seed=seed) + session.execute(GrantItem(character_id="character-0001", item_id="torch", quantity=6)) + session.execute(GrantItem(character_id="character-0001", item_id="tinder_box")) + entered(session) + place(session, (3, 2)) + return session + + def carried_magic(self, session) -> list[MagicItemInstance]: + return [ + instance + for member in session.party.living_members() + for instance in member.inventory.items + if isinstance(instance, MagicItemInstance) + ] + + def test_placed_magic_items_instantiate_on_take(self): + session = self.build_at_magic_chest() + result = session.execute(TakeTreasure(feature_id="chest")) + assert result.accepted + carried = self.carried_magic(session) + assert sorted(instance.template_id for instance in carried) == ["sword_plus_1", "wand_of_fear"] + assert all(not instance.identified for instance in carried) + wand = next(instance for instance in carried if instance.template_id == "wand_of_fear") + assert wand.charges_remaining is not None and wand.charges_remaining >= 2 # 2d10, rolled on take + acquired_ids = [ + item_id + for event in result.events + if event.code == "exploration.item.acquired" + for item_id in event.item_ids + ] + assert {instance.instance_id for instance in carried} <= set(acquired_ids) + assert "delve:1:chest" in session.dungeon_state.emptied_caches + assert not session.execute(TakeTreasure(feature_id="chest")).accepted + + def test_the_details_are_identical_across_two_seeded_runs(self): + def run(): + session = self.build_at_magic_chest(seed=11) + assert session.execute(TakeTreasure(feature_id="chest")).accepted + return sorted( + (instance.model_dump(mode="json") for instance in self.carried_magic(session)), + key=lambda dumped: dumped["instance_id"], + ) + + assert run() == run() + + class TestTrapResolutionCensus: """The Designing_a_Dungeon example traps, resolved through the kernel.""" diff --git a/tests/test_treasure_kernel.py b/tests/test_treasure_kernel.py index dc53a62..4436c4f 100644 --- a/tests/test_treasure_kernel.py +++ b/tests/test_treasure_kernel.py @@ -2,6 +2,8 @@ from collections import Counter +import pytest + from osrlib.core.items import MagicItemCategory from osrlib.core.monsters import IdAllocator from osrlib.core.rng import RngStream @@ -11,6 +13,7 @@ generate_magic_item, generate_treasure, generate_unguarded_treasure, + instantiate_magic_item, plan_treasure_ref, roll_room_contents, ) @@ -175,6 +178,40 @@ def test_generated_items_enter_play_unidentified(self): assert not items[0].identified +class TestMagicItemInstantiation: + """`instantiate_magic_item`: a named item's creation details, no table selection.""" + + def test_named_item_rolls_its_creation_details(self): + instance = instantiate_magic_item("wand_of_fear", tier="expert", stream=stream_for(3), allocator=IdAllocator()) + assert instance.instance_id == "magic-item-0001" + assert instance.template_id == "wand_of_fear" + assert instance.charges_remaining is not None and instance.charges_remaining >= 2 # 2d10, rolled at creation + assert not instance.identified + + def test_same_seed_same_instance(self): + first = instantiate_magic_item("sword_plus_1", tier="expert", stream=stream_for(7), allocator=IdAllocator()) + second = instantiate_magic_item("sword_plus_1", tier="expert", stream=stream_for(7), allocator=IdAllocator()) + assert first == second + + def test_generic_armour_rolls_its_base_type(self): + instance = instantiate_magic_item("armour_plus_1", tier="expert", stream=stream_for(9), allocator=IdAllocator()) + assert instance.base_item_id is not None + + def test_ammunition_quantity_rolls_from_the_template(self): + instance = instantiate_magic_item( + "arrows_plus_1", tier="expert", stream=stream_for(13), allocator=IdAllocator() + ) + assert instance.quantity >= 3 # the template's 3d10, not the default single + + def test_unknown_id_raises(self): + with pytest.raises(ValueError): + instantiate_magic_item("vorpal_sword", tier="expert", stream=stream_for(1), allocator=IdAllocator()) + + def test_unknown_tier_raises(self): + with pytest.raises(ValueError, match="tier"): + instantiate_magic_item("sword_plus_1", tier="epic", stream=stream_for(1), allocator=IdAllocator()) + + class TestTreasureRefSemantics: def test_the_six_case_census(self): monsters = load_monsters()