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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/building-an-adventure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
112 changes: 80 additions & 32 deletions src/osrlib/core/treasure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -74,6 +76,7 @@
"generate_treasure",
"generate_treasure_entries",
"generate_unguarded_treasure",
"instantiate_magic_item",
"plan_treasure_ref",
"roll_room_contents",
]
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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"):
Expand All @@ -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(
Expand Down
25 changes: 20 additions & 5 deletions src/osrlib/crawl/adventure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand Down Expand Up @@ -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")
Expand All @@ -122,14 +128,22 @@ 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:
"""Validate an adventure's cross-references — the fail-fast content gate.

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
Expand All @@ -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")
Expand Down Expand Up @@ -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":
Expand Down
20 changes: 19 additions & 1 deletion src/osrlib/crawl/content_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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:
Expand Down
13 changes: 10 additions & 3 deletions src/osrlib/crawl/dungeon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading