diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index bad496c..f95cdca 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -96,11 +96,28 @@ Between them come the events of the rule functions in [`osrlib.core`][osrlib.core]: attacks, damage, saving throws, initiative, and morale. -A round follows the OSE SRD's combat sequence. The party declares, and initiative decides which side -acts first. On the party's turn, the session resolves movement, then missile attacks, then magic, -then melee. On the monsters' turn, it checks each group's morale, then resolves the actions each -group's policy chose, in order. An attack with a weapon that has the `slow` quality comes last, -after both sides have acted. Every rule is a function in +A round follows the OSE SRD's combat sequence: + +1. The party declares. Before anything happens, the session works out where each member's declared + move would put them, and judges every declaration against those places. A member who closes and + attacks in the same declaration is judged at the distance the close leaves them. If any + declaration fails, the session refuses the whole round, and nothing changes. +2. The spells being cast are announced, and NPC adventurers choose their actions. +3. Initiative decides which side acts first. +4. On the party's turn, the session resolves movement, then missile attacks, then magic, then + melee. In the movement step, the members who fall back alone move first and the members who + close move next, each in marching order. A fighting withdrawal or a retreat that every declarer + makes moves the whole formation instead, by moving the groups back. Each missile attack and each + spell is measured from where its member stands after the movement step. A melee attack resolves + only if the member is within 5' of the group and in its front rank when the attack comes up, + or if the group is running and the member was within 5' of it when the round was accepted. +5. On the monsters' turn, the session checks each group's morale, then resolves the actions each + group's policy chose, in order. A group that closes stops within 5' of the nearest member it can + see, and its monsters can attack in the same round. A monster's melee attack resolves only + against a member in its group's front rank within 5' of the group. +6. An attack with a weapon that has the `slow` quality comes last, after both sides have acted. + +Every rule is a function in [`osrlib.core.combat`][osrlib.core.combat] or [`osrlib.core.spells`][osrlib.core.spells], like [`roll_initiative`][osrlib.core.combat.roll_initiative], [`resolve_attack`][osrlib.core.combat.resolve_attack], and @@ -114,7 +131,10 @@ [`BattleDeclaration.move`][osrlib.crawl.commands.BattleDeclaration.move] describes. A group closes until it's within reach of the nearest member it can see, so the members in front keep the monsters off the members behind them. To check a round's declarations before you send them, call -[`preview_battle_round`][osrlib.crawl.battle.preview_battle_round]. +[`preview_battle_round`][osrlib.crawl.battle.preview_battle_round]. To draw the battle and offer +each member their choices, read the player view's +[`EncounterView`][osrlib.crawl.views.EncounterView]: each member's advance, each group's distance +from each member, who can reach each group in melee, and which groups each member can see. During a round, the session also applies these rules without a command for them: @@ -1326,10 +1346,12 @@ def _formation_width(session) -> int | None: def _party_ranks(session) -> list[list]: - """The living members cut into rows of the formation width in marching order, wherever each one stands. + """The living members cut into rows of the formation width in marching order, ignoring where each one stands. - The player view's `front_rank` and the rank-mates of a ring of protection read these rows. Every rule that - measures reach reads the ranks toward each group by distance instead (see `_ranks_toward`). + These are the ranks while every member stands on the party line, as they do outside a battle. They stand in + for the ranks toward a group only where there's no group to measure toward (see `_front_rank_toward_nearest` + and `_ally_protection_bonus`). Every rule that measures reach reads the ranks toward each group by distance + (see `_ranks_toward`). """ width = _formation_width(session) living = session.party.living_members() @@ -1343,29 +1365,42 @@ def _party_front_rank(session) -> list: return ranks[0] if ranks else [] -def _ally_protection_bonus(session, defender) -> int: +def _ally_protection_bonus(session, defender, group=None) -> int: """The Ring of Protection 5' Radius: a rank-mate's ring shields the defender. The battle space has no adjacency finer than the rank, so "allies within 5'" - is the wearer's rank. The wearer's +1 is counted with the rest of its - equipped items, so this bonus comes only from allies' rings, and multiple - rings never stack. + is the defender's rank toward the attacking group: the members in the same + row of the ranks toward it (see `_ranks_toward`), who stand at the same + distance from it. A member who has closed on the group alone has no + rank-mate there, and a member who stays behind isn't shielded by a ring + worn in the front rank. With no group, every member counts as standing on + the party line, so the rows are the marching-order ranks. The wearer's +1 + is counted with the rest of its equipped items, so this bonus comes only + from allies' rings, and multiple rings never stack. Args: session (osrlib.crawl.session.GameSession): The battle's session. defender: The combatant under attack. A combatant in no rank gets no bonus. + group: The group the attack comes from. For an attack with no + attacking group, like a confused member's on a fellow member, pass + the defender's nearest group. None reads the marching-order ranks. Returns: 1 when a living rank-mate wears the radius ring, else 0. """ - for rank in _party_ranks(session): - if defender not in rank: + if group is None: + rows = [[member.id for member in rank] for rank in _party_ranks(session)] + else: + rows = _ranks_toward(group, _placement(session)) + defender_id = getattr(defender, "id", None) + for row in rows: + if defender_id not in row: continue - for ally in rank: - if ally is defender: + for ally_id in row: + if ally_id == defender_id: continue - for ring in ally.inventory.rings: + for ring in session.member(ally_id).inventory.rings: if magic_item_template(ring).params.get("radius_rank"): return 1 return 0 @@ -1754,6 +1789,97 @@ def _melee_rejection(member_id: str, group, placement: _Placement) -> Rejection return None +def _front_rank_toward_nearest(session, placement: _Placement) -> list[str]: + """The first row of the party's ranks toward the nearest group, at any distance, in marching order. + + The nearest group is the one that stands closest to any living member, among the groups that haven't fled and + have a living member. When two are equally near, it's the one that comes first in the encounter's groups. If no + group has a living member, a group that hasn't fled stands in, and then the encounter's first group. With no + group at all, it's the first rank of the marching order. Outside a battle every member stands on the party line, + so the result is the first rank of the marching order there too. The player view's `EncounterView.front_rank` + reports it. The call reads state and takes no draw. + """ + groups = session.encounter.groups + fighting = [group for group in groups if not group.fled and _living_monsters(session, group)] + candidates = fighting or [group for group in groups if not group.fled] or list(groups[:1]) + if not candidates: + return [member.id for member in _party_front_rank(session)] + nearest = min( + candidates, + key=lambda group: min((placement.distance(member_id, group) for member_id in placement.order), default=0), + ) + rows = _ranks_toward(nearest, placement) + return rows[0] if rows else [] + + +_DEFAULT_INFRAVISION_FEET = 60 +"""How far infravision sees when its data gives no range. The Hazards and Challenges page: "Infravision works within a +limited range (60' for monsters, unless specified otherwise in a monster's description)." The dwarf and the elf have +"infravision to 60'".""" + + +def _infravision_feet(session, member) -> int: + """How far the member sees by infravision, in feet, or zero when they have none. + + It's the longest `range_feet` among the member's class ability tagged `infravision` and the `infravision` + effects on them, like the spell's. A source with no range gives `_DEFAULT_INFRAVISION_FEET`. It doesn't ask + whether darkness or blindness stops it. + """ + ranges = [ + int(ability.params.get("range_feet", _DEFAULT_INFRAVISION_FEET)) + for ability in member.definition.abilities + if ability.tag == "infravision" + ] + ranges.extend( + int(effect.definition.params.get("range_feet", _DEFAULT_INFRAVISION_FEET)) + for effect in session.ledger.active_on(member.id, "infravision") + ) + return max(ranges, default=0) + + +def _group_lit(session, group, placement: _Placement) -> bool: + """Whether a light the party carries reaches the group, where `placement` puts the members. + + The group is lit when a living member with a light effect on them, like a burning torch, stands within that + light's radius of the group, measured along the range track. A darkness effect that puts out the party's light, + as `GameSession.party_light` reports it, leaves the group unlit. The call reads state and takes no draw. + """ + lit, _ = session.party_light() + if not lit: + return False + from osrlib.crawl.exploration import _light_radius_feet + from osrlib.crawl.session import LIGHT_EFFECT_KINDS + + for member in session.party.living_members(): + for effect in session.ledger.active_on(member.id): + if effect.definition.kind not in LIGHT_EFFECT_KINDS: + continue + if placement.distance(member.id, group) <= _light_radius_feet(effect.definition.params): + return True + return False + + +def _members_seeing(session, group, placement: _Placement, *, lit: bool) -> list[str]: + """The living members who can see the group, in marching order, where `placement` puts them. + + A member sees a lit group. A member also sees a group within their infravision's range (see + `_infravision_feet`) when no darkness effect stops infravision, as `GameSession.party_light` reports it. A blind + member sees nothing. The call reads state and takes no draw. + """ + _, infravision_works = session.party_light() + seeing = [] + for member in session.party.living_members(): + if has_condition(member, Condition.BLIND): + continue + if lit: + seeing.append(member.id) + continue + reach = _infravision_feet(session, member) if infravision_works else 0 + if reach > 0 and placement.distance(member.id, group) <= reach: + seeing.append(member.id) + return seeing + + def _nearest_group(session, member_id: str): """The group nearest the member among the groups that haven't fled and have a living member. @@ -1963,9 +2089,13 @@ def preview_battle_round(session, declarations: Sequence[BattleDeclaration]) -> entry in the result's `rejections` is empty, `ResolveBattleRound` accepts the same declarations. The projection applies each planned move as the party's movement step would: each member's - `close`, and each `fighting_withdrawal` or `retreat` a member makes alone. A defensive move counts - as a formation move only when `declarations` covers every declarer and all of them make it. The - order of `declarations` doesn't matter. The projection doesn't include anything the monsters do, + `close`, and each `fighting_withdrawal` or `retreat` a member makes alone. A partial plan, one that + leaves out some declarers, judges each `fighting_withdrawal` or `retreat` as that member's own move, + even when every member in the plan makes the same one. The complete plan can turn into a formation + move: when every declarer makes the same defensive move, the whole party moves back as one body, + the groups move back instead of the members, and the preview of the complete plan shows that. So a + member's projected advance can change when the last declaration is added. The order of + `declarations` doesn't matter. The projection doesn't include anything the monsters do, because the session decides that after it rolls initiative. So when the monsters act first, a member's `close` can fall short of where the preview put them. @@ -4099,7 +4229,7 @@ def _confused_party_overrides(session, by_member, fire_damaged) -> list[Event]: context = AttackContext( distance_feet=MELEE_RANGE_FEET, situational_modifier=stance_modifier, - defender_ally_ac_bonus=_ally_protection_bonus(session, target), + defender_ally_ac_bonus=_ally_protection_bonus(session, target, _nearest_group(session, target.id)), ) result = resolve_attack( member, @@ -4223,7 +4353,9 @@ def _monster_block( ) else: events.extend( - _resolve_monster_melee(session, monster, target, retreating=exposed, routine=action.routine) + _resolve_monster_melee( + session, monster, target, group, retreating=exposed, routine=action.routine + ) ) _watch_disruption(events, pending_casters, disrupted, acted) elif action.kind == "npc_shoot": @@ -4288,7 +4420,7 @@ def _resolve_npc_attack(session, npc, target, group, *, missile: bool, retreatin context = AttackContext( distance_feet=_member_distance(session, target.id, group) if missile else MELEE_RANGE_FEET, defender_retreating=retreating, - defender_ally_ac_bonus=_ally_protection_bonus(session, target), + defender_ally_ac_bonus=_ally_protection_bonus(session, target, group), ) result = resolve_attack( npc, @@ -4546,12 +4678,14 @@ def _confused_overrides(session, group, actions, *, retreating: Collection[str] targets = _reachable_targets(session, monster, pool) if targets: target = targets[session.streams.get(COMBAT_STREAM).randbelow(len(targets))] - events.extend(_resolve_monster_melee(session, monster, target, retreating=target.id in retreating)) + events.extend( + _resolve_monster_melee(session, monster, target, group, retreating=target.id in retreating) + ) elif outcome == "attack_own_group": fellows = [other for other in _living_monsters(session, group) if other.id != monster.id] if fellows: target = fellows[session.streams.get(COMBAT_STREAM).randbelow(len(fellows))] - events.extend(_resolve_monster_melee(session, monster, target, retreating=False)) + events.extend(_resolve_monster_melee(session, monster, target, group, retreating=False)) # no_action: the confused creature babbles. return events @@ -4573,11 +4707,14 @@ def _behaviour_outcome(params, total: int) -> str: """ -def _resolve_monster_melee(session, monster, target, *, retreating: bool, routine: int | None = None) -> list[Event]: +def _resolve_monster_melee( + session, monster, target, group, *, retreating: bool, routine: int | None = None +) -> list[Event]: """Resolve one of a monster's attack routines against one target, with what each hit does beyond damage. - `routine` is an index into the template's `attacks`. None resolves the first routine, as a - confused monster's attack does. An index out of range, negative included, resolves nothing, and + `group` is the monster's group, which decides the target's rank-mates for a ring of protection (see + `_ally_protection_bonus`). `routine` is an index into the template's `attacks`. None resolves the first + routine, as a confused monster's attack does. An index out of range, negative included, resolves nothing, and so does a `SPECIAL` routine. An attack in `_UNROLLED_ATTACK_NAMES` takes no attack roll. """ events: list[Event] = [] @@ -4601,7 +4738,7 @@ def _resolve_monster_melee(session, monster, target, *, retreating: bool, routin distance_feet=MELEE_RANGE_FEET, situational_modifier=_gazer_bonus(monster, target), defender_retreating=retreating, - defender_ally_ac_bonus=_ally_protection_bonus(session, target), + defender_ally_ac_bonus=_ally_protection_bonus(session, target, group), ) result = resolve_attack( monster, diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 042ab0e..ee9fe57 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -1182,11 +1182,13 @@ class GroupMovedEvent(Event): closes on a group by themselves doesn't move the group, so that emits a `MemberMovedEvent` and no `GroupMovedEvent`. - Distance decides who can reach whom, so redraw a battle screen's ranks from this - event. A member's distance to this group is the group's `distance_feet` minus the member's - entry in [`EncounterView.advance_feet`][osrlib.crawl.views.EncounterView.advance_feet], and + The event reports the group's distance right after its move. When the party line moves back + later in the round, the distance grows with no event, so use this event to narrate or animate + the move, not to draw where the group stands. A member's distance to this group is the group's + `distance_feet` minus the member's entry in + [`EncounterView.advance_feet`][osrlib.crawl.views.EncounterView.advance_feet], and [`EncounterGroupView.member_distance_feet`][osrlib.crawl.views.EncounterGroupView.member_distance_feet] - lists it for you. + lists it for you in the player view after the round. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.group.moved"}) @@ -1235,10 +1237,12 @@ class MemberMovedEvent(Event): makes, moves the groups instead of the members, so it emits [`GroupMovedEvent`][osrlib.crawl.events.GroupMovedEvent]s and none of these. - Redraw the member's place on a battle screen from this event, or read + The event reports the member's advance right after their move, before the party line moves back, + so it isn't always where the member stands once the round is over. Use it to narrate or animate + the move. To draw where each member stands, read [`EncounterView.advance_feet`][osrlib.crawl.views.EncounterView.advance_feet] and [`EncounterGroupView.member_distance_feet`][osrlib.crawl.views.EncounterGroupView.member_distance_feet] - from the next player view. + from the player view after the round. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.member.moved"}) @@ -1258,7 +1262,9 @@ class MemberMovedEvent(Event): advance_feet: int """The member's advance right after the move, in feet forward of the party line. Zero is the line, and a negative value is behind it. When the party line moves back at the end of the movement step, - the member's advance grows by the same amount as everyone's, and the next player view shows it.""" + every member's advance grows by the same amount, and this value isn't updated. Read + [`EncounterView.advance_feet`][osrlib.crawl.views.EncounterView.advance_feet] after the round for + where the member stands.""" group_id: str """The group `distance_feet` is measured to. For a `close`, it's the group the member closed on. For a `fighting_withdrawal` or a `retreat`, it's the group nearest the member after the diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 4bdeaf4..aa00bb7 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -474,9 +474,12 @@ class EncounterView(BaseModel): The width is the side of the largest square of open cells that includes the party's cell, times 10', divided by 5' for each combatant - ([`FIGHTER_FRONTAGE_FEET`][osrlib.crawl.battle.FIGHTER_FRONTAGE_FEET]). A one-cell corridor gives - 2, and so does a crossroads of one-cell corridors. A room gives twice its shorter side in cells: a - 4 by 4 room gives 8, and a 2 by 8 room gives 4. The ranks toward each group are rows this wide, so + ([`FIGHTER_FRONTAGE_FEET`][osrlib.crawl.battle.FIGHTER_FRONTAGE_FEET]). The square holds only + cells of the space the party stands in, so a door or the edge of a room bounds it. A one-cell + corridor gives 2, and so does a crossroads of one-cell corridors. A room gives twice its shorter + side in cells: a 4 by 4 room gives 8, and a 2 by 8 room gives 4. The width comes from the party's + cell, so it's set in an encounter before a battle starts, and it stays the same for the whole + fight. The ranks toward each group are rows this wide, so a group's [`front_rank`][osrlib.crawl.views.EncounterGroupView.front_rank] never holds more members than this. Compare the two to show how many places are left in a group's front rank.""" party_free_round: bool = False @@ -951,6 +954,7 @@ def build_player_view(session) -> PlayerView: conditions=tuple(active.condition.value for active in member.conditions), inventory=_masked_inventory(member), memorized_spells=tuple(copy.model_dump(mode="json") for copy in member.memorized_spells), + encounter_rate_feet=_encounter_rate_feet(session, member), ) for member in session.party.members ) @@ -1119,13 +1123,32 @@ def _canonical_edge(cell: Position, direction: Direction) -> str: return edge_key(cell, direction) +def _encounter_rate_feet(session, member) -> int: + """The member's encounter rate in feet per round, doubled by *haste*, as a battle moves them.""" + from osrlib.crawl.battle import _encounter_rate, _haste_multiplier + + return _encounter_rate(member, session) * _haste_multiplier(session, member.id, "movement_multiplier") + + def _encounter_view(session) -> EncounterView | None: from osrlib.core.combat import cannot_move - from osrlib.crawl.battle import _able_declarers, _party_front_rank + from osrlib.crawl.battle import ( + _able_declarers, + _formation_width, + _front_rank_toward_nearest, + _group_lit, + _members_seeing, + _placement, + _reachable_front_rank, + _signed_distance, + ) state = session.encounter if state is None: return None + battle = session.battle + placement = _placement(session) + living_ids = [member.id for member in session.party.living_members()] groups = [] for group in state.groups: living = [ @@ -1134,6 +1157,17 @@ def _encounter_view(session) -> EncounterView | None: if not has_condition(session.combatant(monster_id), Condition.DEAD) ] conditions = sorted({active.condition.value for monster in living for active in monster.conditions}) + # Positions, reach, and sight exist only in a battle. Each reads the party's own state and the group's + # distance, which the view already shows, so none of them tells the players anything new about the group. + distances: dict[str, int] = {} + front_rank: tuple[str, ...] = () + lit: bool | None = None + seen_by: tuple[str, ...] = () + if battle is not None: + distances = {member_id: _signed_distance(session, member_id, group) for member_id in living_ids} + front_rank = tuple(_reachable_front_rank(group, placement)) + lit = _group_lit(session, group, placement) + seen_by = tuple(_members_seeing(session, group, placement, lit=lit)) groups.append( EncounterGroupView( id=group.id, @@ -1142,10 +1176,13 @@ def _encounter_view(session) -> EncounterView | None: distance_feet=group.distance_feet, visible_conditions=tuple(conditions), fled=group.fled, + member_distance_feet=distances, + front_rank=front_rank, + lit=lit, + seen_by=seen_by, ) ) declarers = _able_declarers(session) - battle = session.battle fired = battle.fired_last_round if battle is not None and session.ruleset.weapon_reload else () return EncounterView( groups=tuple(groups), @@ -1154,9 +1191,14 @@ def _encounter_view(session) -> EncounterView | None: battle_round=battle.round if battle is not None else None, pursuit_gap_feet=state.pursuit.gap_feet if state.pursuit is not None else None, declarers=tuple(member.id for member in declarers), - front_rank=tuple(member.id for member in _party_front_rank(session)), + front_rank=tuple(_front_rank_toward_nearest(session, placement)), immobile=tuple(member.id for member in declarers if cannot_move(member)), reloading=tuple(fired), + advance_feet=( + {member_id: battle.advance_feet.get(member_id, 0) for member_id in living_ids} if battle is not None else {} + ), + formation_width=_formation_width(session), + party_free_round=battle is not None and battle.monsters_hold_rounds > 0, ) diff --git a/tests/test_battle_player_view.py b/tests/test_battle_player_view.py index 453cce6..1a5cd38 100644 --- a/tests/test_battle_player_view.py +++ b/tests/test_battle_player_view.py @@ -612,8 +612,6 @@ def test_the_fled_field_is_additive(self): # ---------------------------------------------------------------------- issue #209: positions in the player view -VIEW = pytest.mark.xfail(reason="chunk: view-and-codes", strict=True) - MISSILE_SPELLS = {"Wren": ("magic_missile", "magic_missile")} @@ -641,7 +639,6 @@ def _missile(game: GameSession, caster_id: str) -> BattleDeclaration: ) -@VIEW class TestThePositionsInThePlayerView: """A battle screen draws the fight and offers each member's choices from the player view alone.""" @@ -766,7 +763,6 @@ def test_each_member_reports_how_far_they_can_move_in_a_round(self): assert rates[nim] == 60 -@VIEW class TestLightAndSightInThePlayerView: """Hild carries the torch, 30' of light. Aelin the elf sees 60' by infravision. Nobody else sees in the dark.""" @@ -842,7 +838,6 @@ def test_the_new_fields_are_additive(self): # ---------------------------------------------------------------------- the walk-through -@VIEW def test_the_walk_through_battle_from_the_player_view(): """The design's section 8, played as a game's battle screen plays it, from the player view and the dry run. diff --git a/tools/docs/rejection_codes.json b/tools/docs/rejection_codes.json index df6da26..de4edc5 100644 --- a/tools/docs/rejection_codes.json +++ b/tools/docs/rejection_codes.json @@ -21,8 +21,8 @@ "battle.none_active": "The command tries to resolve a battle round, but no battle is currently in progress in the session.", "combat.attack.attacker_blind": "The attacking combatant is blinded and cannot make an attack this round.", "combat.attack.attacker_incapacitated": "The attacking combatant is dead, petrified, paralysed, asleep, or weakened, and therefore cannot make an attack.", - "combat.attack.out_of_range": "The target is farther away than the attacking missile weapon's longest range band.", - "combat.attack.out_of_reach": "The attacker declared a melee attack, or a melee-range magic item effect, against a target or group more than 5 feet away.", + "combat.attack.out_of_range": "The target is farther away than the attacking missile weapon's longest range band. In a battle, the distance is the attacking character's own distance to the group, after any close the same declaration makes.", + "combat.attack.out_of_reach": "The attacker declared a melee attack, or a melee-range magic item effect, against a target or group more than 5 feet away. In a battle, the distance is the attacking character's own distance to the group, after any close the same declaration makes, so a character who can't reach the group this round can declare the close without the attack.", "combat.attack.reload": "The attacker tried to fire a weapon that requires a reload, such as a crossbow, again this round after firing it last round without a round spent reloading in between.", "combat.breath.exhausted": "The monster has already used its breath weapon the maximum number of times allowed per day.", "combat.breath.no_breath_weapon": "The monster attempting to breathe does not have a breath weapon ability at all.",