From 6dba0f83aa3934ebddd7cd0969d4dbe6cfd4a2a1 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 30 Jul 2026 07:57:02 -0700 Subject: [PATCH] Helpless sides do not run: morale needs someone awake, flight needs legs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A monster group put entirely to sleep raised the half-incapacitated morale trigger, failed the check, and sprinted off the field unconscious — exiting past 120 feet as "routed", its treasure escaping with it (found in osr-web play: five slept kobolds opened round 2 with "break and flee"). - Morale is a decision: a group whose members are all incapacitated (asleep, paralysed, petrified) makes no morale check. The triggers are left pending, not consumed, so a member who can act again judges them then. - Fleeing is movement: only members who can move run. A broken side with no one able to move stays where it lies, and a mixed side leaves its helpless behind as a new stay-behind group at the break distance (MonstersLeftBehindEvent, battle.group.left_behind), individual treasure bundles staying with the abandoned while the runners carry off the shared group bundle. - Pursuit needs legs: a party retreat or evasion is no longer chased by a group with no mobile living member — nor by a group itself fleeing or all turned — the flight simply succeeds. Six new tests (TestHelplessSidesDoNotRun) each fail against the unfixed engine, the evade case on hell hounds so the party cannot simply outrun them. Claude-Session: https://claude.ai/code/session_01EbAHoQCazPyXqzfEMR56EZ --- src/osrlib/crawl/battle.py | 64 +++++++++++++++++++++-- src/osrlib/crawl/encounter.py | 21 +++++++- src/osrlib/crawl/events.py | 21 ++++++++ src/osrlib/messages.py | 3 ++ tests/test_battle.py | 96 ++++++++++++++++++++++++++++++++++- tests/test_events_kernel.py | 1 + 6 files changed, 198 insertions(+), 8 deletions(-) diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index d949306..bc9b5bd 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -47,6 +47,7 @@ SaveCategory, TargetingMode, alignments_differ, + cannot_move, incapacitated, morale_modifier, morale_triggers, @@ -80,6 +81,7 @@ GameOverEvent, GroupMovedEvent, MonsterFledEvent, + MonstersLeftBehindEvent, SpellDeclaredEvent, ) from osrlib.data import load_classes, load_spells @@ -551,11 +553,23 @@ def _check_ends(session, *, party_retreating: bool) -> list[Event] | None: from osrlib.crawl.encounter import PursuitState session.battle = None + # Only a group still willing and able to run chases: not the broken and + # not the shaken (they are fleeing themselves), and not a group whose + # living members all lie helpless — retreat from those simply succeeds. + pursuers = [ + group + for group in groups + if not group.fled + and not group.surrendered + and not group.fleeing + and not _group_all_shaken(session, group) + and encounter_module._group_can_pursue(session, group) + ] + if not pursuers: + return [BattleEndedEvent(code="battle.ended.fled"), *encounter_module.end_encounter(session, "evaded")] session.mode = SessionMode.ENCOUNTER session.encounter.evading = True - session.encounter.pursuit = PursuitState( - gap_feet=min(group.distance_feet for group in groups if not group.fled and not group.surrendered) - ) + session.encounter.pursuit = PursuitState(gap_feet=min(group.distance_feet for group in pursuers)) return [BattleEndedEvent(code="battle.ended.fled")] return None @@ -1696,6 +1710,11 @@ def _monster_block( if not free_round: events.extend(_group_morale(session, group, fire_damaged)) if group.fleeing or _group_all_shaken(session, group): + if not any(not cannot_move(monster) for monster in _living_monsters(session, group)): + # A broken side that cannot run — slept or webbed mid-flight — + # lies where it is; flight resumes only if someone can move again. + continue + events.extend(_leave_helpless_behind(session, group)) rate = _pursuer_full_rate(session, group) group.distance_feet += rate events.append(GroupMovedEvent(group_id=group.id, distance_feet=group.distance_feet)) @@ -1713,8 +1732,6 @@ def _monster_block( if has_condition(monster, Condition.DEAD) or has_condition(monster, Condition.CONFUSED): continue if action.kind == "close" and not moved: - from osrlib.core.combat import cannot_move - if cannot_move(monster): continue rate = _encounter_rate(monster, session) @@ -1880,6 +1897,37 @@ def _pursuer_full_rate(session, group) -> int: return base.rate_feet +def _leave_helpless_behind(session, group) -> list[Event]: + """Split a routing group's immobile living members into a stay-behind group. + + Fleeing is movement, and a member who cannot move cannot run: the runners keep + the original group — its flags, and the shared group bundle they carry off + ("routed ones flee with theirs") — while the helpless become a fresh + non-fleeing group where the side broke, at the current distance, their + individual treasure bundles still on them. Dead members also stay with the + runners' group record: outcome classification and loot drops key off the + `dead` condition before any group flag, so their bookkeeping is unchanged. + """ + from osrlib.crawl.encounter import EncounterGroup + + left_ids = [monster.id for monster in _living_monsters(session, group) if cannot_move(monster)] + if not left_ids: + return [] + stay_behind = EncounterGroup( + id=session.allocator.allocate("group"), + label=group.label, + monster_ids=left_ids, + distance_feet=group.distance_feet, + ) + for monster_id in left_ids: + bundle = group.member_treasure.pop(monster_id, None) + if bundle is not None: + stay_behind.member_treasure[monster_id] = bundle + group.monster_ids = [monster_id for monster_id in group.monster_ids if monster_id not in set(left_ids)] + session.encounter.groups.append(stay_behind) + return [MonstersLeftBehindEvent(group_id=stay_behind.id, source_group_id=group.id, count=len(left_ids))] + + def _group_morale(session, group, fire_damaged) -> list[Event]: """Morale auto-invoked: the kernel's triggers through the per-battle tracker. @@ -1892,6 +1940,12 @@ def _group_morale(session, group, fire_damaged) -> list[Event]: if score is None or group.fleeing: return [] members = [session.combatant(monster_id) for monster_id in group.monster_ids] + if all(incapacitated(member) for member in members): + # No one on the side is awake to break: a morale check is a decision, and + # a side that is entirely asleep, paralysed, or petrified makes none. The + # triggers stay pending — unconsumed — so a member who can act again + # judges them then. + return [] triggers = morale_triggers(members) acted = state.morale_acted.setdefault(group.id, []) events: list[Event] = [] diff --git a/src/osrlib/crawl/encounter.py b/src/osrlib/crawl/encounter.py index 6cfd8b7..3fa90b8 100644 --- a/src/osrlib/crawl/encounter.py +++ b/src/osrlib/crawl/encounter.py @@ -24,7 +24,7 @@ from pydantic import BaseModel, ConfigDict, Field -from osrlib.core.combat import roll_reaction +from osrlib.core.combat import cannot_move, roll_reaction from osrlib.core.effects import Condition, has_condition from osrlib.core.events import Event from osrlib.core.items import Coins @@ -391,7 +391,11 @@ def _handle_evade(session, command: Evade) -> tuple[list[Rejection], list[Event] ): events.append(ItemsDroppedEvent(character_id=member.id, item_ids=("rations",))) - pursuers = [group for group in state.groups if not group.fled and not group.surrendered] + pursuers = [ + group + for group in state.groups + if not group.fled and not group.surrendered and _group_can_pursue(session, group) + ] pursues = state.stance in (ReactionResult.ATTACKS.value, ReactionResult.HOSTILE.value) and pursuers if not pursues or _party_run_rate(session) > _pursuer_rate(session, pursuers): events.append(EvasionEvent(code="encounter.evasion.succeeded")) @@ -504,6 +508,19 @@ def _party_run_rate(session) -> int: return exploration.exploration_rate(session) +def _group_can_pursue(session, group: EncounterGroup) -> bool: + """Whether any of the group's living members can actually give chase. + + Pursuit is running: a group whose living members are all asleep, paralysed, + petrified, or webbed in place has nobody able to follow, and the party's + flight from it simply succeeds. + """ + return any( + not has_condition(combatant, Condition.DEAD) and not cannot_move(combatant) + for combatant in (session.combatant(monster_id) for monster_id in group.monster_ids) + ) + + def _pursuer_rate(session, groups) -> int: """The slowest pursuing group's base ground mode, full rate per round. diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index ed4f08b..641a923 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -54,6 +54,7 @@ "LocationEnteredEvent", "MonsterDefeatedEvent", "MonsterFledEvent", + "MonstersLeftBehindEvent", "MonstersSpawnedEvent", "NpcPartySpawnedEvent", "PartyMovedEvent", @@ -478,6 +479,25 @@ class MonsterFledEvent(Event): group_id: str +class MonstersLeftBehindEvent(Event): + """A routing group left its helpless members where they lie. + + Fleeing is movement, and a member who cannot move (asleep, paralysed, held by a + *web*) cannot run: the runners split off and keep fleeing under the original + group id while the helpless stay behind as the new group `group_id`, at the + distance the side broke from. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.group.left_behind"}) + + event_type: Literal["monsters_left_behind"] = "monsters_left_behind" + code: str = "battle.group.left_behind" + visibility: Visibility = Visibility.PLAYER + group_id: str + source_group_id: str + count: int + + class MonsterDefeatedEvent(Event): """One monster defeated — feeds the adventure's XP award. @@ -746,6 +766,7 @@ class DiceRolledEvent(Event): SpellDeclaredEvent, GroupMovedEvent, MonsterFledEvent, + MonstersLeftBehindEvent, MonsterDefeatedEvent, BattleEndedEvent, HoardGeneratedEvent, diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index d76a42c..b82a30c 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -285,6 +285,9 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: "battle.group.moved": lambda event: f"{event.group_id} is now {event.distance_feet}' away.", "battle.side.fled": lambda event: f"{event.group_id} flees the battle!", "battle.side.surrendered": lambda event: f"{event.group_id} surrenders.", + "battle.group.left_behind": lambda event: ( + f"{event.source_group_id} leaves {event.count} helpless behind as {event.group_id}." + ), "battle.monster.defeated": lambda event: f"{event.monster_id} ({event.template_id}) is {event.outcome}.", "battle.ended.victory": lambda event: "The battle is won.", "battle.ended.fled": lambda event: "The party flees the battle.", diff --git a/tests/test_battle.py b/tests/test_battle.py index 26d2db4..f85a776 100644 --- a/tests/test_battle.py +++ b/tests/test_battle.py @@ -1,7 +1,7 @@ """Battle machine tests: rounds, disruption, effect consumption, footprints, morale.""" from crawl_fixtures import build_adventure, build_party -from osrlib.core.effects import Condition, EffectDefinition, ModifierSpec, has_condition +from osrlib.core.effects import ActiveCondition, Condition, EffectDefinition, ModifierSpec, has_condition from osrlib.core.ruleset import Ruleset from osrlib.core.tables import ReactionResult from osrlib.crawl import battle as battle_module @@ -11,6 +11,7 @@ EngageBattle, EnterDungeon, EquipItem, + Evade, GrantItem, LightSource, ReorderParty, @@ -532,6 +533,99 @@ def test_victory_posts_the_defeat_ledger(self): assert all(record.xp == 5 for record in session.defeated_monsters if record.template_id == "goblin") +def sleep_monsters(session, monster_ids): + for monster_id in monster_ids: + session.monsters[monster_id].conditions = ( + ActiveCondition(condition=Condition.ASLEEP, effect_id="effect-9999"), + ) + + +class TestHelplessSidesDoNotRun: + """A side with no one awake makes no morale decision and cannot flee (issue: slept groups broke and ran).""" + + def test_all_asleep_group_neither_checks_morale_nor_flees(self): + session = battle_session(count=2, distance=20, seed=9) + group = session.encounter.groups[0] + sleep_monsters(session, group.monster_ids) + result = session.execute(ResolveBattleRound(declarations=hold_all(session))) + assert result.accepted + codes = [getattr(event, "code", "") for event in result.events] + assert not any(code.startswith("combat.morale.") for code in codes) + assert "battle.side.fled" not in codes + assert "battle.group.moved" not in codes + assert group.fleeing is False + assert group.distance_feet == 20 + + def test_pending_trigger_is_judged_once_someone_wakes(self): + session = battle_session(count=2, distance=20, seed=9) + group = session.encounter.groups[0] + sleep_monsters(session, group.monster_ids) + result = session.execute(ResolveBattleRound(declarations=hold_all(session))) + assert not any(getattr(event, "code", "").startswith("combat.morale.") for event in result.events) + # One goblin shakes the spell: half the side is still down, and the woken + # goblin now judges the half-incapacitated trigger the gate left pending. + session.monsters[group.monster_ids[0]].conditions = () + result = session.execute(ResolveBattleRound(declarations=hold_all(session))) + assert result.accepted + assert any(getattr(event, "code", "").startswith("combat.morale.") for event in result.events) + + def test_fleeing_group_leaves_its_sleepers_behind(self): + session = battle_session(count=3, distance=20, seed=9) + group = session.encounter.groups[0] + group.fleeing = True + sleeper_id = group.monster_ids[0] + sleep_monsters(session, [sleeper_id]) + result = session.execute(ResolveBattleRound(declarations=hold_all(session))) + assert result.accepted + left_behind = next(event for event in result.events if event.code == "battle.group.left_behind") + assert left_behind.source_group_id == group.id + assert left_behind.count == 1 + stay_behind = next(entry for entry in session.encounter.groups if entry.id == left_behind.group_id) + assert stay_behind.monster_ids == [sleeper_id] + assert stay_behind.distance_feet == 20 # where the side broke + assert stay_behind.fleeing is False + assert sleeper_id not in group.monster_ids + assert group.distance_feet > 20 # the runners kept running + + def test_fleeing_group_fully_asleep_stops_where_it_is(self): + session = battle_session(count=2, distance=20, seed=9) + group = session.encounter.groups[0] + group.fleeing = True + sleep_monsters(session, group.monster_ids) + result = session.execute(ResolveBattleRound(declarations=hold_all(session))) + assert result.accepted + codes = [getattr(event, "code", "") for event in result.events] + assert "battle.group.moved" not in codes + assert "battle.group.left_behind" not in codes + assert group.distance_feet == 20 + assert session.mode.value == "battle" # still at the party's mercy, not escaped + + def test_retreat_from_helpless_monsters_escapes_without_pursuit(self): + session = battle_session(count=2, distance=40, seed=9) + sleep_monsters(session, session.encounter.groups[0].monster_ids) + declarations = tuple( + BattleDeclaration(character_id=member.id, action="move", move="retreat") + for member in session.party.living_members() + ) + result = session.execute(ResolveBattleRound(declarations=declarations)) + assert result.accepted + codes = [getattr(event, "code", "") for event in result.events] + assert "battle.ended.fled" in codes + assert "encounter.ended" in codes + assert session.encounter is None + assert session.mode.value == "exploring" + + def test_evading_helpless_monsters_succeeds_outright(self): + # Hell hounds outrun the party, so only the sleep keeps this from a pursuit. + session = battle_session(template_id="hellhound_3", count=2, distance=40, seed=9, engage=False) + sleep_monsters(session, session.encounter.groups[0].monster_ids) + result = session.execute(Evade()) + assert result.accepted + codes = [getattr(event, "code", "") for event in result.events] + assert "encounter.evasion.succeeded" in codes + assert session.encounter is None + + class TestDefaultPolicy: def test_policy_draws_only_from_the_monster_action_stream(self): from osrlib.crawl.session import MONSTER_ACTION_STREAM diff --git a/tests/test_events_kernel.py b/tests/test_events_kernel.py index 5c9ea18..3de98e4 100644 --- a/tests/test_events_kernel.py +++ b/tests/test_events_kernel.py @@ -112,6 +112,7 @@ def sample_event(event_class, code): "SpellDeclaredEvent": dict(caster_id="pc-1", spell_id="sleep"), "GroupMovedEvent": dict(group_id="group-1", distance_feet=30), "MonsterFledEvent": dict(group_id="group-1"), + "MonstersLeftBehindEvent": dict(group_id="group-2", source_group_id="group-1", count=2), "MonsterDefeatedEvent": dict(monster_id="monster-0001", template_id="goblin", outcome="slain", xp=5), "BattleEndedEvent": dict(), "FlagSetEvent": dict(key="portcullis_open", value=True),