Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 59 additions & 5 deletions src/osrlib/crawl/battle.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
SaveCategory,
TargetingMode,
alignments_differ,
cannot_move,
incapacitated,
morale_modifier,
morale_triggers,
Expand Down Expand Up @@ -80,6 +81,7 @@
GameOverEvent,
GroupMovedEvent,
MonsterFledEvent,
MonstersLeftBehindEvent,
SpellDeclaredEvent,
)
from osrlib.data import load_classes, load_spells
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -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.

Expand All @@ -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] = []
Expand Down
21 changes: 19 additions & 2 deletions src/osrlib/crawl/encounter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions src/osrlib/crawl/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"LocationEnteredEvent",
"MonsterDefeatedEvent",
"MonsterFledEvent",
"MonstersLeftBehindEvent",
"MonstersSpawnedEvent",
"NpcPartySpawnedEvent",
"PartyMovedEvent",
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -746,6 +766,7 @@ class DiceRolledEvent(Event):
SpellDeclaredEvent,
GroupMovedEvent,
MonsterFledEvent,
MonstersLeftBehindEvent,
MonsterDefeatedEvent,
BattleEndedEvent,
HoardGeneratedEvent,
Expand Down
3 changes: 3 additions & 0 deletions src/osrlib/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
96 changes: 95 additions & 1 deletion tests/test_battle.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,6 +11,7 @@
EngageBattle,
EnterDungeon,
EquipItem,
Evade,
GrantItem,
LightSource,
ReorderParty,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/test_events_kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading