From 242350760b3b3c0e67756905e42dc6cfb40ff59c Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 13:50:35 -0700 Subject: [PATCH 1/5] Pin the default weapon and the device and turning re-check Contract stubs and acceptance tests for two owner decisions. The engine's behavior doesn't change yet. An attack with no weapon_id now means the wielded weapon that suits the attack (#162, item 18). The group's distance when the round is accepted decides the attack: melee at 5 feet or less, missile beyond. The session takes the first wielded item, in equip order, with that quality. It prefers a missile weapon whose long range reaches the group, skips splash weapons and items with no weapon stats, and falls back to bare hands. The choice holds for the whole round, so the round plays out as if the chosen weapon had been named. A device use and a turning are judged again in the magic phase, as a cast and a scroll read are (#145). One that now fails fizzles with a new event, DeclarationFizzledEvent. Its codes are magic.device.fizzled and magic.turning.fizzled, and it carries actor_id, item_id (the declared instance id, or None for a turning), and reason (the re-check's first rejection code). SpellDisruptedEvent stays for spells and scrolls, so its spell_id always names a spell. A fizzled device keeps its charge and costs what a hold costs. The new event is registered in KERNEL_EVENT_CLASSES and KernelEvent, with a message template for each code and a registry sample in test_events_kernel.py. These docstrings state the contract: BattleDeclaration.weapon_id, _party_magic, and the two validators. The acceptance tests in tests/test_weapon_default_and_device_recheck.py are marked xfail for chunk weapon-and-device. The file's unmarked classes pin behavior that stays the same. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- src/osrlib/core/events.py | 50 ++ src/osrlib/crawl/battle.py | 35 +- src/osrlib/crawl/commands.py | 32 +- src/osrlib/messages.py | 4 + tests/test_events_kernel.py | 3 + .../test_weapon_default_and_device_recheck.py | 759 ++++++++++++++++++ 6 files changed, 866 insertions(+), 17 deletions(-) create mode 100644 tests/test_weapon_default_and_device_recheck.py diff --git a/src/osrlib/core/events.py b/src/osrlib/core/events.py index 5a41a60..d439e02 100644 --- a/src/osrlib/core/events.py +++ b/src/osrlib/core/events.py @@ -75,6 +75,7 @@ "DamageAbsorbedEvent", "DamageDealtEvent", "DeathEvent", + "DeclarationFizzledEvent", "EffectAttachedEvent", "EffectExpiredEvent", "EffectReleasedEvent", @@ -1115,6 +1116,53 @@ class SpellDisruptedEvent(Event): """ +class DeclarationFizzledEvent(Event): + """A declared wand, staff, or rod use, or a declared turning, came to nothing, and cost nothing. + + A session reports it during a battle round. In the round's magic phase, the session checks each declared + device use and turning again, just before it would resolve. If a check it passed at the start of the round now + fails, because something earlier in the round changed, the declaration fizzles. One example is a staff of + striking aimed at a group that runs out of melee reach before the magic phase. Another is a wand aimed at a + group that leaves the battle first. `reason` has the rejection code of the check that failed. + + Nothing happened, and nothing was spent. The device was never used, so it keeps its charge and isn't + identified, and a turning has nothing to spend. Tell the player the action failed, and that the device still + has its charge. A cast or a scroll read that fizzles is reported by + [`SpellDisruptedEvent`][osrlib.core.events.SpellDisruptedEvent] instead, because a spell is lost. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.device.fizzled", "magic.turning.fizzled"}) + """The two codes: `magic.device.fizzled` for a wand, staff, or rod use, and `magic.turning.fizzled` for a + turning.""" + + event_type: Literal["declaration_fizzled"] = "declaration_fizzled" + """The fixed string that identifies this event class in a serialized event.""" + + code: str + """Which declaration fizzled: `magic.device.fizzled` or `magic.turning.fizzled`.""" + + visibility: Visibility = Visibility.PLAYER + """Player visibility: everyone sees the action fail.""" + + actor_id: str + """The entity id of the member who declared the device use or the turning.""" + + item_id: str | None = None + """The device's per-instance id, the `item_id` the declaration named, on a `magic.device.fizzled` event. It's + the id the player already used, so it reveals nothing about a device that isn't identified. `None` on a + `magic.turning.fizzled` event.""" + + reason: str + """Why the declaration fizzled: the code of the first check that failed when the session checked it again. + + Any check the declaration passed at the start of the round can fail, so read the code and don't assume a + cause. A staff of striking whose group ran out of reach gives `combat.attack.out_of_reach`, and a wand aimed at + a group that left the battle gives `battle.declaration.unknown_group`. Match on it as you match on the code of a + [`Rejection`][osrlib.core.validation.Rejection]. [The rejection code reference][rejection-codes] explains each + code. + """ + + class SpellForgottenEvent(Event): """A caster forgot a memorized spell, because the caster no longer has room for it. @@ -1300,6 +1348,7 @@ class MagicDispelledEvent(Event): SpellsMemorizedEvent, SpellCastEvent, SpellDisruptedEvent, + DeclarationFizzledEvent, SpellForgottenEvent, SpellBookUpdatedEvent, UndeadTurnedEvent, @@ -1347,6 +1396,7 @@ class rather than one event: to build a table of handlers, to generate a JSON Sc | SpellsMemorizedEvent | SpellCastEvent | SpellDisruptedEvent + | DeclarationFizzledEvent | SpellForgottenEvent | SpellBookUpdatedEvent | UndeadTurnedEvent diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 7508120..e3b28d5 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -1450,9 +1450,13 @@ def _validate_declaration(session, declaration: BattleDeclaration, member) -> li """Judge one declaration against the state it refers to, and return every reason it cannot stand. This runs twice on the declarations the round accepts. Once in the validation pre-phase, where a - rejection refuses the whole command, and again in the magic phase, immediately before a `cast` - resolves, because the phases between the two can change what these checks read. It reads state and - takes no draw, so running it a second time costs nothing and changes nothing. + rejection refuses the whole command, and again in the magic phase, immediately before a `cast`, a + `turn_undead`, or a device or scroll `use_item` resolves, because the phases between the two can change + what these checks read. It reads state and takes no draw, so running it a second time costs nothing and + changes nothing. + + An `attack` with no `weapon_id` is judged with the weapon that `BattleDeclaration.weapon_id` says the + session chooses, from the group's distance when the round is accepted. """ state = session.battle if declaration.action == "hold": @@ -1646,10 +1650,10 @@ def _validate_magic_item_declaration(session, declaration: BattleDeclaration, me A scroll read resolves in the magic phase too, through the declaration's spell fields. - For a scroll this runs twice, as `_validate_declaration`'s `cast` branch does: once in the - validation pre-phase, and again in the magic phase immediately before the read resolves, because the - phases between the two can change what these checks read. It reads state and takes no draw, so the - second run costs nothing and changes nothing. + For a scroll or a device this runs twice, as `_validate_declaration`'s `cast` branch does: once in the + validation pre-phase, and again in the magic phase immediately before the read or the use resolves, + because the phases between the two can change what these checks read. It reads state and takes no draw, + so the second run costs nothing and changes nothing. """ from osrlib.crawl import exploration @@ -2404,9 +2408,20 @@ def _party_magic(session, by_member, pending_casters, disrupted, acted, state) - `magic.cast.disrupted` rather than `magic.cast.fizzled`. The disruption comes from the attack that hit, so it's the outcome the players saw. - A cast, and a scroll read inside `_resolve_scroll_cast`, is judged again immediately before it resolves, - with the checks it passed at the top of the round, and one that now fails any of them fizzles instead of - reaching the kernel. A device use and a turning are not judged again. + A cast, a scroll read inside `_resolve_scroll_cast`, a device use (a wand, staff, or rod), and a turning + are each judged again immediately before they resolve, with the checks they passed at the top of the + round. One that now fails any of them fizzles instead of resolving, and the re-check takes no draw, so a + declaration that passes it resolves with the same draws in the same order as before. A potion has no + check to fail. + + A fizzled cast or scroll read reports a `SpellDisruptedEvent` at `magic.cast.fizzled`, and the caster loses + the memorized copy, or the scroll its spell, as a disruption loses them. A fizzled device use reports a + `DeclarationFizzledEvent` at `magic.device.fizzled` with the declared instance id as `item_id`, and a fizzled + turning reports one at `magic.turning.fizzled` with no `item_id`. Each event's `reason` is the first rejection + code of the re-check. A device was never used, so it keeps its charge, isn't identified, emits no + `items.device.activated`, and breaks no invisibility. A turning that fizzles costs nothing and draws + nothing. A fizzled device use or turning still releases the actor's concentration, as every declaration + other than a cast or a hold does. """ events: list[Event] = [] for member, declaration in by_member.values(): diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index ecdfb3a..76a4888 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2407,8 +2407,8 @@ class BattleDeclaration(BaseModel): `action` determines which other fields apply. Leave the others at their defaults. - - `attack` takes `target_group_id` and, optionally, `weapon_id`. `None` means bare - hands. + - `attack` takes `target_group_id` and, optionally, `weapon_id`. `None` means the + wielded weapon that suits the attack, or bare hands when the member wields none. - `cast` takes `spell_id`, `spell_mode`, `reversed`, and `targets`. - `move` takes `move`, and `target_group_id` for a `close`. It changes the distance between the party and the monsters. @@ -2447,11 +2447,29 @@ class BattleDeclaration(BaseModel): [`EncounterGroupView.fled`][osrlib.crawl.views.EncounterGroupView.fled] is true, with `battle.declaration.unknown_group`.""" weapon_id: str | None = None - """Which wielded weapon to attack with: a mundane weapon's catalog id, a magic weapon's - per-instance id, or `None` to strike unarmed. The weapon has to be equipped already, so - equip it with [`EquipItem`][osrlib.crawl.commands.EquipItem] before the fight. The session - refuses a weapon that's only carried with `battle.declaration.weapon_not_wielded`. Whether the - attack is melee or missile depends on the weapon's qualities and the group's distance.""" + """Which wielded weapon to attack with: a mundane weapon's catalog id or a magic weapon's + per-instance id. The weapon has to be equipped already, so equip it with + [`EquipItem`][osrlib.crawl.commands.EquipItem] before the fight. The session refuses a weapon + that's only carried with `battle.declaration.weapon_not_wielded`. Whether the attack is melee + or missile depends on the weapon's qualities and the group's distance. + + Leave it `None` to attack with the wielded weapon that suits the attack. The group's distance + when the round is accepted decides which attack that is: a melee attack at 5' or less, and a + missile attack beyond 5'. The session goes through the member's wielded items in the order they + were equipped and takes the first one with the `melee` quality for a melee attack, or the + `missile` quality for a missile attack. For a missile attack, it takes the first one whose long + range reaches the group. Only when none reaches does it take the first missile weapon, and the + session then refuses the attack with `combat.attack.out_of_range`. A torch counts, because the + weapon table lists it as a melee weapon. A splash weapon, like a flask of oil or a vial of holy + water, never counts, because a `use_item` declaration throws it. A wand, or another magic item + with no base weapon, has no weapon stats, so it never counts either. A member with no weapon that + suits the attack strikes unarmed. + + The session makes this choice once, when it accepts the round. The attack then resolves as if + you had named that weapon, even when the distance changes before the attack resolves. The + command log keeps the declaration as you sent it, with `None`. The attack's + [`AttackRolledEvent`][osrlib.core.events.AttackRolledEvent] names the weapon used in + `attack_name`.""" spell_id: str | None = None """Which spell a `cast` declaration casts, from [`load_spells`][osrlib.data.load_spells] (see the diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index 24074b7..296ded3 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -219,6 +219,10 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: f"{event.caster_id}'s casting of {event.spell_id} is disrupted — the spell is lost." ), "magic.cast.fizzled": lambda event: f"{event.caster_id}'s casting of {event.spell_id} fizzles — the spell is lost.", + "magic.device.fizzled": lambda event: ( + f"{event.actor_id}'s use of {event.item_id} fizzles. The device keeps its charge." + ), + "magic.turning.fizzled": lambda event: f"{event.actor_id}'s attempt to turn undead fizzles.", "magic.memory.forgotten": lambda event: f"{event.caster_id} forgets {event.spell_id}.", "magic.book.added": lambda event: f"{event.caster_id} adds {event.spell_id} to their spell book.", "magic.turning.turned": lambda event: _turning(event, "the undead are turned"), diff --git a/tests/test_events_kernel.py b/tests/test_events_kernel.py index db84eeb..217a11b 100644 --- a/tests/test_events_kernel.py +++ b/tests/test_events_kernel.py @@ -70,6 +70,9 @@ def sample_event(event_class, code): ), "SpellCastEvent": dict(caster_id="pc-1", spell_id="fire_ball", mode="damage", target_ids=("monster-0001",)), "SpellDisruptedEvent": dict(caster_id="pc-1", spell_id="fire_ball"), + "DeclarationFizzledEvent": dict( + actor_id="pc-1", item_id="magic-item-0001", reason="combat.attack.out_of_reach" + ), "SpellForgottenEvent": dict(caster_id="pc-1", spell_id="sleep"), "SpellBookUpdatedEvent": dict(caster_id="pc-1", spell_id="web"), "UndeadTurnedEvent": dict( diff --git a/tests/test_weapon_default_and_device_recheck.py b/tests/test_weapon_default_and_device_recheck.py new file mode 100644 index 0000000..78ddf86 --- /dev/null +++ b/tests/test_weapon_default_and_device_recheck.py @@ -0,0 +1,759 @@ +"""An attack with no weapon named, and a device use or turning judged again in the magic phase. + +Two owner decisions, pinned here before the code (issue #162, item 18, and issue #145). + +**The default weapon.** `BattleDeclaration.weapon_id=None` used to mean bare hands, so a member holding a sword +attacked barehanded whenever a front end left the field out. Now `None` means the wielded weapon that suits +the attack. The SRD decides which attack a declaration makes from the distance alone. Its weapon qualities +read "**Melee:** Close quarters weapon (5' or less)" and "**Missile:** Thrown or fired weapon (greater than +5' distance)", and its combat page says missile attacks "Are possible when opponents are more than 5' from +one another" and melee attacks "Are possible when opponents are 5' or less from each other". The session +reads the group's distance when it accepts the round, then takes the first wielded item, in the order the +member equipped them, with the `melee` quality for a melee attack or the `missile` quality for a missile +attack. Among missile weapons it prefers the first whose long range reaches the group. The SRD's weapon table +lists the torch with the `Melee` quality, so a torch counts. Holy water and burning oil are "Splash weapon" +entries that a `use_item` declaration throws, so they never count, and a wand has no weapon stats at all. A +member with nothing that suits the attack strikes unarmed, as before. The choice is made once, when the round +is accepted, so a round with `weapon_id=None` is exactly the round that names the chosen weapon. Most tests +here assert that equivalence: the same seed and setup, once with `None` and once with the chosen id, must give +the same events, rejections, stream states, and party. + +**The device and turning re-check.** Since #139 the magic phase judges a `cast` and a scroll-read `use_item` +again just before they resolve, and one that now fails fizzles with `magic.cast.fizzled`. A device `use_item` +and a `turn_undead` now get the same re-check. One that fails it fizzles with a new event, +`DeclarationFizzledEvent`, at `magic.device.fizzled` or `magic.turning.fizzled`, with the re-check's first +rejection code as `reason`. `SpellDisruptedEvent` stays for spells and scrolls only, so its `spell_id` always +names a spell. A fizzled device use costs what a `hold` costs: the device keeps its charge, isn't identified, +emits no `items.device.activated`, and breaks no invisibility, because it was never used. Its event's +`item_id` is the device's instance id, which reveals nothing about an unidentified device. A fizzled turning +has no `item_id`, draws nothing, and costs nothing. + +Tests of new behavior are marked expected-to-fail for the chunk. The unmarked classes pin behavior that has to +stay as it is, the explicit weapon, bare hands, and a device the re-check passes, and they pass today. +""" + +from collections.abc import Callable +from typing import Any + +import pytest + +from crawl_fixtures import build_adventure, build_party +from osrlib.core.character import party_to_document +from osrlib.core.combat import COMBAT_STREAM, incapacitated +from osrlib.core.effects import ActiveCondition, Condition, EffectDefinition, has_condition +from osrlib.core.events import ( + AttackRolledEvent, + DeclarationFizzledEvent, + Event, + SpellDisruptedEvent, + UndeadTurnedEvent, + Visibility, +) +from osrlib.core.items import MagicItemInstance +from osrlib.core.ruleset import Ruleset +from osrlib.core.spells import MAGIC_STREAM +from osrlib.core.tables import ReactionResult +from osrlib.crawl import battle as battle_module +from osrlib.crawl import encounter as encounter_module +from osrlib.crawl.battle import MELEE_RANGE_FEET +from osrlib.crawl.commands import ( + BattleDeclaration, + CommandResult, + EngageBattle, + EnterDungeon, + EquipItem, + GrantItem, + LightSource, + ResolveBattleRound, + SessionMode, + SpawnMonsters, +) +from osrlib.crawl.session import GameSession +from osrlib.data import load_equipment, load_magic_items +from osrlib.messages import format_message +from osrlib.persistence import replay_game + +CHUNK = pytest.mark.xfail(reason="chunk: weapon-and-device", strict=True) + +FIGHTER = "character-0001" +THIEF = "character-0002" +CLERIC = "character-0003" +MAGIC_USER = "character-0004" + +WEAPON_SEED = 4 +"""A seed under which the fighter lives to swing in every weapon case, the slow two-handed sword included.""" + +SCAN_SEEDS = range(16) +"""Seeds to scan for both initiative outcomes. Each scanning test asserts it found both.""" + + +# ---------------------------------------------------------------------- helpers + + +def _held_id(held) -> str: + """The id a declaration names a wielded item by: a magic item's instance id, a mundane item's catalog id.""" + return held.instance_id if isinstance(held, MagicItemInstance) else held.template.id + + +def _light_and_enter(session: GameSession) -> None: + session.execute(GrantItem(character_id=FIGHTER, item_id="torch", quantity=6)) + session.execute(GrantItem(character_id=FIGHTER, item_id="tinder_box")) + session.execute(EnterDungeon(dungeon_id="delve")) + for _ in range(20): + lit = session.execute(LightSource(character_id=FIGHTER, item_id="torch")) + if any(event.code == "exploration.light.lit" for event in lit.events): + return + pytest.fail("the torch never lit") + + +def armed_battle( + wielded: tuple[str, ...], *, distance: int, seed: int = WEAPON_SEED, carried: tuple[str, ...] = () +) -> GameSession: + """A goblin battle built from commands alone, so `replay_game` can rebuild it from the command log. + + The fighter equips `wielded` in that order and carries `carried` unequipped. Two goblins appear at + `distance` and the battle is engaged. + """ + session = GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=seed) + for item_id in wielded: + assert session.execute(GrantItem(character_id=FIGHTER, item_id=item_id)).accepted + equipped = session.execute(EquipItem(character_id=FIGHTER, item_id=item_id)) + assert equipped.accepted, [rejection.code for rejection in equipped.rejections] + for item_id in carried: + assert session.execute(GrantItem(character_id=FIGHTER, item_id=item_id)).accepted + _light_and_enter(session) + session.execute(SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=distance)) + if session.mode is SessionMode.ENCOUNTER: + session.execute(EngageBattle()) + assert session.mode is SessionMode.BATTLE + assert session.encounter.groups[0].distance_feet == distance + assert [_held_id(held) for held in session.member(FIGHTER).inventory.wielded] == list(wielded) + return session + + +def declarations(session: GameSession, *extra: BattleDeclaration) -> tuple[BattleDeclaration, ...]: + """The round's declarations in marching order: each member's from `extra`, and a `hold` for the rest. + + The order is fixed whatever `extra` holds, because the initiative event lists the party in declaration + order, and a test that compares two rounds needs the same order in both. + """ + chosen = {declaration.character_id: declaration for declaration in extra} + return tuple( + chosen.get(member.id, BattleDeclaration(character_id=member.id, action="hold")) + for member in session.party.living_members() + if not incapacitated(member) + ) + + +def attack(session: GameSession, weapon_id: str | None, member_id: str = FIGHTER) -> BattleDeclaration: + return BattleDeclaration( + character_id=member_id, action="attack", target_group_id=session.encounter.groups[0].id, weapon_id=weapon_id + ) + + +def attack_names(events, member_id: str = FIGHTER) -> list[str]: + return [ + event.attack_name for event in events if isinstance(event, AttackRolledEvent) and event.attacker_id == member_id + ] + + +def outcome(session: GameSession, result: CommandResult, *, drop: Callable[[Event], bool] = lambda _event: False): + """Everything a round produced or changed that a player or a replay could tell apart. + + `drop` removes events from the comparison, for a test that expects exactly those events to differ. + """ + return { + "accepted": result.accepted, + "rejections": [rejection.model_dump(mode="json") for rejection in result.rejections], + "events": [event.model_dump(mode="json") for event in result.events if not drop(event)], + "streams": {key: state.model_dump(mode="json") for key, state in session.streams.export_states().items()}, + "round": session.battle.round if session.battle is not None else None, + "party": [member.model_dump(mode="json") for member in session.party.members], + "ledger": session.ledger.model_dump(mode="json"), + } + + +def stream_states(session: GameSession): + return {key: state.model_dump(mode="json") for key, state in session.streams.export_states().items()} + + +def fizzles(events, actor_id: str, code: str) -> list[DeclarationFizzledEvent]: + """The actor's `DeclarationFizzledEvent`s at `code`. A device or turning fizzle is never a spell event.""" + assert not any(isinstance(event, SpellDisruptedEvent) and event.caster_id == actor_id for event in events), ( + "a device use or turning reported as a lost spell" + ) + return [ + event + for event in events + if isinstance(event, DeclarationFizzledEvent) and event.code == code and event.actor_id == actor_id + ] + + +def monsters_first(result: CommandResult, group_id: str) -> bool: + """Whether the monster side acted before the party this round, read off the initiative event.""" + initiative = next(event for event in result.events if event.code == "combat.initiative.rolled") + return initiative.order[0] == group_id + + +def is_fizzle(event: Event) -> bool: + return isinstance(event, DeclarationFizzledEvent) + + +# ---------------------------------------------------------------------- the default weapon + + +EQUIVALENCE_CASES = [ + pytest.param(5, ("sword",), "sword", "Sword", id="a sword at melee range"), + pytest.param(5, ("two_handed_sword",), "two_handed_sword", "Two-handed sword", id="a slow two-handed sword"), + pytest.param(40, ("crossbow",), "crossbow", "Crossbow", id="a crossbow beyond 5 feet"), + pytest.param(5, ("short_bow", "sword"), "sword", "Sword", id="the sword after a bow at melee range"), + pytest.param(40, ("sword", "short_bow"), "short_bow", "Short bow", id="the bow after a sword beyond 5 feet"), + pytest.param(5, ("mace", "sword"), "mace", "Mace", id="the first of two melee weapons"), + pytest.param(40, ("spear", "long_bow"), "spear", "Spear", id="the first missile weapon that reaches"), + pytest.param(100, ("spear", "long_bow"), "long_bow", "Long bow", id="a later missile weapon that reaches"), + pytest.param(5, ("torch",), "torch", "improvised", id="a torch is a melee weapon"), + pytest.param(40, ("oil_flask", "short_bow"), "short_bow", "Short bow", id="a splash weapon is passed over"), + pytest.param(200, ("short_bow",), "short_bow", None, id="a bow that cannot reach is still the bow"), +] + + +@CHUNK +class TestAnAttackWithNoWeaponUsesTheWieldedWeapon: + """`weapon_id=None` attacks with the wielded weapon that suits the attack, chosen when the round is + accepted, and the round is the one that names that weapon.""" + + def test_a_member_holding_a_sword_swings_it(self): + session = armed_battle(("sword",), distance=5) + result = session.execute(ResolveBattleRound(declarations=declarations(session, attack(session, None)))) + assert result.accepted, [rejection.code for rejection in result.rejections] + names = attack_names(result.events) + assert names, "the fighter attacked" + assert set(names) == {load_equipment().get("sword").name} + + @pytest.mark.parametrize(("distance", "wielded", "chosen", "attack_name"), EQUIVALENCE_CASES) + def test_leaving_the_weapon_out_is_the_round_that_names_the_suitable_weapon( + self, distance, wielded, chosen, attack_name + ): + named = armed_battle(wielded, distance=distance) + named_result = named.execute(ResolveBattleRound(declarations=declarations(named, attack(named, chosen)))) + if attack_name is None: + assert not named_result.accepted + assert [rejection.code for rejection in named_result.rejections] == ["combat.attack.out_of_range"] + else: + assert named_result.accepted, [rejection.code for rejection in named_result.rejections] + names = attack_names(named_result.events) + assert names and set(names) == {attack_name}, names + + default = armed_battle(wielded, distance=distance) + default_result = default.execute(ResolveBattleRound(declarations=declarations(default, attack(default, None)))) + assert outcome(default, default_result) == outcome(named, named_result) + + def test_the_weapon_is_chosen_when_the_round_is_accepted(self): + """At 20 feet the bow suits the attack. The thief's `close` brings the party to 5 feet before the missile + phase, where the sword would suit, but the bow chosen at acceptance is the one that fires.""" + + def play(weapon_id): + session = armed_battle(("short_bow", "sword"), distance=20) + close = BattleDeclaration( + character_id=THIEF, action="move", move="close", target_group_id=session.encounter.groups[0].id + ) + result = session.execute( + ResolveBattleRound(declarations=declarations(session, attack(session, weapon_id), close)) + ) + return session, result + + named, named_result = play("short_bow") + assert named_result.accepted, [rejection.code for rejection in named_result.rejections] + moves = [event for event in named_result.events if event.code == "battle.group.moved"] + first_shot = next( + index + for index, event in enumerate(named_result.events) + if isinstance(event, AttackRolledEvent) and event.attacker_id == FIGHTER + ) + assert moves and named_result.events.index(moves[0]) < first_shot + assert moves[0].distance_feet == MELEE_RANGE_FEET, "the party closed to melee range before the shot" + assert set(attack_names(named_result.events)) == {"Short bow"} + + default, default_result = play(None) + assert outcome(default, default_result) == outcome(named, named_result) + + def test_a_wand_held_before_the_sword_is_passed_over(self): + def play(weapon_id): + session = armed_battle(("sword",), distance=5) + wand = MagicItemInstance( + instance_id="magic-item-9001", template_id="wand_of_cold", identified=True, charges_remaining=5 + ) + session.member(FIGHTER).inventory.wielded.insert(0, wand) + result = session.execute(ResolveBattleRound(declarations=declarations(session, attack(session, weapon_id)))) + return session, result + + named, named_result = play("sword") + assert named_result.accepted + assert set(attack_names(named_result.events)) == {"Sword"} + default, default_result = play(None) + assert outcome(default, default_result) == outcome(named, named_result) + + def test_a_magic_weapon_is_chosen_by_its_instance(self): + def play(weapon_id): + session = armed_battle((), distance=5) + blade = MagicItemInstance(instance_id="magic-item-9002", template_id="sword_plus_1", base_item_id="sword") + session.member(FIGHTER).inventory.wielded.append(blade) + result = session.execute(ResolveBattleRound(declarations=declarations(session, attack(session, weapon_id)))) + return session, result + + named, named_result = play("magic-item-9002") + assert named_result.accepted + names = attack_names(named_result.events) + assert names and set(names) == {load_magic_items().get("sword_plus_1").name} + default, default_result = play(None) + assert outcome(default, default_result) == outcome(named, named_result) + + def test_a_refused_default_costs_nothing(self): + session = armed_battle(("short_bow",), distance=200) + streams = stream_states(session) + party = [member.model_dump(mode="json") for member in session.party.members] + logged = len(session.command_log) + round_before = session.battle.round + result = session.execute(ResolveBattleRound(declarations=declarations(session, attack(session, None)))) + assert not result.accepted + assert [rejection.code for rejection in result.rejections] == ["combat.attack.out_of_range"] + assert result.events == () + assert stream_states(session) == streams + assert [member.model_dump(mode="json") for member in session.party.members] == party + assert session.battle.round == round_before + assert len(session.command_log) == logged + + def test_a_default_battle_replays_to_the_same_game(self): + session = armed_battle(("sword",), distance=5) + swung = [] + for _ in range(3): + if session.mode is not SessionMode.BATTLE or incapacitated(session.member(FIGHTER)): + break + result = session.execute(ResolveBattleRound(declarations=declarations(session, attack(session, None)))) + assert result.accepted, [rejection.code for rejection in result.rejections] + swung.extend(attack_names(result.events)) + assert swung and set(swung) == {"Sword"} + + logged = [command for command in session.command_log if isinstance(command, ResolveBattleRound)] + assert logged + fighter_declarations = [ + declaration + for command in logged + for declaration in command.declarations + if declaration.character_id == FIGHTER + ] + assert fighter_declarations and all(declaration.weapon_id is None for declaration in fighter_declarations) + + replayed = replay_game( + WEAPON_SEED, party_to_document(build_party().members), session.adventure, Ruleset(), session.command_log + ) + assert [event.model_dump(mode="json") for event in replayed.event_log] == [ + event.model_dump(mode="json") for event in session.event_log + ] + assert stream_states(replayed) == stream_states(session) + + +class TestExplicitAndUnarmedAttacksStayAsTheyAre: + """An explicit `weapon_id` behaves as it always has, and a member with nothing that suits the attack strikes + unarmed. These pass before and after the chunk.""" + + def test_an_explicit_weapon_is_never_swapped_for_a_better_one(self): + session = armed_battle(("sword", "short_bow"), distance=40) + result = session.execute(ResolveBattleRound(declarations=declarations(session, attack(session, "sword")))) + assert not result.accepted + assert [rejection.code for rejection in result.rejections] == ["combat.attack.out_of_reach"] + + def test_an_explicit_weapon_that_is_only_carried_is_refused(self): + session = armed_battle(("sword",), distance=5, carried=("short_bow",)) + result = session.execute(ResolveBattleRound(declarations=declarations(session, attack(session, "short_bow")))) + assert not result.accepted + assert [rejection.code for rejection in result.rejections] == ["battle.declaration.weapon_not_wielded"] + assert result.rejections[0].params == {"item": "short_bow"} + + def test_a_member_wielding_nothing_strikes_unarmed(self): + session = armed_battle((), distance=5, carried=("sword",)) + result = session.execute(ResolveBattleRound(declarations=declarations(session, attack(session, None)))) + assert result.accepted, [rejection.code for rejection in result.rejections] + names = attack_names(result.events) + assert names and set(names) == {"unarmed"} + + def test_a_member_wielding_only_a_wand_strikes_unarmed(self): + session = armed_battle((), distance=5) + session.member(FIGHTER).inventory.wielded.append( + MagicItemInstance( + instance_id="magic-item-9001", template_id="wand_of_cold", identified=True, charges_remaining=5 + ) + ) + result = session.execute(ResolveBattleRound(declarations=declarations(session, attack(session, None)))) + assert result.accepted, [rejection.code for rejection in result.rejections] + names = attack_names(result.events) + assert names and set(names) == {"unarmed"} + + def test_a_member_wielding_only_a_splash_weapon_is_refused_beyond_reach(self): + session = armed_battle(("oil_flask",), distance=40) + streams = stream_states(session) + result = session.execute(ResolveBattleRound(declarations=declarations(session, attack(session, None)))) + assert not result.accepted + assert [rejection.code for rejection in result.rejections] == ["combat.attack.out_of_reach"] + assert stream_states(session) == streams + + +# ---------------------------------------------------------------------- the device and turning re-check + + +def staff_battle(seed: int, *, identified: bool = False, invisible: bool = False): + """Goblins at melee range that are already fleeing, and a cleric with a staff of striking aimed at them. + + The declaration passes at the top of the round. When the monsters win initiative, the group runs 60 feet + before the party's magic phase, out of the staff's melee reach. When the party wins, the staff resolves at + 5 feet. The group is still inside 120 feet either way, so it hasn't fled and the battle goes on. + """ + session = GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=seed) + _light_and_enter(session) + instances = session.spawn("goblin", 2) + encounter_module.start_encounter( + session, + groups=[("goblin", instances)], + kind="spawned", + distance_feet=MELEE_RANGE_FEET, + pinned_stance=ReactionResult.HOSTILE, + party_aware=True, + ) + session.execute(EngageBattle()) + assert session.mode is SessionMode.BATTLE + group = session.encounter.groups[0] + group.fleeing = True + staff = MagicItemInstance( + instance_id="magic-item-9003", template_id="staff_of_striking", identified=identified, charges_remaining=5 + ) + session.member(CLERIC).inventory.items.append(staff) + if invisible: + session.ledger.attach( + EffectDefinition(kind="invisibility", condition=Condition.INVISIBLE), + CLERIC, + clock=session.clock, + allocator=session.allocator, + registry=session.registry(), + ) + return session, group, staff + + +def use_staff(session, group, staff) -> BattleDeclaration: + return BattleDeclaration( + character_id=CLERIC, action="use_item", item_id=staff.instance_id, target_group_id=group.id + ) + + +def wand_battle(seed: int): + """Two goblin groups at 40 feet. The first is moved to 100 feet and is already fleeing, and the magic-user + aims a wand of cold at it. + + When the monsters win initiative, the first group runs past 120 feet and leaves the battle before the magic + phase. The second group keeps the battle going. When the party wins, the wand goes off at 100 feet. + """ + session = GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=seed) + _light_and_enter(session) + first = session.spawn("goblin", 2) + second = session.spawn("goblin", 2) + encounter_module.start_encounter( + session, + groups=[("goblin", first), ("goblin", second)], + kind="spawned", + distance_feet=40, + pinned_stance=ReactionResult.HOSTILE, + party_aware=True, + ) + session.execute(EngageBattle()) + assert session.mode is SessionMode.BATTLE + fleeing = session.encounter.groups[0] + fleeing.distance_feet = 100 + fleeing.fleeing = True + wand = MagicItemInstance(instance_id="magic-item-9001", template_id="wand_of_cold", charges_remaining=5) + session.member(MAGIC_USER).inventory.items.append(wand) + declaration = BattleDeclaration( + character_id=MAGIC_USER, action="use_item", item_id=wand.instance_id, target_group_id=fleeing.id + ) + return session, fleeing, wand, declaration + + +def first_seed_where_monsters_act_first(play: Callable[[int], tuple[CommandResult, Any]]) -> int: + """The first seed in `SCAN_SEEDS` whose round, as `play` runs it, gave the monster side initiative. + + `play` takes a seed and returns the round's result and the group whose initiative to read. + """ + for seed in SCAN_SEEDS: + result, group = play(seed) + if monsters_first(result, group.id): + return seed + pytest.fail("no seed let the monsters act first") + + +def activations(events, instance_id: str) -> list[Event]: + return [ + event + for event in events + if event.code == "items.device.activated" and getattr(event, "instance_id", None) == instance_id + ] + + +def assert_device_fizzle( + fizzle: DeclarationFizzledEvent, *, actor_id: str, instance_id: str, template_id: str, reason: str +): + assert fizzle.event_type == "declaration_fizzled" + assert fizzle.code == "magic.device.fizzled" + assert fizzle.actor_id == actor_id + assert fizzle.item_id == instance_id + assert fizzle.reason == reason + assert fizzle.visibility is Visibility.PLAYER + assert template_id not in fizzle.model_dump_json(), "the fizzle names an unidentified device" + message = format_message(fizzle) + assert message and message != fizzle.code + assert instance_id in message + assert "lost" not in message.lower(), "a device that fizzles loses nothing" + + +@CHUNK +class TestADeviceUseIsJudgedAgainInTheMagicPhase: + """A wand, staff, or rod declaration is judged again just before it resolves. One that now fails fizzles + with `magic.device.fizzled`, keeps its charge, and costs what a `hold` costs.""" + + def test_a_staff_of_striking_whose_group_ran_out_of_reach_fizzles(self): + fizzled_seeds, resolved_seeds = [], [] + for seed in SCAN_SEEDS: + session, group, staff = staff_battle(seed) + result = session.execute( + ResolveBattleRound(declarations=declarations(session, use_staff(session, group, staff))) + ) + assert result.accepted, [rejection.code for rejection in result.rejections] + if monsters_first(result, group.id): + assert group.distance_feet > MELEE_RANGE_FEET and not group.fled, "the group ran but stayed" + found = fizzles(result.events, CLERIC, "magic.device.fizzled") + assert len(found) == 1, [event.code for event in result.events] + assert_device_fizzle( + found[0], + actor_id=CLERIC, + instance_id=staff.instance_id, + template_id="staff_of_striking", + reason="combat.attack.out_of_reach", + ) + assert not activations(result.events, staff.instance_id) + assert not attack_names(result.events, CLERIC) + assert staff.charges_remaining == 5 + assert staff.identified is False + fizzled_seeds.append(seed) + else: + assert not fizzles(result.events, CLERIC, "magic.device.fizzled") + assert activations(result.events, staff.instance_id) + assert attack_names(result.events, CLERIC) + assert staff.charges_remaining == 4 + resolved_seeds.append(seed) + assert fizzled_seeds, "no seed let the monsters act first" + assert resolved_seeds, "no seed let the party act first" + + def test_a_fizzled_device_use_costs_what_a_hold_costs(self): + seed = first_seed_where_monsters_act_first(lambda seed: self._staff_round(seed, use=True)[1:3]) + used_session, used_result, _, staff = self._staff_round(seed, use=True) + held_session, held_result, _, held_staff = self._staff_round(seed, use=False) + assert len(fizzles(used_result.events, CLERIC, "magic.device.fizzled")) == 1 + assert outcome(used_session, used_result, drop=is_fizzle) == outcome(held_session, held_result) + assert staff.model_dump(mode="json") == held_staff.model_dump(mode="json") + assert has_condition(used_session.member(CLERIC), Condition.INVISIBLE), "the unused staff broke invisibility" + + @staticmethod + def _staff_round(seed: int, *, use: bool): + session, group, staff = staff_battle(seed, invisible=True) + assert has_condition(session.member(CLERIC), Condition.INVISIBLE) + extra = (use_staff(session, group, staff),) if use else () + result = session.execute(ResolveBattleRound(declarations=declarations(session, *extra))) + assert result.accepted, [rejection.code for rejection in result.rejections] + return session, result, group, staff + + def test_an_area_wand_aimed_at_a_group_that_left_the_battle_fizzles(self): + fizzled_seeds, resolved_seeds = [], [] + for seed in SCAN_SEEDS: + session, fleeing, wand, declaration = wand_battle(seed) + result = session.execute(ResolveBattleRound(declarations=declarations(session, declaration))) + assert result.accepted, [rejection.code for rejection in result.rejections] + assert session.mode is SessionMode.BATTLE, "the second group keeps the battle going" + if monsters_first(result, fleeing.id): + assert fleeing.fled, "the group left the battle before the magic phase" + found = fizzles(result.events, MAGIC_USER, "magic.device.fizzled") + assert len(found) == 1, [event.code for event in result.events] + assert_device_fizzle( + found[0], + actor_id=MAGIC_USER, + instance_id=wand.instance_id, + template_id="wand_of_cold", + reason="battle.declaration.unknown_group", + ) + assert not activations(result.events, wand.instance_id) + assert wand.charges_remaining == 5 + assert wand.identified is False + fizzled_seeds.append(seed) + else: + assert not fizzles(result.events, MAGIC_USER, "magic.device.fizzled") + assert activations(result.events, wand.instance_id) + assert wand.charges_remaining == 4 + resolved_seeds.append(seed) + assert fizzled_seeds, "no seed let the monsters act first" + assert resolved_seeds, "no seed let the party act first" + + def test_a_fizzled_device_round_replays_identically(self): + def play(seed): + session, group, staff = staff_battle(seed) + result = session.execute( + ResolveBattleRound(declarations=declarations(session, use_staff(session, group, staff))) + ) + return session, result, group + + seed = first_seed_where_monsters_act_first(lambda seed: play(seed)[1:]) + first_session, first_result, _ = play(seed) + second_session, second_result, _ = play(seed) + assert len(fizzles(first_result.events, CLERIC, "magic.device.fizzled")) == 1 + assert outcome(first_session, first_result) == outcome(second_session, second_result) + + +def weaken_the_cleric_after_party_movement(monkeypatch) -> None: + """Weaken the cleric partway through the round, after the round was accepted and before the magic phase. + + Nothing in the rules weakens a cleric in the middle of a battle round today, so the test forces it. It wraps + the party block's movement step, which runs before the party's missile and magic phases. Weakened is one of + the conditions under which `validate_turn_undead` refuses a turning, and it isn't one of the conditions that + make the magic phase skip a member outright. + """ + original = battle_module._party_movement + + def movement_then_weakness(session, by_member): + events = original(session, by_member) + cleric = session.member(CLERIC) + if not has_condition(cleric, Condition.WEAKENED): + cleric.conditions = (*cleric.conditions, ActiveCondition(condition=Condition.WEAKENED, effect_id=None)) + return events + + monkeypatch.setattr(battle_module, "_party_movement", movement_then_weakness) + + +def skeleton_battle(seed: int) -> GameSession: + session = GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=seed) + _light_and_enter(session) + instances = session.spawn("skeleton", 2) + encounter_module.start_encounter( + session, + groups=[("skeleton", instances)], + kind="spawned", + distance_feet=40, + pinned_stance=ReactionResult.HOSTILE, + party_aware=True, + ) + session.execute(EngageBattle()) + assert session.mode is SessionMode.BATTLE + return session + + +@CHUNK +class TestATurningIsJudgedAgainInTheMagicPhase: + """A `turn_undead` declaration is judged again just before it resolves. One that now fails fizzles with + `magic.turning.fizzled` and no `item_id`, instead of raising out of the round.""" + + def test_a_turning_the_cleric_can_no_longer_attempt_fizzles(self, monkeypatch): + weaken_the_cleric_after_party_movement(monkeypatch) + turning = skeleton_battle(seed=3) + assert not has_condition(turning.member(CLERIC), Condition.WEAKENED), "accepted while able to turn" + turn = BattleDeclaration(character_id=CLERIC, action="turn_undead") + result = turning.execute(ResolveBattleRound(declarations=declarations(turning, turn))) + assert result.accepted, [rejection.code for rejection in result.rejections] + assert has_condition(turning.member(CLERIC), Condition.WEAKENED), "the cleric was weakened mid-round" + + found = fizzles(result.events, CLERIC, "magic.turning.fizzled") + assert len(found) == 1, [event.code for event in result.events] + fizzle = found[0] + assert fizzle.event_type == "declaration_fizzled" + assert fizzle.actor_id == CLERIC + assert fizzle.item_id is None + assert fizzle.reason == "magic.turning.caster_incapacitated" + assert fizzle.visibility is Visibility.PLAYER + message = format_message(fizzle) + assert message and message != fizzle.code + assert "lost" not in message.lower(), "a turning that fizzles loses nothing" + assert "turn" in message.lower(), "the message names the turning" + assert not any(isinstance(event, UndeadTurnedEvent) for event in result.events) + + holding = skeleton_battle(seed=3) + held = holding.execute(ResolveBattleRound(declarations=declarations(holding))) + assert held.accepted + assert outcome(turning, result, drop=is_fizzle) == outcome(holding, held) + + def test_a_fizzled_turning_draws_nothing_from_the_magic_stream(self, monkeypatch): + weaken_the_cleric_after_party_movement(monkeypatch) + session = skeleton_battle(seed=3) + before = session.streams.get(MAGIC_STREAM).export_state() + turn = BattleDeclaration(character_id=CLERIC, action="turn_undead") + result = session.execute(ResolveBattleRound(declarations=declarations(session, turn))) + assert result.accepted + assert len(fizzles(result.events, CLERIC, "magic.turning.fizzled")) == 1 + assert session.streams.get(MAGIC_STREAM).export_state() == before + + +# ---------------------------------------------------------------------- what stays as it is + + +STAFF_SEED_0_CODES = [ + "battle.round.started", + "combat.initiative.rolled", + "items.item.identified", + "items.device.activated", + "combat.attack.hit", + "combat.damage.dealt", + "combat.state.hit_points", + "effects.condition.gained", + "combat.death.died", + "combat.state.hit_points", + "battle.group.moved", +] +"""The staff round under seed 0, captured before the chunk: the party acts first and the staff strikes at 5 feet.""" + +STAFF_SEED_0_ROLL = (16, 16, 13) +"""The cleric's attack roll under seed 0 as `(roll, total, required)`, captured before the chunk.""" + +STAFF_SEED_0_DAMAGE = (5, (1, 4)) +"""The staff's 2d6 under seed 0 as `(amount, rolls)`, captured before the chunk.""" + + +class TestADeviceTheReCheckPassesResolvesAsItDid: + """The re-check takes no draw, so a device use that passes it resolves with the same events and the same draws + as before the chunk. The literal values below were captured from the engine before the chunk. They pass before + and after it.""" + + def test_a_staff_of_striking_in_reach_strikes_and_spends_a_charge(self): + session, group, staff = staff_battle(0) + combat_before = session.streams.get(COMBAT_STREAM).export_state() + result = session.execute( + ResolveBattleRound(declarations=declarations(session, use_staff(session, group, staff))) + ) + assert result.accepted + assert not monsters_first(result, group.id), "the party acts first under this seed" + assert [event.code for event in result.events] == STAFF_SEED_0_CODES + strike = next( + event for event in result.events if isinstance(event, AttackRolledEvent) and event.attacker_id == CLERIC + ) + assert (strike.roll, strike.total, strike.required) == STAFF_SEED_0_ROLL + damage = next(event for event in result.events if event.code == "combat.damage.dealt") + assert (damage.amount, tuple(damage.rolls)) == STAFF_SEED_0_DAMAGE + assert staff.charges_remaining == 4 + assert staff.identified is True + assert session.streams.get(COMBAT_STREAM).export_state() != combat_before + + def test_a_staff_declared_out_of_reach_is_refused_and_costs_nothing(self): + session, group, staff = staff_battle(0) + group.fleeing = False + group.distance_feet = 40 + streams = stream_states(session) + result = session.execute( + ResolveBattleRound(declarations=declarations(session, use_staff(session, group, staff))) + ) + assert not result.accepted + assert [rejection.code for rejection in result.rejections] == ["combat.attack.out_of_reach"] + assert result.events == () + assert staff.charges_remaining == 5 + assert staff.identified is False + assert stream_states(session) == streams From e6cbbb99931069db7e9ab00bce94b079ebb5595d Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 14:01:11 -0700 Subject: [PATCH 2/5] Default the attack weapon and re-check device uses and turnings An attack declared with no weapon_id now uses the wielded weapon that suits the attack (#162, item 18). The session chooses it once, when it accepts the round, from the group's distance: the first wielded item in equip order with the melee or missile quality, preferring a missile weapon whose long range reaches the group, skipping splash weapons and items with no weapon stats, and falling back to bare hands. The chosen id replaces None in the round's working declarations, so validation, initiative, and every phase read it as if it had been named. The command log keeps the declaration as sent. A wand, staff, or rod use and a turning are judged again in the magic phase, as a cast and a scroll read are (#145). One that now fails fizzles with DeclarationFizzledEvent and the re-check's first rejection code as its reason. A fizzled device spends no charge, isn't identified, and breaks no invisibility. A fizzled turning draws nothing. The re-check takes no draw, so a declaration that passes it resolves with the same draws as before. The ResolveBattleRound example relies on the default weapon instead of a helper, and prints the same result. The adaptations register and the Unreleased changelog describe both rules. No golden file changed. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 5 ++- docs/adaptations.md | 4 +- src/osrlib/crawl/battle.py | 82 ++++++++++++++++++++++++++++++++++-- src/osrlib/crawl/commands.py | 16 ++----- 4 files changed, 89 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44b1fa5..3b9d6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - [`Rest`][osrlib.crawl.commands.Rest] has a new field `hidden`, and [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] takes a matching `hidden` parameter, so a game can let the party hide while it rests. The OSE SRD lowers the chance of wandering monsters when the party rests quietly in an out-of-the-way place, and a game sets `hidden` to say the party does. Each wandering-monster check during a hidden rest takes 1 off the chance in 6, after noise and bright light add theirs. On a level with the usual 1 in 6 chance, the session rolls no check during a hidden rest. The field defaults to `False`, so a saved command without it still parses as a plain rest. In town no checks run, so `hidden` changes nothing there. - Each attack routine has a new field, [`AttackRoutine.use`][osrlib.core.monsters.AttackRoutine.use], that gives when the monster uses the routine. Its value is a [`RoutineUse`][osrlib.core.monsters.RoutineUse]. The first routine is `PRIMARY`. A later routine is `ALTERNATIVE` when the OSE SRD lets the monster use it in place of the first in any round, like a thoul's weapon. It's `SITUATIONAL` when the SRD ties it to a situation osrlib doesn't model, like an elephant's trample or a giant's thrown boulder. It's `SPECIAL` when it's a breath, a gaze, or a spell. Every routine in the built-in monster and spell catalogs has its label. A routine with no label is `PRIMARY`, so in a monster template of your own, label each later routine, or the default policy picks it as often as the first. - A policy of your own can choose which attack routine a monster uses, in the new field [`MonsterAction.routine`][osrlib.crawl.battle.MonsterAction.routine]. The field is an index into the template's `attacks`, and `None` uses the first routine. So your policy can have an elephant trample when your game decides it charges. Battle rolls the routine's attacks as melee attacks, and rolls nothing for a `SPECIAL` routine. An index the template doesn't have makes the action impossible, so the session skips it. +- [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] reports a wand, staff, or rod use or a turning that fizzled in a battle round (#145). Its code is `magic.device.fizzled` for a device and `magic.turning.fizzled` for a turning. `actor_id` names the member who declared it, `item_id` is the device's instance id (`None` for a turning), and `reason` is the rejection code of the check that failed. Tell the player the action failed. A device that fizzles keeps its charge. ### Changed @@ -29,7 +30,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - A melee attack on a petrified creature hits automatically, as on a paralysed or sleeping one (#166). [`attack_roll`][osrlib.core.combat.attack_roll] reports `combat.attack.auto_hit` and draws no d20, so only the damage is rolled. A missile attack on a petrified creature is still rolled. - [`UseItem`][osrlib.crawl.commands.UseItem] with a potion and a `target_id` that names someone other than the member using it is now refused with `items.use.target_not_allowed`. Before, the session accepted the command and the member in `character_id` drank the potion, so a front end that expected the target to drink got no warning. A `target_id` of `None`, or the id of the member using the potion, drinks the potion as before, and a healing device still takes another member (#174). - A plain [`Rest`][osrlib.crawl.commands.Rest] in a dungeon now checks for wandering monsters at the level's full chance. Before, every rest took 1 off the chance, so a level with the usual 1 in 6 chance never rolled during a rest and resting was always safe. The session still adds 1 for noise and 1 for bright light, and a rest of a night or a day still checks once each turn and stops when monsters come. To rest quietly and take the 1 off, set the new `hidden` field. The `resting` keyword of [`wandering_check`][osrlib.crawl.exploration.wandering_check] still takes 1 off the chance. [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] now sets it from `hidden`, so `advance_turns(resting=True)` alone no longer lowers the chance. It still counts the turns as rest. -- Some command logs recorded under 2.0.1 don't replay under this release. A log that contains a command this release refuses makes [`replay_game`][osrlib.persistence.replay_game] raise [`ContentValidationError`][osrlib.errors.ContentValidationError]. Two commands that 2.0.1 accepted are refused now, so a log that contains one of them raises: a potion `UseItem` aimed at another member, and a temple service that would do nothing. Other logs replay without an error but play out differently. That happens with *sleep* cast at a drained monster, a lair the party left and came back to, a rest in a dungeon, a rest that a wandering monster interrupted, a trap save with a `modifier`, or a chute trap where every member passed the save. In battle, it also happens with a monster that regenerates (a troll, a thoul, or a vampire), a monster whose attack has an effect (like a ghoul's paralysing claw or a wight's level drain), a basilisk's or a medusa's gaze, a melee attack on a petrified creature, or a monster that can choose between attack routines (a spitting cobra, a thoul, a mule, a minotaur, a wererat, or a monster of your own with more than one unlabelled routine). Each of these changes has an entry in this section or under Fixed. Loading a 2.0.1 save with [`load_game`][osrlib.persistence.load_game] isn't affected, because a load restores the saved state and runs no commands. The loaded game then plays on under this release's rules. +- An `attack` in a [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] with no `weapon_id` now uses the wielded weapon that suits the attack (#162). Before, the member struck unarmed, even with a sword in hand. The group's distance when the session accepts the round decides the attack: melee at 5 feet or less, missile beyond. The session takes the first wielded weapon, in the order the member equipped them, that can make that attack. For a missile attack it prefers the first whose long range reaches the group. It passes over a splash weapon, like a flask of oil, and an item with no weapon stats, like a wand. A member with no suitable weapon still strikes unarmed. The round plays out as if you had named the chosen weapon, and the command log keeps the declaration as you sent it. [`BattleDeclaration.weapon_id`][osrlib.crawl.commands.BattleDeclaration.weapon_id] describes the choice. A declaration that names a weapon works as before. +- A wand, staff, or rod use and a turning declared in [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] are checked again in the magic phase, just before they resolve, as a spell cast and a scroll read are (#145). Before, they resolved even when the round had changed something they depend on. For example, a staff of striking struck a group that had already run out of melee reach. Now a declaration that fails the second check fizzles, and the session reports it with a [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent]. A device that fizzles was never used, so it keeps its charge, isn't identified, and doesn't break the user's invisibility. A turning that fizzles rolls nothing. A device use or turning that passes the second check resolves with the same rolls as before. +- Some command logs recorded under 2.0.1 don't replay under this release. A log that contains a command this release refuses makes [`replay_game`][osrlib.persistence.replay_game] raise [`ContentValidationError`][osrlib.errors.ContentValidationError]. Two commands that 2.0.1 accepted are refused now, so a log that contains one of them raises: a potion `UseItem` aimed at another member, and a temple service that would do nothing. Other logs replay without an error but play out differently. That happens with *sleep* cast at a drained monster, a lair the party left and came back to, a rest in a dungeon, a rest that a wandering monster interrupted, a trap save with a `modifier`, or a chute trap where every member passed the save. In battle, it also happens with a monster that regenerates (a troll, a thoul, or a vampire), a monster whose attack has an effect (like a ghoul's paralysing claw or a wight's level drain), a basilisk's or a medusa's gaze, a melee attack on a petrified creature, a monster that can choose between attack routines (a spitting cobra, a thoul, a mule, a minotaur, a wererat, or a monster of your own with more than one unlabelled routine), an attack declared with no `weapon_id` by a member who wields a weapon, or a device use (a wand, staff, or rod) or a turning that now fizzles. Each of these changes has an entry in this section or under Fixed. Loading a 2.0.1 save with [`load_game`][osrlib.persistence.load_game] isn't affected, because a load restores the saved state and runs no commands. The loaded game then plays on under this release's rules. ### Fixed diff --git a/docs/adaptations.md b/docs/adaptations.md index 148f506..0d878bb 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -372,7 +372,9 @@ The party moves as a formation and individual members cannot leave it (the Bard' A party melee attack hits the first living, visible monster of the group's reachable rank (deterministic, no draw), and monsters pick uniformly on the monster-action stream. Under individual initiative the machine still resolves side blocks, ordered by the best individual total (the SRD's phase sequence is per side). Turn undead resolves in the magic phase, needs no declaration posting, and cannot be disrupted, because it is a class ability rather than a spell. The machine detects disruption per the RAW trigger (a declared caster successfully attacked or failing a save before acting) and releases concentration on any other declared action. A turning is one of those actions, so a cleric who declares one stops concentrating, even when confusion stops the turning before it resolves. A caster maintains one concentration spell at a time: casting a second ends the first, and a spell that needs no concentration leaves it alone (#167). Locked by `test_rules_calls.py::TestTurningEndsConcentration` and `test_rules_calls.py::TestOneConcentrationSpellAtATime`. -A declaration is judged twice: once when the round is accepted, and again in the magic phase immediately before it resolves, with every check it passed the first time, because the phases between the two can change what those checks read. Failing any one of them fizzles the declaration, whatever the round changed to cause the failure. Two cases that occur are an ally's *silence 15' radius* anchoring on the party's cell earlier in the same magic phase, which refuses a cast with `magic.cast.silenced_area`, and the party's last torch going out as its bearer dies in the missile phase, which refuses a scroll read with `exploration.action.requires_light`. A cast or scroll read the re-check refuses is not cast and never raises out of the round: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with `magic.cast.fizzled` on `SpellDisruptedEvent`, whose `reason` field contains the first rejection code the re-check produced. A caster the round already disrupted reports `magic.cast.disrupted` instead, because that check runs first. Each battle round advances the clock one round through the ledger. Locked by `test_battle.py`, the split refusal by `test_battle.py::TestAFormationMoveNeedsEveryDeclarer`, and the re-check by `test_battle.py::TestTheMagicPhaseRechecksADeclaration`. +An `attack` declaration that names no weapon attacks with the wielded weapon that suits the attack (#162). The SRD decides melee or missile by distance alone ("Melee: Close quarters weapon (5' or less)" and "Missile: Thrown or fired weapon (greater than 5' distance)"), so the group's distance when the round is accepted decides it: a melee attack at 5' or less, a missile attack beyond. The session takes the first wielded item, in the order the member equipped them, with the `melee` or `missile` quality. For a missile attack it prefers the first whose long range reaches the group, and when none reaches it takes the first missile weapon, which the session then refuses with `combat.attack.out_of_range`. A torch counts, because the weapon table gives it the melee quality. A splash weapon never counts, because a `use_item` declaration throws it, and a magic item with no base weapon, like a wand, has no weapon stats. A member with nothing that suits the attack strikes unarmed. The choice is made once, when the round is accepted, and the round then plays out exactly as if the player had named that weapon, even when the distance changes before the attack resolves. The command log keeps the declaration as it was sent. Locked by `test_weapon_default_and_device_recheck.py`. + +A declaration is judged twice: once when the round is accepted, and again in the magic phase immediately before it resolves, with every check it passed the first time, because the phases between the two can change what those checks read. Failing any one of them fizzles the declaration, whatever the round changed to cause the failure. Two cases that occur are an ally's *silence 15' radius* anchoring on the party's cell earlier in the same magic phase, which refuses a cast with `magic.cast.silenced_area`, and the party's last torch going out as its bearer dies in the missile phase, which refuses a scroll read with `exploration.action.requires_light`. A cast or scroll read the re-check refuses is not cast and never raises out of the round: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with `magic.cast.fizzled` on `SpellDisruptedEvent`, whose `reason` field contains the first rejection code the re-check produced. A caster the round already disrupted reports `magic.cast.disrupted` instead, because that check runs first. A wand, staff, or rod use and a turning get the same re-check (#145). A staff of striking aimed at a group that ran out of melee reach fizzles with `combat.attack.out_of_reach`, and a wand aimed at a group that fled the battle fizzles with `battle.declaration.unknown_group`. One that fizzles is reported by `DeclarationFizzledEvent`, at `magic.device.fizzled` or `magic.turning.fizzled`, with the same `reason` field. `SpellDisruptedEvent` stays for spells and scrolls, so its `spell_id` always names a spell. A fizzled device use costs what a `hold` costs: the device was never used, so it keeps its charge, isn't identified, and breaks no invisibility. A fizzled turning draws nothing and costs nothing. Each battle round advances the clock one round through the ledger. Locked by `test_battle.py`, the split refusal by `test_battle.py::TestAFormationMoveNeedsEveryDeclarer`, and the re-check by `test_battle.py::TestTheMagicPhaseRechecksADeclaration` and, for devices and turnings, `test_weapon_default_and_device_recheck.py`. ### Spell effect consumption in battle diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index e3b28d5..b0f6fb3 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -222,7 +222,13 @@ from osrlib.core.creature import Creature from osrlib.core.dice import roll from osrlib.core.effects import EFFECTS_STREAM, Condition, EffectDefinition, has_condition, kill -from osrlib.core.events import AttackRolledEvent, Event, SavingThrowRolledEvent, SpellDisruptedEvent +from osrlib.core.events import ( + AttackRolledEvent, + DeclarationFizzledEvent, + Event, + SavingThrowRolledEvent, + SpellDisruptedEvent, +) from osrlib.core.items import ( GearTemplate, ItemInstance, @@ -1369,6 +1375,48 @@ def _find_wielded(member, weapon_id: str | None): return None +def _with_default_weapon(session, declaration: BattleDeclaration, member) -> BattleDeclaration: + """Fill in the weapon of an `attack` that names none, as `BattleDeclaration.weapon_id` describes. + + The group's distance decides the attack: melee at 5' or less, missile beyond. The weapon is the first + wielded item, in equip order, with that quality. For a missile attack it's the first of those whose long + range reaches the group, and the first missile weapon only when none reaches. A splash weapon never + counts, because a `use_item` declaration throws it, and neither does an item with no weapon stats, like a + wand. The session calls this once, when it + accepts the round, so the rest of the round reads the chosen weapon as if the player had named it. + + Returns: + The declaration with `weapon_id` set to the chosen weapon's id: a magic weapon's instance id or a + mundane weapon's catalog id. The declaration itself when it isn't an `attack`, already names a weapon, + targets no group the battle still has, or the member wields nothing that suits the attack, which + leaves the attack unarmed. + """ + if declaration.action != "attack" or declaration.weapon_id is not None: + return declaration + group = _group_by_id(session, declaration.target_group_id) + if group is None or group.fled: + return declaration + missile = group.distance_feet > MELEE_RANGE_FEET + wanted = WeaponQuality.MISSILE if missile else WeaponQuality.MELEE + suitable = [] + for held in member.inventory.wielded: + is_magic = isinstance(held, MagicItemInstance) + facet = _declaration_facet(held if is_magic else held.template) + qualities = getattr(facet, "qualities", ()) + if wanted in qualities and WeaponQuality.SPLASH not in qualities: + suitable.append((held.instance_id if is_magic else held.template.id, facet)) + if not suitable: + return declaration + chosen = suitable[0][0] + if missile: + for weapon_id, facet in suitable: + ranges = getattr(facet, "missile_ranges", None) + if ranges is None or group.distance_feet <= ranges.long.max_feet: + chosen = weapon_id + break + return declaration.model_copy(update={"weapon_id": chosen}) + + def _declaration_facet(weapon): """The combat stats behind a declaration: a facet, a template, or a magic base.""" if isinstance(weapon, MagicItemInstance): @@ -1456,7 +1504,8 @@ def _validate_declaration(session, declaration: BattleDeclaration, member) -> li changes nothing. An `attack` with no `weapon_id` is judged with the weapon that `BattleDeclaration.weapon_id` says the - session chooses, from the group's distance when the round is accepted. + session chooses, from the group's distance when the round is accepted. `_handle_resolve_battle_round` + fills that weapon in with `_with_default_weapon` before it calls this. """ state = session.battle if declaration.action == "hold": @@ -1920,6 +1969,9 @@ def _handle_resolve_battle_round(session, command: ResolveBattleRound) -> tuple[ by_member = {} for declaration in command.declarations: member = session.member(declaration.character_id) + # The default weapon is chosen once, here, so the round plays out as if the player had named it. + # The command log keeps the declaration as it was sent. + declaration = _with_default_weapon(session, declaration, member) by_member[declaration.character_id] = (member, declaration) rejections.extend(_validate_declaration(session, declaration, member)) rejections.extend(_formation_split_rejections(declarers, command.declarations)) @@ -2434,6 +2486,21 @@ def _party_magic(session, by_member, pending_casters, disrupted, acted, state) - continue # thrown splash gear resolved in the missile phase instance = member.inventory.magic_item(declaration.item_id) category = magic_item_template(instance).category + if category in (MagicItemCategory.ROD, MagicItemCategory.STAFF, MagicItemCategory.WAND): + # The device use is judged again, as a cast is below. A refused one was never used, so + # it spends no charge, identifies nothing, and breaks no invisibility. + recheck = _validate_declaration(session, declaration, member) + if recheck: + events.append( + DeclarationFizzledEvent( + code="magic.device.fizzled", + actor_id=member.id, + item_id=declaration.item_id, + reason=recheck[0].code, + ) + ) + acted.add(member.id) + continue events.extend(_resolve_magic_item_use(session, member, declaration, state)) if category is not MagicItemCategory.POTION: # Unleashing a device or scroll is an attack for invisibility's @@ -2443,7 +2510,16 @@ def _party_magic(session, by_member, pending_casters, disrupted, acted, state) - continue if declaration.action == "turn_undead": # Turning resolves in the magic phase but is never disruptable: it is - # a class ability, not a spell (see the adaptations register). + # a class ability, not a spell (see the adaptations register). It is judged + # again, though, and a refused one fizzles before it draws or costs anything. + recheck = _validate_declaration(session, declaration, member) + if recheck: + events.append( + DeclarationFizzledEvent(code="magic.turning.fizzled", actor_id=member.id, reason=recheck[0].code) + ) + events.extend(_release_concentration(session, member.id, state)) + acted.add(member.id) + continue candidates = [ session.combatant(monster_id) for group in session.encounter.groups for monster_id in group.monster_ids ] diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 76a4888..b1cb2d4 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2702,18 +2702,9 @@ class ResolveBattleRound(Command): if session.mode is SessionMode.ENCOUNTER: # the goblins didn't attack first session.execute(EngageBattle()) - def wielded_weapon_id(member): - # A magic weapon is named by its instance id, a mundane one by its catalog id. - wielded = member.inventory["wielded"] - if not wielded: - return None - return wielded[0].get("instance_id") or wielded[0]["template"]["id"] - rounds = 0 while session.mode is SessionMode.BATTLE: - view = session.view(Visibility.PLAYER) - encounter = view.encounter - weapons = {member.id: wielded_weapon_id(member) for member in view.party} + encounter = session.view(Visibility.PLAYER).encounter target = min((group for group in encounter.groups if group.count), key=lambda group: group.distance_feet) declarations = [] for member_id in encounter.declarers: @@ -2722,9 +2713,8 @@ def wielded_weapon_id(member): character_id=member_id, action="move", move="close", target_group_id=target.id ) elif member_id in encounter.front_rank: - declaration = BattleDeclaration( - character_id=member_id, action="attack", target_group_id=target.id, weapon_id=weapons[member_id] - ) + # With no weapon_id, the member attacks with the wielded weapon that suits the attack. + declaration = BattleDeclaration(character_id=member_id, action="attack", target_group_id=target.id) else: declaration = BattleDeclaration(character_id=member_id, action="hold") declarations.append(declaration) From a84ffd67339cc6897af279657883d64a11ef50c0 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 14:03:32 -0700 Subject: [PATCH 3/5] Prefer a real weapon over a torch in the default-weapon tests A member who lights a torch and then draws a sword is the usual dungeon setup, and the default attack should swing the sword. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- tests/test_weapon_default_and_device_recheck.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_weapon_default_and_device_recheck.py b/tests/test_weapon_default_and_device_recheck.py index 78ddf86..9bea9d5 100644 --- a/tests/test_weapon_default_and_device_recheck.py +++ b/tests/test_weapon_default_and_device_recheck.py @@ -11,7 +11,9 @@ reads the group's distance when it accepts the round, then takes the first wielded item, in the order the member equipped them, with the `melee` quality for a melee attack or the `missile` quality for a missile attack. Among missile weapons it prefers the first whose long range reaches the group. The SRD's weapon table -lists the torch with the `Melee` quality, so a torch counts. Holy water and burning oil are "Splash weapon" +lists the torch with the `Melee` quality, so a torch counts, but only when the member wields no real weapon that +suits the attack, because a member with a torch in one hand and a sword in the other swings the sword. Holy +water and burning oil are "Splash weapon" entries that a `use_item` declaration throws, so they never count, and a wand has no weapon stats at all. A member with nothing that suits the attack strikes unarmed, as before. The choice is made once, when the round is accepted, so a round with `weapon_id=None` is exactly the round that names the chosen weapon. Most tests @@ -212,6 +214,8 @@ def is_fizzle(event: Event) -> bool: pytest.param(40, ("spear", "long_bow"), "spear", "Spear", id="the first missile weapon that reaches"), pytest.param(100, ("spear", "long_bow"), "long_bow", "Long bow", id="a later missile weapon that reaches"), pytest.param(5, ("torch",), "torch", "improvised", id="a torch is a melee weapon"), + pytest.param(5, ("torch", "sword"), "sword", "Sword", id="a sword over a torch equipped first"), + pytest.param(5, ("torch", "dagger"), "dagger", "Dagger", id="a dagger over a torch equipped first"), pytest.param(40, ("oil_flask", "short_bow"), "short_bow", "Short bow", id="a splash weapon is passed over"), pytest.param(200, ("short_bow",), "short_bow", None, id="a bow that cannot reach is still the bow"), ] From 2374a60e319581d0eab7ff9f12a3390b73112c83 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 14:06:56 -0700 Subject: [PATCH 4/5] Prefer a real weapon over an improvised one for the default attack A member who lights a torch and then draws a sword swung the torch, because the torch was equipped first. Among the wielded items that suit the attack, a real weapon now comes before an improvised one, a gear item whose stats come from its combat facet, like the torch. Equip order holds within each kind, and the torch still counts when nothing else suits the attack. The weapon_id docstring, the adaptations register, and the changelog bullet say the same. No golden file changed. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 2 +- docs/adaptations.md | 2 +- src/osrlib/crawl/battle.py | 16 ++++++++++------ src/osrlib/crawl/commands.py | 12 +++++++----- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b9d6f8..5402e41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - A melee attack on a petrified creature hits automatically, as on a paralysed or sleeping one (#166). [`attack_roll`][osrlib.core.combat.attack_roll] reports `combat.attack.auto_hit` and draws no d20, so only the damage is rolled. A missile attack on a petrified creature is still rolled. - [`UseItem`][osrlib.crawl.commands.UseItem] with a potion and a `target_id` that names someone other than the member using it is now refused with `items.use.target_not_allowed`. Before, the session accepted the command and the member in `character_id` drank the potion, so a front end that expected the target to drink got no warning. A `target_id` of `None`, or the id of the member using the potion, drinks the potion as before, and a healing device still takes another member (#174). - A plain [`Rest`][osrlib.crawl.commands.Rest] in a dungeon now checks for wandering monsters at the level's full chance. Before, every rest took 1 off the chance, so a level with the usual 1 in 6 chance never rolled during a rest and resting was always safe. The session still adds 1 for noise and 1 for bright light, and a rest of a night or a day still checks once each turn and stops when monsters come. To rest quietly and take the 1 off, set the new `hidden` field. The `resting` keyword of [`wandering_check`][osrlib.crawl.exploration.wandering_check] still takes 1 off the chance. [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] now sets it from `hidden`, so `advance_turns(resting=True)` alone no longer lowers the chance. It still counts the turns as rest. -- An `attack` in a [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] with no `weapon_id` now uses the wielded weapon that suits the attack (#162). Before, the member struck unarmed, even with a sword in hand. The group's distance when the session accepts the round decides the attack: melee at 5 feet or less, missile beyond. The session takes the first wielded weapon, in the order the member equipped them, that can make that attack. For a missile attack it prefers the first whose long range reaches the group. It passes over a splash weapon, like a flask of oil, and an item with no weapon stats, like a wand. A member with no suitable weapon still strikes unarmed. The round plays out as if you had named the chosen weapon, and the command log keeps the declaration as you sent it. [`BattleDeclaration.weapon_id`][osrlib.crawl.commands.BattleDeclaration.weapon_id] describes the choice. A declaration that names a weapon works as before. +- An `attack` in a [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] with no `weapon_id` now uses the wielded weapon that suits the attack (#162). Before, the member struck unarmed, even with a sword in hand. The group's distance when the session accepts the round decides the attack: melee at 5 feet or less, missile beyond. The session takes the first wielded weapon, in the order the member equipped them, that can make that attack. For a missile attack it prefers the first whose long range reaches the group. A real weapon comes before an improvised one, like a torch, so a member who lit a torch and then drew a sword swings the sword. The torch still counts when the member wields nothing else that suits the attack. The session passes over a splash weapon, like a flask of oil, and an item with no weapon stats, like a wand. A member with no suitable weapon still strikes unarmed. The round plays out as if you had named the chosen weapon, and the command log keeps the declaration as you sent it. [`BattleDeclaration.weapon_id`][osrlib.crawl.commands.BattleDeclaration.weapon_id] describes the choice. A declaration that names a weapon works as before. - A wand, staff, or rod use and a turning declared in [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] are checked again in the magic phase, just before they resolve, as a spell cast and a scroll read are (#145). Before, they resolved even when the round had changed something they depend on. For example, a staff of striking struck a group that had already run out of melee reach. Now a declaration that fails the second check fizzles, and the session reports it with a [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent]. A device that fizzles was never used, so it keeps its charge, isn't identified, and doesn't break the user's invisibility. A turning that fizzles rolls nothing. A device use or turning that passes the second check resolves with the same rolls as before. - Some command logs recorded under 2.0.1 don't replay under this release. A log that contains a command this release refuses makes [`replay_game`][osrlib.persistence.replay_game] raise [`ContentValidationError`][osrlib.errors.ContentValidationError]. Two commands that 2.0.1 accepted are refused now, so a log that contains one of them raises: a potion `UseItem` aimed at another member, and a temple service that would do nothing. Other logs replay without an error but play out differently. That happens with *sleep* cast at a drained monster, a lair the party left and came back to, a rest in a dungeon, a rest that a wandering monster interrupted, a trap save with a `modifier`, or a chute trap where every member passed the save. In battle, it also happens with a monster that regenerates (a troll, a thoul, or a vampire), a monster whose attack has an effect (like a ghoul's paralysing claw or a wight's level drain), a basilisk's or a medusa's gaze, a melee attack on a petrified creature, a monster that can choose between attack routines (a spitting cobra, a thoul, a mule, a minotaur, a wererat, or a monster of your own with more than one unlabelled routine), an attack declared with no `weapon_id` by a member who wields a weapon, or a device use (a wand, staff, or rod) or a turning that now fizzles. Each of these changes has an entry in this section or under Fixed. Loading a 2.0.1 save with [`load_game`][osrlib.persistence.load_game] isn't affected, because a load restores the saved state and runs no commands. The loaded game then plays on under this release's rules. diff --git a/docs/adaptations.md b/docs/adaptations.md index 0d878bb..3c7872c 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -372,7 +372,7 @@ The party moves as a formation and individual members cannot leave it (the Bard' A party melee attack hits the first living, visible monster of the group's reachable rank (deterministic, no draw), and monsters pick uniformly on the monster-action stream. Under individual initiative the machine still resolves side blocks, ordered by the best individual total (the SRD's phase sequence is per side). Turn undead resolves in the magic phase, needs no declaration posting, and cannot be disrupted, because it is a class ability rather than a spell. The machine detects disruption per the RAW trigger (a declared caster successfully attacked or failing a save before acting) and releases concentration on any other declared action. A turning is one of those actions, so a cleric who declares one stops concentrating, even when confusion stops the turning before it resolves. A caster maintains one concentration spell at a time: casting a second ends the first, and a spell that needs no concentration leaves it alone (#167). Locked by `test_rules_calls.py::TestTurningEndsConcentration` and `test_rules_calls.py::TestOneConcentrationSpellAtATime`. -An `attack` declaration that names no weapon attacks with the wielded weapon that suits the attack (#162). The SRD decides melee or missile by distance alone ("Melee: Close quarters weapon (5' or less)" and "Missile: Thrown or fired weapon (greater than 5' distance)"), so the group's distance when the round is accepted decides it: a melee attack at 5' or less, a missile attack beyond. The session takes the first wielded item, in the order the member equipped them, with the `melee` or `missile` quality. For a missile attack it prefers the first whose long range reaches the group, and when none reaches it takes the first missile weapon, which the session then refuses with `combat.attack.out_of_range`. A torch counts, because the weapon table gives it the melee quality. A splash weapon never counts, because a `use_item` declaration throws it, and a magic item with no base weapon, like a wand, has no weapon stats. A member with nothing that suits the attack strikes unarmed. The choice is made once, when the round is accepted, and the round then plays out exactly as if the player had named that weapon, even when the distance changes before the attack resolves. The command log keeps the declaration as it was sent. Locked by `test_weapon_default_and_device_recheck.py`. +An `attack` declaration that names no weapon attacks with the wielded weapon that suits the attack (#162). The SRD decides melee or missile by distance alone ("Melee: Close quarters weapon (5' or less)" and "Missile: Thrown or fired weapon (greater than 5' distance)"), so the group's distance when the round is accepted decides it: a melee attack at 5' or less, a missile attack beyond. The session takes the first wielded item, in the order the member equipped them, with the `melee` or `missile` quality. For a missile attack it prefers the first whose long range reaches the group, and when none reaches it takes the first missile weapon, which the session then refuses with `combat.attack.out_of_range`. A real weapon comes before an improvised one, a gear item whose stats come from its combat facet, like a torch, and equip order holds within each kind. A member who lights a torch and then draws a sword, the usual dungeon setup, swings the sword. The torch still counts when nothing else suits the attack, because the weapon table gives it the melee quality. A splash weapon never counts, because a `use_item` declaration throws it, and a magic item with no base weapon, like a wand, has no weapon stats. A member with nothing that suits the attack strikes unarmed. The choice is made once, when the round is accepted, and the round then plays out exactly as if the player had named that weapon, even when the distance changes before the attack resolves. The command log keeps the declaration as it was sent. Locked by `test_weapon_default_and_device_recheck.py`. A declaration is judged twice: once when the round is accepted, and again in the magic phase immediately before it resolves, with every check it passed the first time, because the phases between the two can change what those checks read. Failing any one of them fizzles the declaration, whatever the round changed to cause the failure. Two cases that occur are an ally's *silence 15' radius* anchoring on the party's cell earlier in the same magic phase, which refuses a cast with `magic.cast.silenced_area`, and the party's last torch going out as its bearer dies in the missile phase, which refuses a scroll read with `exploration.action.requires_light`. A cast or scroll read the re-check refuses is not cast and never raises out of the round: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with `magic.cast.fizzled` on `SpellDisruptedEvent`, whose `reason` field contains the first rejection code the re-check produced. A caster the round already disrupted reports `magic.cast.disrupted` instead, because that check runs first. A wand, staff, or rod use and a turning get the same re-check (#145). A staff of striking aimed at a group that ran out of melee reach fizzles with `combat.attack.out_of_reach`, and a wand aimed at a group that fled the battle fizzles with `battle.declaration.unknown_group`. One that fizzles is reported by `DeclarationFizzledEvent`, at `magic.device.fizzled` or `magic.turning.fizzled`, with the same `reason` field. `SpellDisruptedEvent` stays for spells and scrolls, so its `spell_id` always names a spell. A fizzled device use costs what a `hold` costs: the device was never used, so it keeps its charge, isn't identified, and breaks no invisibility. A fizzled turning draws nothing and costs nothing. Each battle round advances the clock one round through the ledger. Locked by `test_battle.py`, the split refusal by `test_battle.py::TestAFormationMoveNeedsEveryDeclarer`, and the re-check by `test_battle.py::TestTheMagicPhaseRechecksADeclaration` and, for devices and turnings, `test_weapon_default_and_device_recheck.py`. diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index b0f6fb3..96fdb29 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -1379,10 +1379,12 @@ def _with_default_weapon(session, declaration: BattleDeclaration, member) -> Bat """Fill in the weapon of an `attack` that names none, as `BattleDeclaration.weapon_id` describes. The group's distance decides the attack: melee at 5' or less, missile beyond. The weapon is the first - wielded item, in equip order, with that quality. For a missile attack it's the first of those whose long - range reaches the group, and the first missile weapon only when none reaches. A splash weapon never - counts, because a `use_item` declaration throws it, and neither does an item with no weapon stats, like a - wand. The session calls this once, when it + wielded item, in equip order, with that quality, and a real weapon comes before an improvised one: a gear + item whose stats come from its `combat` facet, like a torch. So a member who lit a torch and then drew a + sword swings the sword, and the torch counts only when nothing else suits the attack. For a missile + attack it's the first of those whose long range reaches the group, and the first missile weapon only + when none reaches. A splash weapon never counts, because a `use_item` declaration throws it, and neither + does an item with no weapon stats, like a wand. The session calls this once, when it accepts the round, so the rest of the round reads the chosen weapon as if the player had named it. Returns: @@ -1398,13 +1400,15 @@ def _with_default_weapon(session, declaration: BattleDeclaration, member) -> Bat return declaration missile = group.distance_feet > MELEE_RANGE_FEET wanted = WeaponQuality.MISSILE if missile else WeaponQuality.MELEE - suitable = [] + weapons, improvised = [], [] for held in member.inventory.wielded: is_magic = isinstance(held, MagicItemInstance) facet = _declaration_facet(held if is_magic else held.template) qualities = getattr(facet, "qualities", ()) if wanted in qualities and WeaponQuality.SPLASH not in qualities: - suitable.append((held.instance_id if is_magic else held.template.id, facet)) + kind = improvised if not is_magic and isinstance(held.template, GearTemplate) else weapons + kind.append((held.instance_id if is_magic else held.template.id, facet)) + suitable = weapons + improvised if not suitable: return declaration chosen = suitable[0][0] diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index b1cb2d4..b047d41 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2459,11 +2459,13 @@ class BattleDeclaration(BaseModel): were equipped and takes the first one with the `melee` quality for a melee attack, or the `missile` quality for a missile attack. For a missile attack, it takes the first one whose long range reaches the group. Only when none reaches does it take the first missile weapon, and the - session then refuses the attack with `combat.attack.out_of_range`. A torch counts, because the - weapon table lists it as a melee weapon. A splash weapon, like a flask of oil or a vial of holy - water, never counts, because a `use_item` declaration throws it. A wand, or another magic item - with no base weapon, has no weapon stats, so it never counts either. A member with no weapon that - suits the attack strikes unarmed. + session then refuses the attack with `combat.attack.out_of_range`. A real weapon comes before an + improvised one, a gear item like a torch, whatever order they were equipped in. So a member who + lit a torch and then drew a sword swings the sword. A torch still counts when nothing else suits + the attack, because the weapon table lists it as a melee weapon. A splash weapon, like a flask + of oil or a vial of holy water, never counts, because a `use_item` declaration throws it. A + wand, or another magic item with no base weapon, has no weapon stats, so it never counts either. + A member with no weapon that suits the attack strikes unarmed. The session makes this choice once, when it accepts the round. The attack then resolves as if you had named that weapon, even when the distance changes before the attack resolves. The From 5d9b8cfee663187d3daadc33a6f003daf64ff971 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 14:15:14 -0700 Subject: [PATCH 5/5] Remove the weapon-and-device markers and edit two changelog phrases Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 4 ++-- tests/test_weapon_default_and_device_recheck.py | 9 ++------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5402e41..dccc77c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - [`Rest`][osrlib.crawl.commands.Rest] has a new field `hidden`, and [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] takes a matching `hidden` parameter, so a game can let the party hide while it rests. The OSE SRD lowers the chance of wandering monsters when the party rests quietly in an out-of-the-way place, and a game sets `hidden` to say the party does. Each wandering-monster check during a hidden rest takes 1 off the chance in 6, after noise and bright light add theirs. On a level with the usual 1 in 6 chance, the session rolls no check during a hidden rest. The field defaults to `False`, so a saved command without it still parses as a plain rest. In town no checks run, so `hidden` changes nothing there. - Each attack routine has a new field, [`AttackRoutine.use`][osrlib.core.monsters.AttackRoutine.use], that gives when the monster uses the routine. Its value is a [`RoutineUse`][osrlib.core.monsters.RoutineUse]. The first routine is `PRIMARY`. A later routine is `ALTERNATIVE` when the OSE SRD lets the monster use it in place of the first in any round, like a thoul's weapon. It's `SITUATIONAL` when the SRD ties it to a situation osrlib doesn't model, like an elephant's trample or a giant's thrown boulder. It's `SPECIAL` when it's a breath, a gaze, or a spell. Every routine in the built-in monster and spell catalogs has its label. A routine with no label is `PRIMARY`, so in a monster template of your own, label each later routine, or the default policy picks it as often as the first. - A policy of your own can choose which attack routine a monster uses, in the new field [`MonsterAction.routine`][osrlib.crawl.battle.MonsterAction.routine]. The field is an index into the template's `attacks`, and `None` uses the first routine. So your policy can have an elephant trample when your game decides it charges. Battle rolls the routine's attacks as melee attacks, and rolls nothing for a `SPECIAL` routine. An index the template doesn't have makes the action impossible, so the session skips it. -- [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] reports a wand, staff, or rod use or a turning that fizzled in a battle round (#145). Its code is `magic.device.fizzled` for a device and `magic.turning.fizzled` for a turning. `actor_id` names the member who declared it, `item_id` is the device's instance id (`None` for a turning), and `reason` is the rejection code of the check that failed. Tell the player the action failed. A device that fizzles keeps its charge. +- [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] reports a wand, staff, or rod use or a turning that fizzled in a battle round (#145). Its code is `magic.device.fizzled` for a device and `magic.turning.fizzled` for a turning. `actor_id` is the member who declared it, `item_id` is the device's instance id (`None` for a turning), and `reason` is the rejection code of the check that failed. Tell the player the action failed. A device that fizzles keeps its charge. ### Changed @@ -30,7 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - A melee attack on a petrified creature hits automatically, as on a paralysed or sleeping one (#166). [`attack_roll`][osrlib.core.combat.attack_roll] reports `combat.attack.auto_hit` and draws no d20, so only the damage is rolled. A missile attack on a petrified creature is still rolled. - [`UseItem`][osrlib.crawl.commands.UseItem] with a potion and a `target_id` that names someone other than the member using it is now refused with `items.use.target_not_allowed`. Before, the session accepted the command and the member in `character_id` drank the potion, so a front end that expected the target to drink got no warning. A `target_id` of `None`, or the id of the member using the potion, drinks the potion as before, and a healing device still takes another member (#174). - A plain [`Rest`][osrlib.crawl.commands.Rest] in a dungeon now checks for wandering monsters at the level's full chance. Before, every rest took 1 off the chance, so a level with the usual 1 in 6 chance never rolled during a rest and resting was always safe. The session still adds 1 for noise and 1 for bright light, and a rest of a night or a day still checks once each turn and stops when monsters come. To rest quietly and take the 1 off, set the new `hidden` field. The `resting` keyword of [`wandering_check`][osrlib.crawl.exploration.wandering_check] still takes 1 off the chance. [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] now sets it from `hidden`, so `advance_turns(resting=True)` alone no longer lowers the chance. It still counts the turns as rest. -- An `attack` in a [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] with no `weapon_id` now uses the wielded weapon that suits the attack (#162). Before, the member struck unarmed, even with a sword in hand. The group's distance when the session accepts the round decides the attack: melee at 5 feet or less, missile beyond. The session takes the first wielded weapon, in the order the member equipped them, that can make that attack. For a missile attack it prefers the first whose long range reaches the group. A real weapon comes before an improvised one, like a torch, so a member who lit a torch and then drew a sword swings the sword. The torch still counts when the member wields nothing else that suits the attack. The session passes over a splash weapon, like a flask of oil, and an item with no weapon stats, like a wand. A member with no suitable weapon still strikes unarmed. The round plays out as if you had named the chosen weapon, and the command log keeps the declaration as you sent it. [`BattleDeclaration.weapon_id`][osrlib.crawl.commands.BattleDeclaration.weapon_id] describes the choice. A declaration that names a weapon works as before. +- An `attack` in a [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] with no `weapon_id` now uses the wielded weapon that suits the attack (#162). Before, the member struck unarmed, even with a sword in hand. The group's distance when the session accepts the round decides the attack: melee at 5 feet or less, missile beyond. The session takes the first wielded weapon, in the order the member equipped them, that can make that attack. For a missile attack it prefers the first whose long range reaches the group. A real weapon comes before an improvised one, like a torch, so a member who lit a torch and then drew a sword swings the sword. The torch still counts when the member wields nothing else that suits the attack. The session passes over a splash weapon, like a flask of oil, and an item with no weapon stats, like a wand. A member with no suitable weapon still strikes unarmed. The round plays out as if you had named the chosen weapon, and the command log records the declaration as you sent it. [`BattleDeclaration.weapon_id`][osrlib.crawl.commands.BattleDeclaration.weapon_id] describes the choice. A declaration that names a weapon works as before. - A wand, staff, or rod use and a turning declared in [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] are checked again in the magic phase, just before they resolve, as a spell cast and a scroll read are (#145). Before, they resolved even when the round had changed something they depend on. For example, a staff of striking struck a group that had already run out of melee reach. Now a declaration that fails the second check fizzles, and the session reports it with a [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent]. A device that fizzles was never used, so it keeps its charge, isn't identified, and doesn't break the user's invisibility. A turning that fizzles rolls nothing. A device use or turning that passes the second check resolves with the same rolls as before. - Some command logs recorded under 2.0.1 don't replay under this release. A log that contains a command this release refuses makes [`replay_game`][osrlib.persistence.replay_game] raise [`ContentValidationError`][osrlib.errors.ContentValidationError]. Two commands that 2.0.1 accepted are refused now, so a log that contains one of them raises: a potion `UseItem` aimed at another member, and a temple service that would do nothing. Other logs replay without an error but play out differently. That happens with *sleep* cast at a drained monster, a lair the party left and came back to, a rest in a dungeon, a rest that a wandering monster interrupted, a trap save with a `modifier`, or a chute trap where every member passed the save. In battle, it also happens with a monster that regenerates (a troll, a thoul, or a vampire), a monster whose attack has an effect (like a ghoul's paralysing claw or a wight's level drain), a basilisk's or a medusa's gaze, a melee attack on a petrified creature, a monster that can choose between attack routines (a spitting cobra, a thoul, a mule, a minotaur, a wererat, or a monster of your own with more than one unlabelled routine), an attack declared with no `weapon_id` by a member who wields a weapon, or a device use (a wand, staff, or rod) or a turning that now fizzles. Each of these changes has an entry in this section or under Fixed. Loading a 2.0.1 save with [`load_game`][osrlib.persistence.load_game] isn't affected, because a load restores the saved state and runs no commands. The loaded game then plays on under this release's rules. diff --git a/tests/test_weapon_default_and_device_recheck.py b/tests/test_weapon_default_and_device_recheck.py index 9bea9d5..31aa3c8 100644 --- a/tests/test_weapon_default_and_device_recheck.py +++ b/tests/test_weapon_default_and_device_recheck.py @@ -30,8 +30,8 @@ `item_id` is the device's instance id, which reveals nothing about an unidentified device. A fizzled turning has no `item_id`, draws nothing, and costs nothing. -Tests of new behavior are marked expected-to-fail for the chunk. The unmarked classes pin behavior that has to -stay as it is, the explicit weapon, bare hands, and a device the re-check passes, and they pass today. +Some classes check behavior that stays as it was: the explicit weapon, bare hands, and a device the re-check +passes. """ from collections.abc import Callable @@ -75,8 +75,6 @@ from osrlib.messages import format_message from osrlib.persistence import replay_game -CHUNK = pytest.mark.xfail(reason="chunk: weapon-and-device", strict=True) - FIGHTER = "character-0001" THIEF = "character-0002" CLERIC = "character-0003" @@ -221,7 +219,6 @@ def is_fizzle(event: Event) -> bool: ] -@CHUNK class TestAnAttackWithNoWeaponUsesTheWieldedWeapon: """`weapon_id=None` attacks with the wielded weapon that suits the attack, chosen when the round is accepted, and the round is the one that names that weapon.""" @@ -516,7 +513,6 @@ def assert_device_fizzle( assert "lost" not in message.lower(), "a device that fizzles loses nothing" -@CHUNK class TestADeviceUseIsJudgedAgainInTheMagicPhase: """A wand, staff, or rod declaration is judged again just before it resolves. One that now fails fizzles with `magic.device.fizzled`, keeps its charge, and costs what a `hold` costs.""" @@ -654,7 +650,6 @@ def skeleton_battle(seed: int) -> GameSession: return session -@CHUNK class TestATurningIsJudgedAgainInTheMagicPhase: """A `turn_undead` declaration is judged again just before it resolves. One that now fails fizzles with `magic.turning.fizzled` and no `item_id`, instead of raising out of the round."""