From 2b3120cfbeeb3e72fdcafb30bead18190bf9cff5 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sat, 31 Oct 2026 01:50:24 -0700 Subject: [PATCH 1/5] Add the contract and acceptance tests for two playtest defects When an attack ignores the defender's shield, because the defender is retreating or the attacker strikes from behind, the attack is made against the armour class the defender would have without the shield. For a magic shield, that takes away its enchantment along with the +1 every shield gives. A cursed shield that makes armour class worse still counts, because the rule negates a shield's bonus and not its penalty. Today a magic shield in that case raises AttributeError, because a MagicItemInstance has no template. When a member's declared melee attack comes up and its group has no living, visible monster left to strike, the attack fizzles with a DeclarationFizzledEvent at battle.melee.fizzled, whose reason is battle.declaration.no_target. The session checks this before the member's position, so it applies whether or not the member closed. A slow attack gets the same check at the end of the round. Today such an attack vanishes with no event. The new tests that fail today are marked xfail for the chunk playtest-fixes. The docstrings and the adaptations register state both rules. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- docs/adaptations.md | 7 +- src/osrlib/core/combat.py | 15 +- src/osrlib/core/events.py | 21 ++- src/osrlib/crawl/battle.py | 36 ++-- src/osrlib/crawl/commands.py | 28 +-- tests/test_ignored_shield.py | 225 +++++++++++++++++++++++ tests/test_melee_no_target.py | 325 ++++++++++++++++++++++++++++++++++ 7 files changed, 618 insertions(+), 39 deletions(-) create mode 100644 tests/test_ignored_shield.py create mode 100644 tests/test_melee_no_target.py diff --git a/docs/adaptations.md b/docs/adaptations.md index 1f6c552..1d8a170 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -232,6 +232,10 @@ Damage resolves in a fixed order: Halving for a successful save-for-half also rounds down. A back-stab adds +4 to hit and doubles damage, both from the thief tag's params, when the caller says the target is unaware and attacked from behind. Locked by `test_combat.py::TestDamagePipeline`. +### An ignored shield loses its bonus and keeps its penalty + +The Other Combat Issues page says of attacking from behind, "AC bonuses from shields are negated", and the Combat page says a retreating combatant's opponent "ignores any AC bonus due to the combatant's shield". When `AttackContext` sets `behind_target` or `defender_retreating`, the attack is made against the armour class the defender would have without the shield. For a magic shield, that takes away the enchantment along with the +1 every shield gives. A cursed shield that makes armour class worse, like the cursed shield -2 or the cursed shield that sets AC 9 [10], gives no bonus to negate, so it still counts. That's osrlib's reading. Locked by `test_ignored_shield.py`. + ### The WIS save modifier applies to magical non-breath saves only The WIS modifier applies to saving throws against magic, except saves against breath attacks. That's how osrlib reads "does not normally include saves against breath attacks". Any further referee discretion is a modifier the caller supplies. Locked by `test_combat.py::TestSavingThrows`. @@ -1109,8 +1113,9 @@ The session judges a melee attack before the round starts, but the round can cha - An attack that closes from beyond 5' to within 5' stays a melee attack wherever the member ends up. When the close falls short, the attack fizzles instead of turning a dagger's stab into a throw the player never declared. - A slow melee attack faces the same check again when it resolves at the end of the round. - Against a group that runs, what decides it is where the member stood when the round was accepted: a member who already stood within 5' of the group still strikes it, wherever it has run since, and a member who was closing on it fizzles. The SRD gives the opponent its attack against a retreating combatant, and osrlib reads only a member already within 5' as that opponent. +- An attack whose group has no living monster the member can see left to strike fizzles too, with the reason `battle.declaration.no_target`. That's the code the session refuses such an attack with at the start of the round. It happens when the party's missiles or spells, or an earlier member's melee, kill every monster in the group. The session checks for a target before it checks the member's position, as it does at the start of the round, so the member gets `no_target` whether or not they closed, and wherever they stand. -Locked by `test_move_and_attack.py::TestTheMeleeStep` and `TestTheMeleeFizzleEvent`. +Locked by `test_move_and_attack.py::TestTheMeleeStep` and `TestTheMeleeFizzleEvent`, and by `test_melee_no_target.py`. #### Light and sight are reported in the view only diff --git a/src/osrlib/core/combat.py b/src/osrlib/core/combat.py index 9d81eca..5411174 100644 --- a/src/osrlib/core/combat.py +++ b/src/osrlib/core/combat.py @@ -379,8 +379,12 @@ class AttackContext(BaseModel): behind_target: bool = False """Whether the attacker strikes from behind. - The defender's shield doesn't count. With `target_unaware`, a thief attacker gets the - back-stab bonus to hit and to damage, and the attack's + The defender's shield doesn't count, as the OSE SRD's "AC bonuses from shields are + negated" says. The attack is made against the armour class the defender would have + without the shield, so a magic shield loses its enchantment along with the +1 that every + shield gives. A cursed shield that makes the defender's armour class worse still counts, + because the rule takes away a shield's bonus and not its penalty. With `target_unaware`, + a thief attacker gets the back-stab bonus to hit and to damage, and the attack's [`AttackRolledEvent`][osrlib.core.events.AttackRolledEvent] has `back_stab` set. """ @@ -395,7 +399,9 @@ class AttackContext(BaseModel): defender_retreating: bool = False """Whether the defender is retreating. - The attacker gets +2 to hit, and the defender's shield doesn't count. A + The attacker gets +2 to hit, and the defender's shield doesn't count, as it doesn't with + `behind_target`. The attack is made against the armour class the defender would have + without the shield, a magic shield's enchantment included, but never a better one. A [`GameSession`][osrlib.crawl.session.GameSession] sets it for you in two cases: - On an attack against a monster group that's fleeing. @@ -1412,7 +1418,8 @@ def attack_roll( - The context's `situational_modifier`. The function adjusts the defender's armour class too. The defender's shield doesn't - count against an attack from behind, or while the defender retreats. The context's + count against an attack from behind, or while the defender retreats, and a magic + shield's enchantment doesn't count either. The context's `defender_ally_ac_bonus`, an armour class bonus that an ally gives the defender, does count. diff --git a/src/osrlib/core/events.py b/src/osrlib/core/events.py index 0d018c6..62c03ec 100644 --- a/src/osrlib/core/events.py +++ b/src/osrlib/core/events.py @@ -1150,13 +1150,15 @@ class DeclarationFizzledEvent(Event): caused the failure. For example, the group a staff of striking was aimed at moved out of melee reach, or the group a wand was aimed at left the battle. - A melee attack fizzles because of where the member stands when the party's melee step comes. The session judges - a melee attack before the round starts, as if every move the round declares had happened. When the attack comes - up, it resolves only if the member stands in the group's front rank within 5' of it. Something earlier in the - round can change that. For example, the monsters win initiative and the group runs before the member's `close`, - or a member the session expected to step back can no longer move. Then the attack fizzles with the code - `battle.melee.fizzled`. The one exception is a group that has started to flee: a member who stood within 5' of - it when the round was accepted still gets the attack. + A melee attack fizzles when the party's melee step comes and the attack has nothing left to strike, or the + member stands in the wrong place. The session judges a melee attack before the round starts, as if every move the + round declares had happened. When the attack comes up, it resolves only if its group still has a living monster + the member can see to strike, and the member stands in the group's front rank within 5' of it. Something earlier + in the round can change either. For example, the party's missiles or spells kill every monster in the group, the + monsters win initiative and the group runs before the member's `close`, or a member the session expected to step + back can no longer move. Then the attack fizzles with the code `battle.melee.fizzled`. A group that has started to + flee is an exception to the position check: a member who stood within 5' of it when the round was accepted still + gets the attack, if the group has a monster left to strike. The event's `actor_id` is the member who declared the action, `item_id` is the device, `group_id` is the group a melee attack was aimed at, and `reason` is the rejection code of the check that failed. @@ -1208,7 +1210,10 @@ class DeclarationFizzledEvent(Event): but not in its front rank when the attack comes up. A melee attack gets this code only when the member is within 5', and `combat.attack.out_of_reach` otherwise. - `battle.declaration.unknown_group`: the group a wand was aimed at left the battle. - - `battle.declaration.no_target`: no living monster is left in the battle for a turning. + - `battle.declaration.no_target`: no living monster is left in the battle for a turning, or no living monster + the member can see is left in the group a melee attack was aimed at, for example because the party's missiles + or spells killed them earlier in the round. A melee attack gets this code wherever the member stands, whether + or not the member closed, because the session checks for a target before it checks the member's position. 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. diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index f95cdca..8ce2bc7 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -140,10 +140,10 @@ - A caster who declared a spell loses it if an attack hits them, or they fail a saving throw, between initiative and their turn to act. -- A declared melee attack that can't resolve because of where the member stands when it comes up, - out of reach or out of the group's front rank, fizzles with a +- A declared melee attack that can't resolve when it comes up fizzles with a [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] at - `battle.melee.fizzled`. + `battle.melee.fizzled`. That happens when its group has no living monster left to strike, or when + the member stands out of reach or out of the group's front rank. - Each monster group checks morale when the OSE SRD calls for it. - When a monster's attack hits, the session applies the effects the attack lists: paralysis, poison, mummy rot, energy drain, petrification, and the spitting cobra's blindness. Each effect @@ -3662,13 +3662,14 @@ def _party_attacks( a melee attack after a close that takes them within 5' (see `_closing_melee`). Their attack stays a melee attack wherever they end up. - A melee attack resolves only if the member then stands in the group's front rank within 5' of it, as - `_melee_rejection` judges it, or if the group runs and the member is in `engaged`, within 5' of it - when the round was accepted (see `_melee_fizzle`). Otherwise the member makes no attack, and a - player-visible `DeclarationFizzledEvent` at `battle.melee.fizzled` says why, with the group's id and - the rejection code as its reason. That happens to a member whose close fell short because the group - ran first, and to a member the pre-phase expected to step into a front rank that someone who couldn't - move still holds. + A melee attack resolves only if the group still has a living, visible monster in its reachable rank to + strike, and the member then stands in the group's front rank within 5' of it, as `_melee_rejection` + judges it, or the group runs and the member is in `engaged`, within 5' of it when the round was + accepted (see `_melee_fizzle`). Otherwise the member makes no attack, and a player-visible + `DeclarationFizzledEvent` at `battle.melee.fizzled` says why, with the group's id and the rejection + code as its reason. That happens to a member whose group the party's missiles or spells killed earlier + in the round, to a member whose close fell short because the group ran first, and to a member the + pre-phase expected to step into a front rank that someone who couldn't move still holds. A slow attack passes these checks here and goes into `slow_attacks` with whether it's a missile attack. At the end of the round, a slow melee attack faces the same checks again where the member then stands. @@ -3710,11 +3711,16 @@ def _melee_fizzle(session, member_id: str, group, engaged: Collection[str]) -> D """Return the fizzle of a declared melee attack that can't resolve where the member stands now, or None. The fizzle is a player-visible `DeclarationFizzledEvent` at `battle.melee.fizzled`, with the group's id - and, as its reason, the rejection code `_melee_rejection` gives for the battle as it stands: - `combat.attack.out_of_reach` or `battle.declaration.not_in_front_rank`. A group that runs is the - exception, whether it failed morale or its living members are all turned or afraid. A member in - `engaged`, who stood within 5' of it when the round was accepted, still strikes it. The call reads - state and takes no draw. + and a reason. The checks come in the order the pre-phase makes them: + + - `battle.declaration.no_target` when the group has no living, visible monster left in its reachable + rank, the same test the pre-phase refuses an attack with. The member gets it wherever they stand. + - Otherwise the rejection code `_melee_rejection` gives for the battle as it stands: + `combat.attack.out_of_reach` or `battle.declaration.not_in_front_rank`. A group that runs is the + exception, whether it failed morale or its living members are all turned or afraid. A member in + `engaged`, who stood within 5' of it when the round was accepted, still strikes it. + + The call reads state and takes no draw. """ if (group.fleeing or _group_all_shaken(session, group)) and member_id in engaged: return None diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 0e20611..f3e3a3e 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2652,16 +2652,22 @@ class BattleDeclaration(BaseModel): gets close but finds no place left in the front rank gets `battle.declaration.not_in_front_rank`. - The monsters can act before the party. A melee attack resolves only if, at the party's melee - step, the member stands in the group's front rank within 5' of it. The one exception is a group - that has started to flee. A member who stood within 5' of it when the round was accepted still - gets the attack, with the bonus against a retreating combatant. Otherwise the member makes no - attack, and the session reports a + The monsters can act before the party, and the party's missiles and spells come before its + melee. A melee attack resolves only if, at the party's melee step, the group still has a living + monster the member can see, and the member stands in the group's front rank within 5' of it. + A group that has started to flee is the one exception to the position check. A member who stood + within 5' of it when the round was accepted still gets the attack, with the bonus against a + retreating combatant. Otherwise the member makes no attack, and the session reports a [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] at - `battle.melee.fizzled`, with the group's id as `group_id`. Its `reason` is - `combat.attack.out_of_reach` when the member is farther than 5' from the group, and - `battle.declaration.not_in_front_rank` when the member is within 5' but not in its front - rank. + `battle.melee.fizzled`, with the group's id as `group_id`. Its `reason` is one of these: + + - `battle.declaration.no_target` when the group has no such monster left, for example + because the party's archers or casters killed them earlier in the round. The session + checks this first, so the member gets it whether or not they closed, and wherever they + stand. + - `combat.attack.out_of_reach` when the member is farther than 5' from the group. + - `battle.declaration.not_in_front_rank` when the member is within 5' but not in its front + rank. `fighting_withdrawal` and `retreat` take a member away from the monsters, with a `move` action. A `fighting_withdrawal` backs away at half the member's encounter rate, facing the monsters, and @@ -2973,8 +2979,8 @@ class ResolveBattleRound(Command): [`SpellDisruptedEvent`][osrlib.core.events.SpellDisruptedEvent], [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] when a device use or a turning fails the check the session makes just before it resolves, - and at `battle.melee.fizzled` when a melee attack can't resolve because of where the - member stands when it comes up, + and at `battle.melee.fizzled` when a melee attack can't resolve when it comes up, + because its group has no monster left to strike or because of where the member stands, [`MagicDispelledEvent`][osrlib.core.events.MagicDispelledEvent], [`UndeadTurnedEvent`][osrlib.core.events.UndeadTurnedEvent], and the effects a spell leaves behind diff --git a/tests/test_ignored_shield.py b/tests/test_ignored_shield.py new file mode 100644 index 0000000..5386286 --- /dev/null +++ b/tests/test_ignored_shield.py @@ -0,0 +1,225 @@ +"""Acceptance tests for the shield an attack ignores (a crash found in playtesting on osrlib 2.2.0). + +The OSE SRD's Other Combat Issues page says of attacking from behind, "AC bonuses from shields are negated." Its +Combat page says a retreating combatant's opponent "ignores any AC bonus due to the combatant's shield". A game's bot +crashed on this rule with seed 100: a monster attacked a retreating member who carried a magic shield, and the kernel +raised `AttributeError` because a `MagicItemInstance` has no `template`. + +The contract these tests check: + +- When an attack ignores the shield, because the context sets `defender_retreating` or `behind_target`, the attack is + made against the armour class the defender would have without the shield. That is the shield's whole part of + `armour_class`. For a mundane shield it's the +1. For a magic shield it's the +1 and the enchantment. +- The rule takes away a shield's bonus and not its penalty. A cursed shield that makes the defender's armour class + worse still counts. +- A defender with no shield, and an attack that doesn't ignore shields, keep the armour class they have today. +""" + +import pytest + +from osrlib.core.abilities import AbilityScore +from osrlib.core.alignment import Alignment +from osrlib.core.character import Character +from osrlib.core.combat import AttackContext, attack_roll +from osrlib.core.events import AttackRolledEvent +from osrlib.core.items import ItemInstance, MagicItemInstance +from osrlib.core.monsters import spawn_monster +from osrlib.core.rng import RngStreams +from osrlib.core.ruleset import Ruleset +from osrlib.crawl.commands import BattleDeclaration, EquipItem, GrantItem, ResolveBattleRound, SpawnMonsters +from osrlib.crawl.events import ItemAcquiredEvent +from osrlib.data import load_classes, load_equipment, load_monsters +from positions_fixtures import HILD, SEEDS, _opened, named, session + +XFAIL = pytest.mark.xfail(reason="chunk: playtest-fixes", strict=True) + +RETREATING = AttackContext(defender_retreating=True) +BEHIND = AttackContext(behind_target=True) +IGNORING = [pytest.param(RETREATING, id="retreating"), pytest.param(BEHIND, id="behind")] + + +class FixedStream: + """A stream stub that yields scripted `randbelow` results.""" + + def __init__(self, values): + self.values = list(values) + + def randbelow(self, n): + return self.values.pop(0) % n + + +def defender(shield: str | None) -> Character: + """A fighter in chain mail with scores of 10, carrying `shield`: a catalog id, or None for no shield. + + `"shield"` is the mundane shield. Any other id is a magic shield from the magic item catalog. + """ + scores = {ability: 10 for ability in AbilityScore} + definition = load_classes().get("fighter") + character = Character( + id="character-0001", + name="Hild", + class_id="fighter", + race=definition.race, + level=2, + xp=definition.row(2).xp, + scores=scores, + alignment=Alignment.LAWFUL, + max_hp=12, + current_hp=12, + ) + equipment = load_equipment() + character.inventory.worn_armour = ItemInstance(template=equipment.get("chainmail")) + if shield == "shield": + character.inventory.shield = ItemInstance(template=equipment.get("shield")) + elif shield is not None: + character.inventory.shield = MagicItemInstance( + instance_id="magic-item-0001", template_id=shield, base_item_id="shield", identified=True + ) + return character + + +def goblin(): + stream = RngStreams(master_seed=3).get("monster_spawn") + return spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=stream) + + +def roll(target, context: AttackContext) -> AttackRolledEvent: + """One goblin attack on `target` in `context`, with a fixed die, and its event.""" + attack = load_monsters().get("goblin").attacks[0].attacks[0] + result = attack_roll(goblin(), target, attack, context=context, ruleset=Ruleset(), stream=FixedStream([10])) + (event,) = result.events + assert isinstance(event, AttackRolledEvent) + return event + + +# ---------------------------------------------------------------------- the shield an attack ignores + +# Chain mail is AC 5, and DEX 10 has no modifier. A shield gives +1 and a magic shield adds its enchantment. +SHIELDED_AC = {"shield": 4, "shield_plus_1": 3, "shield_plus_2": 2} +UNSHIELDED_AC = 5 + + +class TestAnIgnoredShieldCountsForNothing: + """An attack that ignores the shield is made against the armour class the defender would have without it.""" + + @pytest.mark.parametrize("context", IGNORING) + @pytest.mark.parametrize( + "shield", + [ + pytest.param("shield", id="mundane"), + pytest.param("shield_plus_1", id="plus_1", marks=XFAIL), + pytest.param("shield_plus_2", id="plus_2", marks=XFAIL), + ], + ) + def test_the_defender_loses_the_shields_whole_part_of_armour_class(self, shield, context): + target = defender(shield) + assert target.armour_class == SHIELDED_AC[shield], "precondition: armour_class counts the shield" + assert defender(None).armour_class == UNSHIELDED_AC, "precondition: chain mail alone" + + ignored = roll(target, context) + bare = roll(defender(None), context) + + assert ignored.defender_ac == UNSHIELDED_AC + assert ignored.required == bare.required + assert ignored.modifier == bare.modifier + + +class TestACursedShieldKeepsItsPenalty: + """The SRD negates a shield's AC bonus. A cursed shield that worsens armour class has no bonus to negate.""" + + @pytest.mark.parametrize("context", IGNORING) + @pytest.mark.parametrize( + ("shield", "cursed_ac"), + [pytest.param("cursed_shield_minus_2", 6, id="minus_2"), pytest.param("cursed_shield_ac_9", 9, id="ac_9")], + ) + @XFAIL + def test_an_ignored_cursed_shield_still_worsens_armour_class(self, shield, cursed_ac, context): + target = defender(shield) + assert target.armour_class == cursed_ac, "precondition: the curse makes armour class worse than chain mail" + assert cursed_ac > UNSHIELDED_AC + + ignored = roll(target, context) + + assert ignored.defender_ac == cursed_ac + + +class TestWhatTheRuleLeavesAlone: + """Guards: nothing changes for a defender with no shield, or for an attack that doesn't ignore shields.""" + + @pytest.mark.parametrize("context", IGNORING) + def test_a_defender_with_no_shield_keeps_their_armour_class(self, context): + target = defender(None) + + assert roll(target, context).defender_ac == target.armour_class == UNSHIELDED_AC + + @pytest.mark.parametrize("context", IGNORING) + def test_a_monster_keeps_its_armour_class(self, context): + target = goblin() + + assert roll(target, context).defender_ac == target.armour_class + + @pytest.mark.parametrize( + "context", + [ + pytest.param(AttackContext(), id="plain"), + pytest.param(AttackContext(distance_feet=5), id="in_melee"), + pytest.param(AttackContext(target_unaware=True), id="unaware_only"), + ], + ) + @pytest.mark.parametrize("shield", ["shield", "shield_plus_1", "shield_plus_2"]) + def test_an_attack_that_doesnt_ignore_the_shield_counts_it(self, shield, context): + target = defender(shield) + + assert roll(target, context).defender_ac == target.armour_class == SHIELDED_AC[shield] + + +# ---------------------------------------------------------------------- the playtest's battle + + +def _shielded_battle(first_seed: int): + """Hild alone, in plate with a sword and a shield +1 equipped by command, facing two goblins 5' away. + + Returns the first session from `first_seed` on in which neither side was surprised. + """ + for seed in range(first_seed, SEEDS.stop): + game = session(seed, (HILD,)) + hild = named(game, "Hild") + granted = game.execute(GrantItem(character_id=hild, item_id="shield_plus_1")) + assert granted.accepted + (shield_id,) = next(event.item_ids for event in granted.events if isinstance(event, ItemAcquiredEvent)) + assert game.execute(EquipItem(character_id=hild, item_id=shield_id)).accepted + assert isinstance(game.member(hild).inventory.shield, MagicItemInstance), "precondition: a magic shield" + spawn = SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=5) + if _opened(game, game.execute(spawn), surprised=False, engage=True): + return game + raise AssertionError("precondition: no seed opened the battle") + + +@XFAIL +def test_a_monster_attacks_a_retreating_member_with_a_magic_shield(): + """The playtest's crash, end to end: Hild retreats, a goblin strikes, and the round completes.""" + seed = SEEDS.start + while seed < SEEDS.stop: + game = _shielded_battle(seed) + seed = game.master_seed + 1 + hild = named(game, "Hild") + member = game.member(hild) + shielded_ac = member.armour_class + assert shielded_ac == 1, "precondition: plate is AC 3, and the shield +1 gives 2 more" + + result = game.execute( + ResolveBattleRound(declarations=(BattleDeclaration(character_id=hild, action="move", move="retreat"),)) + ) + + assert result.accepted, [rejection.code for rejection in result.rejections] + swings = [ + event + for event in result.events + if isinstance(event, AttackRolledEvent) and event.defender_id == hild and event.defender_ac is not None + ] + if not swings: + continue + assert all(swing.defender_ac == shielded_ac + 2 for swing in swings), "the shield and its +1 both went" + assert member.armour_class == shielded_ac, "the shield still counts outside the retreat" + return + raise AssertionError("precondition: no seed had a goblin attack the retreating member") diff --git a/tests/test_melee_no_target.py b/tests/test_melee_no_target.py new file mode 100644 index 0000000..932eaa2 --- /dev/null +++ b/tests/test_melee_no_target.py @@ -0,0 +1,325 @@ +"""Acceptance tests for a melee attack whose group has no monster left to strike (found in playtesting). + +A game's playtest declared `action="attack"` with `move="close"` on a group. The party's archers and casters killed +every monster in that group earlier in the same round. The members closed, and then their melee attacks vanished +with no event, so the game couldn't tell the player why they didn't swing. + +The contract these tests check: + +- When a member's declared melee attack comes up and its group has no living, visible monster left to strike, the + attack fizzles. The session reports a player-visible `DeclarationFizzledEvent` at `battle.melee.fizzled`, with the + member's id as `actor_id`, the group's id as `group_id`, and `battle.declaration.no_target` as `reason`. That is the + rejection code validation gives a declaration aimed at such a group. +- It applies whether or not the member closed, and wherever the member stands. The session asks it before it asks + about the member's position, in the order validation asks. A slow melee attack that comes up at the end of the round + gets the same check then. +- Each such attack fizzles once. +- A group with a living monster left still takes the attack as before. +- A guard: a group that flees out of the battle before the melee step, from a member who wasn't within 5' of it when + the round was accepted, fizzles that member's attack with `combat.attack.out_of_reach`, as it does today. + +The battles are built by command from seeded sessions. Each test sets the hit points of the goblin it needs killed to +1, so a magic missile kills it for certain, and asserts the kill as a precondition. +""" + +import pytest + +from osrlib.core.effects import Condition, has_condition +from osrlib.core.events import DeclarationFizzledEvent, SpellCastEvent, Visibility +from osrlib.crawl.commands import EquipItem, GrantItem, SpawnMonsters +from osrlib.messages import format_message +from positions_fixtures import ( + AELIN, + HILD, + NIM, + OSRIC, + SEEDS, + TAM, + WALK, + WREN, + _opened, + attacks_by, + declare, + hall, + hold_monsters, + monsters_first, + moved, + named, + play, + position, + session, + strike, +) + +XFAIL = pytest.mark.xfail(reason="chunk: playtest-fixes", strict=True) + +MELEE_FIZZLED = "battle.melee.fizzled" +NO_TARGET = "battle.declaration.no_target" +OUT_OF_REACH = "combat.attack.out_of_reach" + +MISSILE_SPELLS = {"Wren": ("magic_missile", "magic_missile")} + +BORS = ("Bors", "fighter") +"""A fighter the fixtures don't arm, so a test can give him a two-handed sword, a slow weapon.""" + + +def battles( + *, + near: int, + near_count: int = 1, + template_id: str = "goblin", + roster=WALK, + gear: dict[str, tuple[str, ...]] | None = None, + hold: bool = True, +): + """Each battle in the hall against `template_id` monsters `near` feet away and two goblins 60' away, by seed. + + Each battle is at round 0, and neither side is surprised. A seed that opens a different battle is skipped. The + near group's first monster has 1 hit point. The far group keeps the battle going when the near group dies. + `gear` names extra items to grant and equip by command, by member name, before the encounter opens. With `hold`, + every group holds still. + """ + for seed in SEEDS: + game = session(seed, roster, spells=MISSILE_SPELLS, adventure=hall()) + for name, items in (gear or {}).items(): + member_id = named(game, name) + for item_id in items: + assert game.execute(GrantItem(character_id=member_id, item_id=item_id)).accepted + assert game.execute(EquipItem(character_id=member_id, item_id=item_id)).accepted + spawn = SpawnMonsters(template_id=template_id, count_fixed=near_count, distance_feet=near) + if not _opened(game, game.execute(spawn), surprised=False, engage=True): + continue + assert game.execute(SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=60)).accepted + assert len(game.encounter.groups) == 2, "precondition: the far group joined the battle" + assert game.encounter.groups[0].distance_feet == near, "precondition: the near group at its distance" + weak = game.combatant(game.encounter.groups[0].monster_ids[0]) + weak.current_hp = 1 + if hold: + hold_monsters(game) + yield game + + +def two_groups(**battle_args): + """The first battle `battles` builds with `battle_args`.""" + return next(battles(**battle_args)) + + +def near_group(game) -> str: + return game.encounter.groups[0].id + + +def weak_goblin(game) -> str: + return game.encounter.groups[0].monster_ids[0] + + +def dead(game, monster_id: str) -> bool: + return has_condition(game.combatant(monster_id), Condition.DEAD) + + +def magic_missile(game, caster: str): + return declare(caster, "cast", spell_id="magic_missile", spell_mode="missiles", targets=(weak_goblin(game),)) + + +def fizzles(events, actor_id: str) -> list[DeclarationFizzledEvent]: + return [ + event + for event in events + if isinstance(event, DeclarationFizzledEvent) and event.code == MELEE_FIZZLED and event.actor_id == actor_id + ] + + +def cast_by(events, caster_id: str) -> SpellCastEvent: + return next(event for event in events if isinstance(event, SpellCastEvent) and event.caster_id == caster_id) + + +def assert_no_target(game, events, member_id: str) -> DeclarationFizzledEvent: + """Assert the member made no attack and has exactly one no-target fizzle at the near group, and return it.""" + assert attacks_by(events, member_id) == [], "the member swung at a group with no monster left" + found = fizzles(events, member_id) + assert len(found) == 1, f"expected one melee fizzle for {member_id}, got {len(found)}" + (fizzle,) = found + assert (fizzle.reason, fizzle.group_id, fizzle.item_id) == (NO_TARGET, near_group(game), None) + assert fizzle.visibility is Visibility.PLAYER + return fizzle + + +# ---------------------------------------------------------------------- the playtest's case + + +class TestMembersWhoClosedOnAGroupKilledEarlierInTheRound: + @XFAIL + def test_a_caster_kills_the_group_in_the_magic_step(self): + """Hild, Osric, and Nim close 15' and attack the goblin at 20'. Wren's magic missile kills it first.""" + game = two_groups(near=20) + hild, osric, nim, wren = (named(game, name) for name in ("Hild", "Osric", "Nim", "Wren")) + closers = (hild, osric, nim) + + events = play(game, *(strike(game, member, move="close") for member in closers), magic_missile(game, wren)) + + assert dead(game, weak_goblin(game)), "precondition: the magic missile killed the goblin" + assert {move.character_id: move.distance_feet for move in moved(events)} == dict.fromkeys(closers, 5), ( + "precondition: every closer reached 5'" + ) + cast = cast_by(events, wren) + for member_id in closers: + fizzle = assert_no_target(game, events, member_id) + assert position(events, fizzle) > position(events, cast) + + @XFAIL + def test_an_archer_kills_the_group_in_the_missile_step(self): + """Hild and Osric close and attack the goblin at 20'. Aelin's arrow kills it first.""" + for game in battles(near=20): + hild, osric, aelin = (named(game, name) for name in ("Hild", "Osric", "Aelin")) + + events = play( + game, strike(game, hild, move="close"), strike(game, osric, move="close"), strike(game, aelin) + ) + + arrows = attacks_by(events, aelin) + if not (arrows and arrows[0].code == "combat.attack.hit" and dead(game, weak_goblin(game))): + continue + for member_id in (hild, osric): + fizzle = assert_no_target(game, events, member_id) + assert position(events, fizzle) > position(events, arrows[0]) + return + raise AssertionError("precondition: no seed had Aelin's arrow kill the goblin") + + @XFAIL + def test_a_close_that_fell_short_still_reports_no_target(self): + """The goblin runs before Nim's close, so Nim ends out of reach. Wren's missile then kills it. + + The group has no monster left, so the reason is `no_target`, not `out_of_reach`. + """ + for game in battles(near=25, hold=False): + game.encounter.groups[0].fleeing = True + nim, wren = named(game, "Nim"), named(game, "Wren") + + events = play(game, strike(game, nim, move="close"), magic_missile(game, wren)) + if not monsters_first(game, events): + continue + + assert dead(game, weak_goblin(game)), "precondition: the magic missile killed the goblin" + (move,) = [move for move in moved(events) if move.character_id == nim] + assert move.distance_feet > 5, "precondition: the goblin ran before Nim's close" + assert_no_target(game, events, nim) + return + raise AssertionError("precondition: no seed had the monsters act first") + + +# ---------------------------------------------------------------------- a member already within 5' + + +class TestAMemberAlreadyInReach: + @XFAIL + def test_the_group_dies_in_the_magic_step(self): + """Hild stands within 5' of the goblin and attacks without moving. Wren's magic missile kills it first.""" + game = two_groups(near=5) + hild, wren = named(game, "Hild"), named(game, "Wren") + + events = play(game, strike(game, hild), magic_missile(game, wren)) + + assert dead(game, weak_goblin(game)), "precondition: the magic missile killed the goblin" + assert moved(events) == [], "precondition: nobody moved" + fizzle = assert_no_target(game, events, hild) + assert position(events, fizzle) > position(events, cast_by(events, wren)) + + @XFAIL + def test_the_group_dies_to_an_earlier_member_in_the_melee_step(self): + """Hild and Osric both attack the goblin at 5'. Hild acts first in marching order and kills it.""" + for game in battles(near=5): + hild, osric = named(game, "Hild"), named(game, "Osric") + + events = play(game, strike(game, hild), strike(game, osric)) + + swings = attacks_by(events, hild) + if not (swings and swings[0].code == "combat.attack.hit" and dead(game, weak_goblin(game))): + continue + fizzle = assert_no_target(game, events, osric) + assert position(events, fizzle) > position(events, swings[0]) + return + raise AssertionError("precondition: no seed had Hild's sword kill the goblin") + + @XFAIL + def test_a_slow_attack_at_the_end_of_the_round_fizzles_once(self): + """Bors's two-handed sword is slow, so it comes up at the end of the round. Hild kills the goblin first.""" + roster = (HILD, BORS, OSRIC, NIM, AELIN, WREN, TAM) + for game in battles(near=5, roster=roster, gear={"Bors": ("two_handed_sword",)}): + hild, bors = named(game, "Hild"), named(game, "Bors") + + events = play(game, strike(game, hild), strike(game, bors)) + + swings = attacks_by(events, hild) + if not (swings and swings[0].code == "combat.attack.hit" and dead(game, weak_goblin(game))): + continue + fizzle = assert_no_target(game, events, bors) + assert position(events, fizzle) > position(events, swings[0]) + return + raise AssertionError("precondition: no seed had Hild's sword kill the goblin") + + @XFAIL + def test_a_slow_attack_whose_group_died_before_the_melee_step_fizzles_once(self): + """Wren's magic missile kills the goblin in the magic step, before Bors's slow attack is even queued.""" + roster = (HILD, BORS, OSRIC, NIM, AELIN, WREN, TAM) + game = two_groups(near=5, roster=roster, gear={"Bors": ("two_handed_sword",)}) + bors, wren = named(game, "Bors"), named(game, "Wren") + + events = play(game, strike(game, bors), magic_missile(game, wren)) + + assert dead(game, weak_goblin(game)), "precondition: the magic missile killed the goblin" + assert_no_target(game, events, bors) + + +# ---------------------------------------------------------------------- guards + + +class TestGuards: + def test_a_group_with_a_living_monster_still_takes_the_attack(self): + """Wren's missile kills one of two goblins at 20'. Nim closes and strikes the other.""" + game = two_groups(near=20, near_count=2) + nim, wren = named(game, "Nim"), named(game, "Wren") + survivor = game.encounter.groups[0].monster_ids[1] + game.combatant(survivor).current_hp = 100 + + events = play(game, strike(game, nim, move="close"), magic_missile(game, wren)) + + assert dead(game, weak_goblin(game)), "precondition: the magic missile killed one goblin" + (swing,) = attacks_by(events, nim) + assert swing.defender_id == survivor + assert position(events, swing) > position(events, cast_by(events, wren)) + assert fizzles(events, nim) == [] + + def test_a_group_that_flees_the_battle_is_out_of_reach_for_a_member_who_was_closing(self): + """Dire wolves 35' away break and run 150' before Nim's close, out of the battle. Nim's attack is out of reach. + + Nim wasn't within 5' of the group when the round was accepted, and the wolves that fled are alive. This is + today's behaviour, and the no-target rule doesn't change it. + """ + for game in battles(near=35, template_id="dire_wolf", hold=False): + game.encounter.groups[0].fleeing = True + nim = named(game, "Nim") + + events = play(game, strike(game, nim, move="close")) + if not monsters_first(game, events): + continue + + assert game.encounter.groups[0].fled, "precondition: the wolves left the battle before Nim's turn" + assert not dead(game, weak_goblin(game)), "precondition: the wolves fled alive" + assert attacks_by(events, nim) == [] + (fizzle,) = fizzles(events, nim) + assert (fizzle.reason, fizzle.group_id) == (OUT_OF_REACH, near_group(game)) + return + raise AssertionError("precondition: no seed had the monsters act first") + + +class TestTheNoTargetMessage: + @XFAIL + def test_the_message_says_there_was_nothing_left_to_strike(self): + event = DeclarationFizzledEvent( + code=MELEE_FIZZLED, actor_id="character-0001", reason=NO_TARGET, group_id="group-0001" + ) + + line = format_message(event) + + assert "character-0001" in line and "group-0001" in line + assert "can't reach" not in line and "front rank" not in line + assert "no attack" in line From 9bcca01e79eb52c20b597bc116562dad2990c818 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sat, 31 Oct 2026 01:57:32 -0700 Subject: [PATCH 2/5] Ignore a magic shield's whole bonus on an attack that ignores shields An attack on a retreating defender, or an attack from behind, raised AttributeError when the defender carried a magic shield, because the attack roll read the shield's template and a MagicItemInstance has none. The attack roll now reads the shield through a new private helper in items.py, _shield_ac_parts, which Character._armour_parts uses too, so the two can't disagree about what a shield gives. The attack is made against the armour class the defender would have without the shield. A magic shield loses its enchantment along with the +1 every shield gives. A cursed shield that makes armour class worse still counts, because only a positive bonus is taken away. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 4 ++++ src/osrlib/core/character.py | 21 +++++---------------- src/osrlib/core/combat.py | 9 +++++---- src/osrlib/core/items.py | 22 ++++++++++++++++++++++ 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6eaf18..0c60bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - The replay plays out differently when the log contains a `close`, an `attack` that set `move="close"`, which 2.2.0 ignored, a fighting withdrawal or a retreat that one member made alone, or a monster group that closed on the party. A battle plays as before when no member moves and no group closes. - [`RemoveMember`][osrlib.crawl.commands.RemoveMember] without `give_to` now refuses to remove a member who carries an item the adventure depends on, so the party can't lose a quest item by dropping the member who held it. The adventure depends on an item when a gate on a door or transition, a trigger that can still fire, or any clause of a quest that isn't completed names its catalog id. The session refuses with `party.remove.carries_quest_item`, and the rejection's `character` and `item` params give the member and the item. Name a member in `give_to` and the item moves to them, so the removal goes through. +### Fixed + +- An attack that ignores the defender's shield no longer raises `AttributeError` when the shield is magic. The bug was present in 2.2.0. Before, an attack on a retreating member who carried a magic shield, or an attack from behind on such a member, crashed the round. Now the attack is made against the armour class the defender would have without the shield, so a magic shield loses its enchantment along with the +1 every shield gives. A cursed shield that makes armour class worse still counts, because the rule takes away a shield's bonus and not its penalty. [`AttackContext.behind_target`][osrlib.core.combat.AttackContext.behind_target] and [`AttackContext.defender_retreating`][osrlib.core.combat.AttackContext.defender_retreating] describe the rule. + ## [2.2.0] - 2026-10-30 ### Added diff --git a/src/osrlib/core/character.py b/src/osrlib/core/character.py index 9ff732f..10e2fc0 100644 --- a/src/osrlib/core/character.py +++ b/src/osrlib/core/character.py @@ -492,7 +492,7 @@ def _armour_parts(self, *, ascending: bool) -> tuple[int, int]: the +1 an ordinary shield gives. A worn item with an armour class bonus that is always active, like a ring of protection, adds to the bonus too. """ - from osrlib.core.items import ArmourTemplate, MagicItemInstance, magic_item_template + from osrlib.core.items import ArmourTemplate, MagicItemInstance, _shield_ac_parts, magic_item_template from osrlib.data import load_equipment base = 10 if ascending else 9 @@ -509,21 +509,10 @@ def _armour_parts(self, *, ascending: bool) -> tuple[int, int]: elif worn is not None and isinstance(worn.template, ArmourTemplate) and worn.template.ac is not None: printed_ascending = worn.template.ac_ascending base = printed_ascending if ascending and printed_ascending is not None else worn.template.ac - bonus = 0 - shield = self.inventory.shield - if isinstance(shield, MagicItemInstance): - template = magic_item_template(shield) - if template.ac_set is not None and template.ac_set_ascending is not None: - # The cursed shield's AC 9 [10] sets the wearer's base outright. - base = template.ac_set_ascending if ascending else template.ac_set - else: - mundane_shield = load_equipment().get("shield") - mundane_bonus = (getattr(mundane_shield, "ac_bonus", None) or 0) if mundane_shield else 0 - bonus += mundane_bonus + template.ac_bonus - elif ( - shield is not None and isinstance(shield.template, ArmourTemplate) and shield.template.ac_bonus is not None - ): - bonus += shield.template.ac_bonus + # The cursed shield's AC 9 [10] sets the wearer's base outright. + shield_base, bonus = _shield_ac_parts(self.inventory.shield, ascending=ascending) + if shield_base is not None: + base = shield_base for ring in self.inventory.rings: template = magic_item_template(ring) if template.always_active: diff --git a/src/osrlib/core/combat.py b/src/osrlib/core/combat.py index 5411174..5438c70 100644 --- a/src/osrlib/core/combat.py +++ b/src/osrlib/core/combat.py @@ -172,6 +172,7 @@ MissileRanges, WeaponQuality, WeaponTemplate, + _shield_ac_parts, equipped_item_modifiers, magic_item_template, ) @@ -1362,12 +1363,12 @@ def _defender_descending_ac(defender: Any, context: AttackContext, *, missile: b ac = getattr(defender, "armour_class", None) if ac is None: return None - # Shield AC is ignored against a retreating defender and attacks from behind. + # A retreating defender, or one attacked from behind, loses the shield's bonus, a magic + # shield's enchantment included. A cursed shield's penalty has no bonus to lose, so it stays. if context.defender_retreating or context.behind_target: inventory = getattr(defender, "inventory", None) - shield = getattr(inventory, "shield", None) if inventory is not None else None - if shield is not None and shield.template.ac_bonus is not None: - ac += shield.template.ac_bonus + _, shield_bonus = _shield_ac_parts(getattr(inventory, "shield", None), ascending=False) + ac += max(shield_bonus, 0) if context.attacker_large: params = _class_ability_params(defender, "defensive_bonus") if params is not None: diff --git a/src/osrlib/core/items.py b/src/osrlib/core/items.py index 528e039..1531a80 100644 --- a/src/osrlib/core/items.py +++ b/src/osrlib/core/items.py @@ -2254,6 +2254,28 @@ def magic_item_template(instance: MagicItemInstance) -> MagicItemTemplate: return load_magic_items().get(instance.template_id) +def _shield_ac_parts(shield: AnyInstance | None, *, ascending: bool) -> tuple[int | None, int]: + """Return the armour class a shield sets, or None, and the bonus the shield adds to armour class. + + A mundane shield adds its +1. A magic shield adds its enchantment on top of the +1 an ordinary + shield gives, so a cursed shield's negative enchantment can make the sum a penalty. The cursed + shield that prints AC 9 [10] sets the wearer's armour class outright and adds nothing. + `Character._armour_parts` and the attack roll both read a shield here, so they agree on what it + gives. + """ + from osrlib.data import load_equipment + + if isinstance(shield, MagicItemInstance): + template = magic_item_template(shield) + if template.ac_set is not None and template.ac_set_ascending is not None: + return (template.ac_set_ascending if ascending else template.ac_set), 0 + mundane = load_equipment().get("shield") + return None, (getattr(mundane, "ac_bonus", None) or 0) + template.ac_bonus + if shield is not None and isinstance(shield.template, ArmourTemplate) and shield.template.ac_bonus is not None: + return None, shield.template.ac_bonus + return None, 0 + + def equipped_item_modifiers(inventory: Inventory) -> list[ModifierSpec]: """Return the stat modifiers a character's equipped magic items grant. From 3eadaeb249830cee4a6f292412ebbb2bee24e156 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sat, 31 Oct 2026 01:57:39 -0700 Subject: [PATCH 3/5] Fizzle a melee attack whose group has nothing left to strike When a member's declared melee attack comes up and its group has no living monster the member can see, the attack now fizzles with a DeclarationFizzledEvent at battle.melee.fizzled, whose reason is battle.declaration.no_target. Before, the attack vanished with no event. _melee_fizzle checks for a target before it checks the member's position, so the member gets this reason whether or not they closed. A slow attack gets the same check at the end of the round, because it goes through the same function. A member who stood within 5' of a group that ran out of the battle in one move also got no event. The engaged member passed the fizzle check, and then _resolve_party_attack returned nothing because the group had fled. That attack now fizzles with combat.attack.out_of_reach. A new guard test in test_melee_no_target.py covers it. The fizzle message now says the member finds nothing left to strike when the reason is no_target. The golden file phase4_delve.json changes, regenerated with tests/generate_phase4_goldens.py from the same seed. One of its battles has two members whose melee attacks are aimed at a group that is already dead when their turn comes. The only change is the two new fizzle events for those members, and their two message lines. No other golden changes. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 4 +++- src/osrlib/core/events.py | 5 +++-- src/osrlib/crawl/battle.py | 24 +++++++++++++++--------- src/osrlib/crawl/commands.py | 6 ++++-- src/osrlib/messages.py | 9 ++++----- tests/goldens/phase4_delve.json | 20 ++++++++++++++++++++ tests/test_melee_no_target.py | 26 ++++++++++++++++++++++++++ 7 files changed, 75 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c60bf1..ad959be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - [`MemberView.encounter_rate_feet`][osrlib.crawl.views.MemberView.encounter_rate_feet] gives how far a member can move in a round, after encumbrance and *haste*. - [`EncounterGroupView.lit`][osrlib.crawl.views.EncounterGroupView.lit] says whether a light a member carries reaches the group, and [`EncounterGroupView.seen_by`][osrlib.crawl.views.EncounterGroupView.seen_by] lists the members who can see it. The session refuses nothing because of light or sight, so use these fields to decide what your game offers and how it describes the fight. - The new event [`MemberMovedEvent`][osrlib.crawl.events.MemberMovedEvent] (`battle.member.moved`) reports each member who moves by themselves, closing on a group or falling back alone (#209). Its fields are `character_id`, `move`, `advance_feet`, `group_id`, and `distance_feet`. It reports the member's place right after the move, before the party line moves back, so use it to narrate the move and read the player view after the round to draw where everyone stands. -- [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] has a new code, `battle.melee.fizzled`, and a new optional field, `group_id` (#209). The session reports it when a declared melee attack can't resolve because of where the member stands when the party's melee step comes, for example when the monsters win initiative and run before the member's close. Its `reason` is `combat.attack.out_of_reach` or `battle.declaration.not_in_front_rank`, so tell the player why the member didn't swing. The field defaults to `None`. +- [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] has a new code, `battle.melee.fizzled`, and a new optional field, `group_id` (#209). The session reports it when a declared melee attack can't resolve when the party's melee step comes, for example when the monsters win initiative and run before the member's close. Its `reason` is `combat.attack.out_of_reach` or `battle.declaration.not_in_front_rank`, so tell the player why the member didn't swing. The field defaults to `None`. + - The reason is `battle.declaration.no_target` when the group has no living monster the member can see left to strike, for example because the party's missiles or spells killed it earlier in the round. The session checks this before it checks where the member stands, so the member gets it whether or not they closed. A slow attack gets the same check at the end of the round. + - The reason is `combat.attack.out_of_reach` when the group ran out of the battle before the attack came up, even for a member who stood within 5 feet of it when the round was accepted. - Two new refusal codes for a move set on an action that can't take one (#209). [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] refuses a `cast` that sets any `move` with `battle.declaration.cast_cannot_move`, because a caster can't move in the round they cast. It refuses a `turn_undead`, a `use_item`, or a `hold` that sets any `move`, and an `attack` with a `fighting_withdrawal` or a `retreat`, with `battle.declaration.move_not_combinable`. Each has the member's id as its `character` param. An attack that closes, by a member who can't move, gets the existing `battle.declaration.cannot_move`. ### Changed diff --git a/src/osrlib/core/events.py b/src/osrlib/core/events.py index 62c03ec..135e97a 100644 --- a/src/osrlib/core/events.py +++ b/src/osrlib/core/events.py @@ -1158,7 +1158,7 @@ class DeclarationFizzledEvent(Event): monsters win initiative and the group runs before the member's `close`, or a member the session expected to step back can no longer move. Then the attack fizzles with the code `battle.melee.fizzled`. A group that has started to flee is an exception to the position check: a member who stood within 5' of it when the round was accepted still - gets the attack, if the group has a monster left to strike. + gets the attack, if the group has a monster left to strike and hasn't run out of the battle. The event's `actor_id` is the member who declared the action, `item_id` is the device, `group_id` is the group a melee attack was aimed at, and `reason` is the rejection code of the check that failed. @@ -1205,7 +1205,8 @@ class DeclarationFizzledEvent(Event): cause. Some codes you may see: - `combat.attack.out_of_reach`: the group a staff of striking was aimed at moved out of reach, or the member - who declared a melee attack is farther than 5' from its group when the attack comes up. + who declared a melee attack is farther than 5' from its group when the attack comes up. A melee attack gets + it too when its group ran out of the battle, wherever the member stood. - `battle.declaration.not_in_front_rank`: the member who declared a melee attack is within 5' of its group but not in its front rank when the attack comes up. A melee attack gets this code only when the member is within 5', and `combat.attack.out_of_reach` otherwise. diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 8ce2bc7..c2df6cf 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -3664,8 +3664,8 @@ def _party_attacks( A melee attack resolves only if the group still has a living, visible monster in its reachable rank to strike, and the member then stands in the group's front rank within 5' of it, as `_melee_rejection` - judges it, or the group runs and the member is in `engaged`, within 5' of it when the round was - accepted (see `_melee_fizzle`). Otherwise the member makes no attack, and a player-visible + judges it, or the group runs without leaving the battle and the member is in `engaged`, within 5' of + it when the round was accepted (see `_melee_fizzle`). Otherwise the member makes no attack, and a player-visible `DeclarationFizzledEvent` at `battle.melee.fizzled` says why, with the group's id and the rejection code as its reason. That happens to a member whose group the party's missiles or spells killed earlier in the round, to a member whose close fell short because the group ran first, and to a member the @@ -3719,17 +3719,23 @@ def _melee_fizzle(session, member_id: str, group, engaged: Collection[str]) -> D `combat.attack.out_of_reach` or `battle.declaration.not_in_front_rank`. A group that runs is the exception, whether it failed morale or its living members are all turned or afraid. A member in `engaged`, who stood within 5' of it when the round was accepted, still strikes it. + - `combat.attack.out_of_reach` for a group that ran out of the battle, even for a member in `engaged`. + Such a group has left the battle space, so nobody can strike it. The call reads state and takes no draw. """ - if (group.fleeing or _group_all_shaken(session, group)) and member_id in engaged: + if not _monster_pool(session, group): + reason = "battle.declaration.no_target" + elif group.fled: + reason = "combat.attack.out_of_reach" + elif (group.fleeing or _group_all_shaken(session, group)) and member_id in engaged: return None - stopped = _melee_rejection(member_id, group, _placement(session)) - if stopped is None: - return None - return DeclarationFizzledEvent( - code="battle.melee.fizzled", actor_id=member_id, reason=stopped.code, group_id=group.id - ) + else: + stopped = _melee_rejection(member_id, group, _placement(session)) + if stopped is None: + return None + reason = stopped.code + return DeclarationFizzledEvent(code="battle.melee.fizzled", actor_id=member_id, reason=reason, group_id=group.id) def _resolve_party_attack(session, member, declaration, fired, fire_damaged) -> list[Event]: diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index f3e3a3e..fdfafa8 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2657,7 +2657,8 @@ class BattleDeclaration(BaseModel): monster the member can see, and the member stands in the group's front rank within 5' of it. A group that has started to flee is the one exception to the position check. A member who stood within 5' of it when the round was accepted still gets the attack, with the bonus against a - retreating combatant. Otherwise the member makes no attack, and the session reports a + retreating combatant, unless the group runs out of the battle. Otherwise the member makes no + attack, and the session reports a [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] at `battle.melee.fizzled`, with the group's id as `group_id`. Its `reason` is one of these: @@ -2665,7 +2666,8 @@ class BattleDeclaration(BaseModel): because the party's archers or casters killed them earlier in the round. The session checks this first, so the member gets it whether or not they closed, and wherever they stand. - - `combat.attack.out_of_reach` when the member is farther than 5' from the group. + - `combat.attack.out_of_reach` when the member is farther than 5' from the group, or the + group ran out of the battle. - `battle.declaration.not_in_front_rank` when the member is within 5' but not in its front rank. diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index e26a6cd..127350c 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -231,11 +231,10 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: "magic.turning.fizzled": lambda event: f"{event.actor_id}'s attempt to turn undead fizzles.", "battle.melee.fizzled": lambda event: ( f"{event.actor_id} " - + ( - "can't get into the front rank against" - if event.reason == "battle.declaration.not_in_front_rank" - else "can't reach" - ) + + { + "battle.declaration.no_target": "finds nothing left to strike in", + "battle.declaration.not_in_front_rank": "can't get into the front rank against", + }.get(event.reason or "", "can't reach") + f" {event.group_id or 'the group'}, and makes no attack." ), "magic.memory.forgotten": lambda event: f"{event.caster_id} forgets {event.spell_id}.", diff --git a/tests/goldens/phase4_delve.json b/tests/goldens/phase4_delve.json index 3bc99a0..6019d54 100644 --- a/tests/goldens/phase4_delve.json +++ b/tests/goldens/phase4_delve.json @@ -2889,6 +2889,24 @@ "target_id": "monster-0014", "visibility": "referee" }, + { + "actor_id": "character-0001", + "code": "battle.melee.fizzled", + "event_type": "declaration_fizzled", + "group_id": "group-0002", + "item_id": null, + "reason": "battle.declaration.no_target", + "visibility": "player" + }, + { + "actor_id": "character-0002", + "code": "battle.melee.fizzled", + "event_type": "declaration_fizzled", + "group_id": "group-0002", + "item_id": null, + "reason": "battle.declaration.no_target", + "visibility": "player" + }, { "code": "battle.ended.victory", "event_type": "battle_ended", @@ -5486,6 +5504,8 @@ "monster-0014 is dead.", "monster-0014 is killed.", "monster-0014 is at 0/2 hit points.", + "character-0001 finds nothing left to strike in group-0002, and makes no attack.", + "character-0002 finds nothing left to strike in group-0002, and makes no attack.", "The battle is won.", "monster-0007 (trader) is slain.", "monster-0008 (trader) is slain.", diff --git a/tests/test_melee_no_target.py b/tests/test_melee_no_target.py index 932eaa2..038766c 100644 --- a/tests/test_melee_no_target.py +++ b/tests/test_melee_no_target.py @@ -17,6 +17,8 @@ - A group with a living monster left still takes the attack as before. - A guard: a group that flees out of the battle before the melee step, from a member who wasn't within 5' of it when the round was accepted, fizzles that member's attack with `combat.attack.out_of_reach`, as it does today. +- Such a group fizzles the attack of a member who was within 5' of it with `combat.attack.out_of_reach` too. A group + that only runs still takes that member's attack, but a group that left the battle can't. The battles are built by command from seeded sessions. Each test sets the hit points of the goblin it needs killed to 1, so a magic missile kills it for certain, and asserts the kill as a precondition. @@ -310,6 +312,30 @@ def test_a_group_that_flees_the_battle_is_out_of_reach_for_a_member_who_was_clos return raise AssertionError("precondition: no seed had the monsters act first") + def test_a_group_that_flees_the_battle_is_out_of_reach_for_a_member_who_was_engaged(self): + """Dire wolves 5' from Hild break and run 150' before Hild's turn, out of the battle. Hild's attack is out of + reach. + + Hild stood within 5' of the group when the round was accepted, so a group that only ran would still take her + attack. A group that left the battle can't, and the attack fizzles once instead of vanishing. + """ + for game in battles(near=5, template_id="dire_wolf", hold=False): + game.encounter.groups[0].fleeing = True + hild = named(game, "Hild") + + events = play(game, strike(game, hild)) + if not monsters_first(game, events): + continue + + assert game.encounter.groups[0].fled, "precondition: the wolves left the battle before Hild's turn" + assert not dead(game, weak_goblin(game)), "precondition: the wolves fled alive" + assert attacks_by(events, hild) == [] + (fizzle,) = fizzles(events, hild) + assert (fizzle.reason, fizzle.group_id) == (OUT_OF_REACH, near_group(game)) + assert fizzle.visibility is Visibility.PLAYER + return + raise AssertionError("precondition: no seed had the monsters act first") + class TestTheNoTargetMessage: @XFAIL From 376a63ac3b964e3e7dbf97a35bb8703056001ddd Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sat, 31 Oct 2026 01:59:18 -0700 Subject: [PATCH 4/5] Remove the playtest-fixes markers and register the fled-group fizzle The tests for the ignored magic shield and the melee with nothing left to strike pass, so their expected-to-fail markers come off. The register's entry for a group that runs now also says that a group that runs out of the battle leaves nothing to strike, so even a member who stood within 5' of it fizzles with combat.attack.out_of_reach. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- docs/adaptations.md | 2 +- tests/test_ignored_shield.py | 8 ++------ tests/test_melee_no_target.py | 11 ----------- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/docs/adaptations.md b/docs/adaptations.md index 1d8a170..9725c32 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -1112,7 +1112,7 @@ The session judges a melee attack before the round starts, but the round can cha - That happens when a member the round expected to step back can no longer move and still stands in the place. It also happens when the monsters win initiative and the group runs before the member's close. - An attack that closes from beyond 5' to within 5' stays a melee attack wherever the member ends up. When the close falls short, the attack fizzles instead of turning a dagger's stab into a throw the player never declared. - A slow melee attack faces the same check again when it resolves at the end of the round. -- Against a group that runs, what decides it is where the member stood when the round was accepted: a member who already stood within 5' of the group still strikes it, wherever it has run since, and a member who was closing on it fizzles. The SRD gives the opponent its attack against a retreating combatant, and osrlib reads only a member already within 5' as that opponent. +- Against a group that runs, what decides it is where the member stood when the round was accepted: a member who already stood within 5' of the group still strikes it, wherever it has run since, and a member who was closing on it fizzles. A group that runs out of the battle altogether leaves nothing to strike, so even a member who stood within 5' of it fizzles with `combat.attack.out_of_reach`. The SRD gives the opponent its attack against a retreating combatant, and osrlib reads only a member already within 5' as that opponent. - An attack whose group has no living monster the member can see left to strike fizzles too, with the reason `battle.declaration.no_target`. That's the code the session refuses such an attack with at the start of the round. It happens when the party's missiles or spells, or an earlier member's melee, kill every monster in the group. The session checks for a target before it checks the member's position, as it does at the start of the round, so the member gets `no_target` whether or not they closed, and wherever they stand. Locked by `test_move_and_attack.py::TestTheMeleeStep` and `TestTheMeleeFizzleEvent`, and by `test_melee_no_target.py`. diff --git a/tests/test_ignored_shield.py b/tests/test_ignored_shield.py index 5386286..d3f4541 100644 --- a/tests/test_ignored_shield.py +++ b/tests/test_ignored_shield.py @@ -31,8 +31,6 @@ from osrlib.data import load_classes, load_equipment, load_monsters from positions_fixtures import HILD, SEEDS, _opened, named, session -XFAIL = pytest.mark.xfail(reason="chunk: playtest-fixes", strict=True) - RETREATING = AttackContext(defender_retreating=True) BEHIND = AttackContext(behind_target=True) IGNORING = [pytest.param(RETREATING, id="retreating"), pytest.param(BEHIND, id="behind")] @@ -107,8 +105,8 @@ class TestAnIgnoredShieldCountsForNothing: "shield", [ pytest.param("shield", id="mundane"), - pytest.param("shield_plus_1", id="plus_1", marks=XFAIL), - pytest.param("shield_plus_2", id="plus_2", marks=XFAIL), + pytest.param("shield_plus_1", id="plus_1"), + pytest.param("shield_plus_2", id="plus_2"), ], ) def test_the_defender_loses_the_shields_whole_part_of_armour_class(self, shield, context): @@ -132,7 +130,6 @@ class TestACursedShieldKeepsItsPenalty: ("shield", "cursed_ac"), [pytest.param("cursed_shield_minus_2", 6, id="minus_2"), pytest.param("cursed_shield_ac_9", 9, id="ac_9")], ) - @XFAIL def test_an_ignored_cursed_shield_still_worsens_armour_class(self, shield, cursed_ac, context): target = defender(shield) assert target.armour_class == cursed_ac, "precondition: the curse makes armour class worse than chain mail" @@ -195,7 +192,6 @@ def _shielded_battle(first_seed: int): raise AssertionError("precondition: no seed opened the battle") -@XFAIL def test_a_monster_attacks_a_retreating_member_with_a_magic_shield(): """The playtest's crash, end to end: Hild retreats, a goblin strikes, and the round completes.""" seed = SEEDS.start diff --git a/tests/test_melee_no_target.py b/tests/test_melee_no_target.py index 038766c..b29fee7 100644 --- a/tests/test_melee_no_target.py +++ b/tests/test_melee_no_target.py @@ -24,7 +24,6 @@ 1, so a magic missile kills it for certain, and asserts the kill as a precondition. """ -import pytest from osrlib.core.effects import Condition, has_condition from osrlib.core.events import DeclarationFizzledEvent, SpellCastEvent, Visibility @@ -53,8 +52,6 @@ strike, ) -XFAIL = pytest.mark.xfail(reason="chunk: playtest-fixes", strict=True) - MELEE_FIZZLED = "battle.melee.fizzled" NO_TARGET = "battle.declaration.no_target" OUT_OF_REACH = "combat.attack.out_of_reach" @@ -149,7 +146,6 @@ def assert_no_target(game, events, member_id: str) -> DeclarationFizzledEvent: class TestMembersWhoClosedOnAGroupKilledEarlierInTheRound: - @XFAIL def test_a_caster_kills_the_group_in_the_magic_step(self): """Hild, Osric, and Nim close 15' and attack the goblin at 20'. Wren's magic missile kills it first.""" game = two_groups(near=20) @@ -167,7 +163,6 @@ def test_a_caster_kills_the_group_in_the_magic_step(self): fizzle = assert_no_target(game, events, member_id) assert position(events, fizzle) > position(events, cast) - @XFAIL def test_an_archer_kills_the_group_in_the_missile_step(self): """Hild and Osric close and attack the goblin at 20'. Aelin's arrow kills it first.""" for game in battles(near=20): @@ -186,7 +181,6 @@ def test_an_archer_kills_the_group_in_the_missile_step(self): return raise AssertionError("precondition: no seed had Aelin's arrow kill the goblin") - @XFAIL def test_a_close_that_fell_short_still_reports_no_target(self): """The goblin runs before Nim's close, so Nim ends out of reach. Wren's missile then kills it. @@ -212,7 +206,6 @@ def test_a_close_that_fell_short_still_reports_no_target(self): class TestAMemberAlreadyInReach: - @XFAIL def test_the_group_dies_in_the_magic_step(self): """Hild stands within 5' of the goblin and attacks without moving. Wren's magic missile kills it first.""" game = two_groups(near=5) @@ -225,7 +218,6 @@ def test_the_group_dies_in_the_magic_step(self): fizzle = assert_no_target(game, events, hild) assert position(events, fizzle) > position(events, cast_by(events, wren)) - @XFAIL def test_the_group_dies_to_an_earlier_member_in_the_melee_step(self): """Hild and Osric both attack the goblin at 5'. Hild acts first in marching order and kills it.""" for game in battles(near=5): @@ -241,7 +233,6 @@ def test_the_group_dies_to_an_earlier_member_in_the_melee_step(self): return raise AssertionError("precondition: no seed had Hild's sword kill the goblin") - @XFAIL def test_a_slow_attack_at_the_end_of_the_round_fizzles_once(self): """Bors's two-handed sword is slow, so it comes up at the end of the round. Hild kills the goblin first.""" roster = (HILD, BORS, OSRIC, NIM, AELIN, WREN, TAM) @@ -258,7 +249,6 @@ def test_a_slow_attack_at_the_end_of_the_round_fizzles_once(self): return raise AssertionError("precondition: no seed had Hild's sword kill the goblin") - @XFAIL def test_a_slow_attack_whose_group_died_before_the_melee_step_fizzles_once(self): """Wren's magic missile kills the goblin in the magic step, before Bors's slow attack is even queued.""" roster = (HILD, BORS, OSRIC, NIM, AELIN, WREN, TAM) @@ -338,7 +328,6 @@ def test_a_group_that_flees_the_battle_is_out_of_reach_for_a_member_who_was_enga class TestTheNoTargetMessage: - @XFAIL def test_the_message_says_there_was_nothing_left_to_strike(self): event = DeclarationFizzledEvent( code=MELEE_FIZZLED, actor_id="character-0001", reason=NO_TARGET, group_id="group-0001" From b1b5d459ba44f7cb498f6baf47ed16f725f09e2f Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sat, 31 Oct 2026 01:59:28 -0700 Subject: [PATCH 5/5] Format the no-target test file Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- tests/test_melee_no_target.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_melee_no_target.py b/tests/test_melee_no_target.py index b29fee7..06453a6 100644 --- a/tests/test_melee_no_target.py +++ b/tests/test_melee_no_target.py @@ -24,7 +24,6 @@ 1, so a magic missile kills it for certain, and asserts the kill as a precondition. """ - from osrlib.core.effects import Condition, has_condition from osrlib.core.events import DeclarationFizzledEvent, SpellCastEvent, Visibility from osrlib.crawl.commands import EquipItem, GrantItem, SpawnMonsters