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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Changed

- [`RemoveMember`][osrlib.crawl.commands.RemoveMember] without `give_to` now refuses to remove a member who carries an item the adventure depends on, so the party can't lose a quest item by dropping the member who held it. The adventure depends on an item when a gate on a door or transition, a trigger that can still fire, or any clause of a quest that isn't completed names its catalog id. The session refuses with `party.remove.carries_quest_item`, and the rejection's `character` and `item` params give the member and the item. Name a member in `give_to` and the item moves to them, so the removal goes through.

## [2.2.0] - 2026-10-30

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/front-ends/llm-referees.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Player commands let the model drive the party's turn. Referee commands let it *r
- [`GrantItem`][osrlib.crawl.commands.GrantItem], [`GrantCoins`][osrlib.crawl.commands.GrantCoins], [`AwardXP`][osrlib.crawl.commands.AwardXP] - place rewards directly, magic items included
- [`RemoveItem`][osrlib.crawl.commands.RemoveItem], [`RemoveCoins`][osrlib.crawl.commands.RemoveCoins] - take items or coins away: a price, a toll, armour a shop buys back
- [`AddMember`][osrlib.crawl.commands.AddMember] - bring a new character into the party in town or while exploring, like a replacement for a fallen member. The new member joins at the back of the marching order, and the event gives their id
- [`RemoveMember`][osrlib.crawl.commands.RemoveMember] - take a character out of the party for good in town or while exploring, like one who died too long ago for *raise dead*. Name another member in `give_to` to keep what the character carries in the party. The removed id is never used again
- [`RemoveMember`][osrlib.crawl.commands.RemoveMember] - take a character out of the party for good in town or while exploring, like one who died too long ago for *raise dead*. Name another member in `give_to` to keep what the character carries in the party. The removed id is never used again. The session refuses with `party.remove.carries_quest_item` when you leave `give_to` out and the character carries an item the adventure depends on, so name a member to take it.
- [`SetDoorState`][osrlib.crawl.commands.SetDoorState] - rewrite any door's state anywhere: lock it, wedge it, reveal it
- [`PlaceParty`][osrlib.crawl.commands.PlaceParty] and [`AdvanceTime`][osrlib.crawl.commands.AdvanceTime] - teleport the party, advance the clock
- [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry] and [`RecordNote`][osrlib.crawl.commands.RecordNote] - what the agent records in the game world: a journal entry is written for the players and appears in their view, and a note is for the referee only and stays out of the player view
Expand Down
26 changes: 25 additions & 1 deletion src/osrlib/crawl/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -3284,6 +3284,24 @@ class RemoveMember(Command):
new owner can drop it or give it away. If they put it on, the curse is revealed again.
Without `give_to`, everything the member carries leaves the game with them.

Without `give_to`, the session refuses to remove a member who carries an item the adventure
depends on, because the party might then never open a door or finish a quest. The adventure
depends on an item when its catalog id appears in a
[`HasItemCondition`][osrlib.crawl.gates.HasItemCondition] or an
[`ItemAcquiredPattern`][osrlib.crawl.triggers.ItemAcquiredPattern] anywhere in the adventure.
That means the gate of a door, a stair, or a passage, a trigger, or any clause of a quest. The
session matches ids the way a `HasItemCondition` does. Mundane items, magic items, and the
adventure's own items all count, equipped or not. An item counts even when another member
carries the same item too. When the session refuses, send the command again and name a
member in `give_to` to take everything, the item included.

Some parts of an adventure can't matter again, and they stop protecting their items. A quest
that [`GameSession.quests`][osrlib.crawl.session.GameSession.quests] shows as `completed`
protects nothing. A trigger protects nothing once it has fired, which means its id is in
`GameSession.fired_triggers`, unless it's `repeatable`. A quest that is still open protects
the items in all its clauses, even an objective that is already complete. A gate always
protects its item, because the party can come back to it.

The session also drops what it tracks about the member. The effects on them end without
events, and their death record, their food and water counts, and the record of when they
last prepared spells are deleted. An effect the member cast on another member lasts its full
Expand Down Expand Up @@ -3321,6 +3339,11 @@ class RemoveMember(Command):
they're dead, asleep, paralysed, or turned to stone.
- `party.remove.last_standing` - no member would be left standing. That includes
removing the only member.
- `party.remove.carries_quest_item` - `give_to` is `None`, and the member carries an
item the adventure depends on. The rejection includes the member's id as `character`
and the item's catalog id as `item`. When the member carries more than one such item,
it names the first in the order that
[`Inventory.all_instances`][osrlib.core.items.Inventory.all_instances] lists them.

The session checks in this order and reports the first that fails.

Expand All @@ -3341,7 +3364,8 @@ class RemoveMember(Command):
give_to: str | None = None
"""The member who takes everything the leaving member carries, by
[`MemberView.id`][osrlib.crawl.views.MemberView.id]. Leave it `None` to let it all leave the
game with the member. The member named here has to be able to act."""
game with the member. The session refuses `None` when the member carries an item the
adventure depends on. The member named here has to be able to act."""


class AwardXP(Command):
Expand Down
57 changes: 57 additions & 0 deletions src/osrlib/crawl/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,10 @@
TriggerFiredEvent,
XpAwardedEvent,
)
from osrlib.crawl.gates import ConditionSpec, HasItemCondition
from osrlib.crawl.party import Party, _anyone_standing, _stands
from osrlib.crawl.quests import ObjectiveSpec, QuestSpec
from osrlib.crawl.triggers import ItemAcquiredPattern, TriggerPattern
from osrlib.data import load_equipment, load_magic_items, load_monsters
from osrlib.errors import ContentValidationError
from osrlib.versioning import SCHEMA_VERSION, engine_version
Expand Down Expand Up @@ -2312,6 +2314,19 @@ def _handle_remove_member(session: GameSession, command: RemoveMember) -> tuple[
return [Rejection(code="session.command.member_incapacitated", params={"character": command.give_to})], []
if not any(_stands(other) for other in session.party.members if other is not member):
return [Rejection(code="party.remove.last_standing", params={"character": command.character_id})], []
if recipient is None:
needed = _quest_item_ids(session)
for instance in member.inventory.all_instances():
if instance.quantity < 1:
continue
item_id = instance.template_id if isinstance(instance, MagicItemInstance) else instance.template.id
if item_id in needed:
return [
Rejection(
code="party.remove.carries_quest_item",
params={"character": command.character_id, "item": item_id},
)
], []
member_id = _member_id(member)
events: list[Event] = []
if recipient is not None:
Expand All @@ -2334,6 +2349,48 @@ def _handle_remove_member(session: GameSession, command: RemoveMember) -> tuple[
return [], events


def _quest_item_ids(session: GameSession) -> set[str]:
"""Return the catalog id of every item the adventure still depends on.

An item counts when a `HasItemCondition` or an `ItemAcquiredPattern` names it in a door or
transition gate, in a trigger that can still fire, or in any clause of a quest that isn't
completed. A trigger that has fired and isn't repeatable can't matter again, and neither can
a completed quest. A gate always counts, because the party can come back to it.
"""
adventure = session.adventure
clauses: list[tuple[TriggerPattern, tuple[ConditionSpec, ...]]] = []
conditions: list[ConditionSpec] = []
for dungeon in adventure.dungeons:
for level in dungeon.levels:
conditions.extend(
edge.door.requires.condition
for edge in level.edges.values()
if edge.door is not None and edge.door.requires is not None
)
conditions.extend(
transition.requires.condition for transition in level.transitions if transition.requires is not None
)
for trigger in adventure.triggers:
if trigger.repeatable or trigger.id not in session.fired_triggers:
clauses.append((trigger.when, trigger.conditions))
for quest in adventure.quests:
if session.quests[quest.id].status == "completed":
continue
if quest.activation is not None:
clauses.append((quest.activation.pattern, quest.activation.conditions))
for objective in quest.objectives:
clauses.append((objective.when.pattern, objective.when.conditions))
if objective.reveal_when is not None:
clauses.append((objective.reveal_when.pattern, objective.reveal_when.conditions))
item_ids: set[str] = set()
for pattern, clause_conditions in clauses:
if isinstance(pattern, ItemAcquiredPattern):
item_ids.add(pattern.item_id)
conditions.extend(clause_conditions)
item_ids.update(condition.item_id for condition in conditions if isinstance(condition, HasItemCondition))
return item_ids


def _hand_over_everything(session: GameSession, giver: Character, recipient: Character) -> list[Event]:
"""Move everything a leaving member carries to `recipient`, as a `GiveItems` of all of it would.

Expand Down
Loading
Loading