From 1ca1b1a28efaf1f8acce9629d93241322000d8c2 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sat, 31 Oct 2026 00:49:54 -0700 Subject: [PATCH 1/3] Fill in the encounter view's position, reach, and sight fields The player view now reports each member's advance, each member's distance to each group, who can fight each group hand to hand, the formation width, the party's free round after a surprise, and each member's encounter rate. Each group's front rank comes from the same helper as group_front_rank, so the two always agree. The encounter's own front rank is now the first row toward the nearest group instead of the first rank of the marching order. Each group also reports whether the party's light reaches it and which members can see it. A group is lit when a living member carrying a light stands within that light's radius of it. A member sees a lit group, or a group within the range of their working infravision. Blind members see nothing, and darkness puts out the light and can stop infravision. The session refuses nothing new because of sight. The ring of protection's 5' radius now shields the defender's rank-mates toward the attacking group, the members at the same distance from it, instead of the defender's marching-order rank. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- src/osrlib/crawl/battle.py | 155 ++++++++++++++++++++++++++++++++----- src/osrlib/crawl/views.py | 54 +++++++++++-- 2 files changed, 182 insertions(+), 27 deletions(-) diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index bad496c..db71eaf 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -1326,10 +1326,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 +1345,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 +1769,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. @@ -4099,7 +4205,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 +4329,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 +4396,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 +4654,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 +4683,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 +4714,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/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, ) From aa9f113c19fae919c4cdf21fd30898418537a996 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sat, 31 Oct 2026 00:50:01 -0700 Subject: [PATCH 2/3] Describe the battle round as it now works, and when to read the view after it The battle module's docstring now walks through the round step by step: the judgement of every declaration at the places its moves would reach, the party's movement step, missiles and spells from each member's own place, melee only within reach and in the front rank, and monsters that close and attack in the same round. It also points a battle screen at the encounter view. MemberMovedEvent and GroupMovedEvent report a distance right after the move, before any shift of the party line, so their docstrings now tell a client to narrate the move from the event and to read EncounterView.advance_feet after the round to draw positions. preview_battle_round's docstring now says that a partial plan judges a defensive move as one member's own move, and that the complete plan can turn into a formation move when every declarer makes the same one. The rejection reference for combat.attack.out_of_reach and combat.attack.out_of_range now says the distance is the member's own, after any close the same declaration makes. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- src/osrlib/crawl/battle.py | 42 ++++++++++++++++++++++++++------- src/osrlib/crawl/events.py | 20 ++++++++++------ tools/docs/rejection_codes.json | 4 ++-- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index db71eaf..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: @@ -2069,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. 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/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.", From b7aea67250b0aa83c5237054ec5cee00fc7df1ad Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sat, 31 Oct 2026 00:53:14 -0700 Subject: [PATCH 3/3] Remove the view-and-codes markers The encounter view tests for positions, reach, and sight pass, including the round-by-round battle with the thief's back-stab, the elf's bow, the fighters' close, and the magic-user's spell, so their expected-to-fail markers come off. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- tests/test_battle_player_view.py | 5 ----- 1 file changed, 5 deletions(-) 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.