From 4ca402330addc0bc9922907c29caffee1d91072c Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 14:04:10 -0700 Subject: [PATCH 1/6] Contract: a member withdraws alone by naming a replacement (chunk single-withdrawal) Issue #171. A fighting withdrawal or a retreat that every declarer makes still moves the formation. One that not every declarer makes becomes the declaring member's own. A front-rank member trades places in the marching order with the back-rank member named in the new BattleDeclaration.replacement_id, and the new MemberWithdrewEvent reports the swap. The adaptations register and the spec state the rule, with the SRD text behind each decision. The event is registered with a message template for each of its two codes. The acceptance tests in tests/test_single_withdrawal.py are marked expected-to-fail. Three tests in test_battle.py pinned formation_split for rounds the contract now refuses with no_replacement and not_in_front_rank. They are rewritten to the new codes and marked the same way. The unmarked guards at the end of the new file pin the formation moves as they are today. The new field defaults to None, so SCHEMA_VERSION stays 4. I regenerated phase5_milestone.json with its generator, because every logged battle declaration now serializes the field. Its only change is 56 added "replacement_id": null lines. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- docs/adaptations.md | 10 +- docs/spec.md | 2 +- src/osrlib/crawl/commands.py | 59 ++- src/osrlib/crawl/events.py | 37 ++ src/osrlib/messages.py | 6 + tests/goldens/phase5_milestone.json | 56 ++ tests/test_battle.py | 53 +- tests/test_events_kernel.py | 1 + tests/test_single_withdrawal.py | 778 ++++++++++++++++++++++++++++ 9 files changed, 955 insertions(+), 47 deletions(-) create mode 100644 tests/test_single_withdrawal.py diff --git a/docs/adaptations.md b/docs/adaptations.md index db2ff5f..7554d5e 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -366,11 +366,17 @@ Pursuit compares the party's slowest running rate (full feet per round) against Battle declarations arrive as one command per round with one declaration per living, able member, rejected whole on any invalid declaration (partial acceptance would complicate replay). Formation width is the frontage the space around the party offers under `formation_width_limit`: the widest square of unbroken floor that includes the party's cell, at five feet of frontage each. RAW's "2–3 characters in a 10' passage" is pinned to its conservative end and then applied to whatever space the party stands in, so two fit in a ten-foot passage however long it runs, and in a room the rank is as wide as the room's shorter side. Ranks recompute from the living marching order at round start and melee reaches the front rank only, both directions, with no firing-into-melee penalty (none exists in the SRD). -The party moves as a formation and individual members cannot leave it (the Bard's Tale convention): all-retreat moves off at the full encounter rate (Combat.md's "full encounter movement rate", with the running pursuit beginning when the battle converts at round end), all-fighting-withdrawal backs off at half encounter rate, and the first `close` declaration advances the formation on its named group, stopping at 5'. A `fighting_withdrawal` or a `retreat` that not every declarer chose is therefore not a legal declaration for that member: the round is refused whole with `battle.declaration.formation_split`, one rejection per defensive move the round split on, naming the declarers who chose it and the declarers who did not. `close` is not a defensive move and needs no agreement: a lone `close` advances the formation, and a `close` declared beside a `fighting_withdrawal` or a `retreat` counts among the others that split the round, so that round is refused and nothing advances. A round whose only declarer declares a defensive move is legal, because everyone agreed. A fighting withdrawal here is a pure move and pinned as one: RAW gives the withdrawing combatant the backward move and takes nothing away, so the attack stays with them, but a member declares one thing per round and the whole formation moves together, so a round the party withdraws in is a round it does not attack in. The SRD's attack-while-withdrawing half is not represented. The move set is the SRD's two defensive moves and `close`. Schema version 4 removed a fourth value, `withdraw`, and its lossless migration clears `withdraw` off a logged declaration and leaves a member who declared it holding, as that round played out. +The party moves as a formation when every declarer agrees (the Bard's Tale convention): all-retreat moves off at the full encounter rate (Combat.md's "full encounter movement rate", with the running pursuit beginning when the battle converts at round end), all-fighting-withdrawal backs off at half encounter rate, and the first `close` declaration advances the formation on its named group, stopping at 5'. A round whose only declarer declares a defensive move is a formation move, because everyone agreed. `close` needs no agreement, so a lone `close` advances the formation. The move set is the SRD's two defensive moves and `close`. Schema version 4 removed a fourth value, `withdraw`, and its lossless migration clears `withdraw` off a logged declaration and leaves a member who declared it holding, as that round played out. + +A `fighting_withdrawal` or a `retreat` that not every declarer makes is the declaring member's own, as Combat.md describes both moves: "The combatant moves backwards at up to half their encounter movement rate" and "The combatant turns and flees from melee". It changes the ranks and leaves the range track alone. The member steps back out of the front rank and trades places in the marching order with the back-rank member the declaration names in `replacement_id`. Both moves need that replacement, because a rank has no empty places: the ranks are the living marching order cut into rows of the formation width, so a member leaves the front rank only by trading places with someone behind it. For a fighting withdrawal this is where the SRD's "There must be clear space behind the combatant" lands. The marching order stays changed after the battle, until a `ReorderParty` outside an encounter sets it again. A withdrawal alone is the only way the order changes in battle, and `ReorderParty` stays refused there. Only a front-rank member may withdraw alone. A back-rank member's defensive move is refused with `battle.declaration.not_in_front_rank`, a member who names no replacement with `battle.declaration.no_replacement`, and a member whose named replacement can't step up with `battle.declaration.replacement_unavailable`. The replacement must be a living, able member behind the front rank who can move, who hasn't declared a `cast` (Combat.md: "The spell caster cannot move and cast a spell in the same round") or a defensive move of their own, and whom no withdrawing member earlier in marching order named. Each rank stands 5' behind the one in front of it, so a replacement k ranks back is k × 5' away, and both members must cover that distance. The withdrawing member covers it at up to half their encounter rate for a fighting withdrawal or their full encounter rate for a retreat. The replacement covers it at up to their full encounter rate, because outside melee "Combatants can move up to their encounter movement rate each round". Each member's own *haste* doubles that member's limit. A swap either member can't cover is refused with `battle.declaration.replacement_too_far`. Each withdrawing member gets at most one of these four rejections, the first that applies in the order given here. With `formation_width_limit` off every living member stands in the one rank, so no member can withdraw alone. A withdrawal alone needs no group in melee, and it can share a round with a `close`. + +The swaps resolve in the party's movement step, in the marching order of the withdrawing members and before the formation's `close`, and each reports a `MemberWithdrewEvent` (`battle.member.withdrew` or `battle.member.retreated`). A side that wins initiative over the party therefore strikes at the ranks as they stood when the round was declared. A swap happens only if, when the movement step comes, the withdrawing member still stands in the front rank, the replacement still stands behind it, and both can still move. Otherwise the two stay where they are and nothing reports the swap. From the movement step on, the replacement stands in the front rank, where monsters acting later can strike them. The replacement may declare a melee attack the round they step up, judged against the ranks the round's swaps produce, because "Movement and attacking may be combined in the same round". That attack resolves only if the swap happened. A melee attack the monster side chose against a member who has since withdrawn is skipped as no longer possible. A retreat's penalties fall on the retreating member alone and last the whole round, whichever side acts first: "the opponent gains a +2 bonus to all attacks against the retreating combatant and ignores any AC bonus due to the combatant's shield". A retreat alone doesn't end the battle. + +A member who makes a fighting withdrawal or a retreat makes no attack that round, alone or with the formation, because a member declares one thing per round and the move is that thing. For a retreat that is RAW: "the retreating combatant may not attack". A fighting withdrawal takes nothing away in RAW, and "Movement and attacking may be combined in the same round", but the round sequence resolves movement before missile attacks and melee. So by the melee step a member who withdrew alone stands behind the front rank, where melee doesn't reach, and a formation that withdrew stands out of melee range. RAW would still allow a missile attack after the move. That half is not represented, and is pinned as such: a withdrawing member's declaration that also names a weapon or a group makes no attack. Locked by `test_single_withdrawal.py`. A party melee attack hits the first living, visible monster of the group's reachable rank (deterministic, no draw), and monsters pick uniformly on the monster-action stream. Under individual initiative the machine still resolves side blocks, ordered by the best individual total (the SRD's phase sequence is per side). Turn undead resolves in the magic phase, needs no declaration posting, and cannot be disrupted, because it is a class ability rather than a spell. The machine detects disruption per the RAW trigger (a declared caster successfully attacked or failing a save before acting) and releases concentration on any other declared action. A turning is one of those actions, so a cleric who declares one stops concentrating, even when confusion stops the turning before it resolves. A caster maintains one concentration spell at a time: casting a second ends the first, and a spell that needs no concentration leaves it alone (#167). Locked by `test_rules_calls.py::TestTurningEndsConcentration` and `test_rules_calls.py::TestOneConcentrationSpellAtATime`. -A declaration is judged twice: once when the round is accepted, and again in the magic phase immediately before it resolves, with every check it passed the first time, because the phases between the two can change what those checks read. Failing any one of them fizzles the declaration, whatever the round changed to cause the failure. Two cases that occur are an ally's *silence 15' radius* anchoring on the party's cell earlier in the same magic phase, which refuses a cast with `magic.cast.silenced_area`, and the party's last torch going out as its bearer dies in the missile phase, which refuses a scroll read with `exploration.action.requires_light`. A cast or scroll read the re-check refuses is not cast and never raises out of the round: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with `magic.cast.fizzled` on `SpellDisruptedEvent`, whose `reason` field contains the first rejection code the re-check produced. A caster the round already disrupted reports `magic.cast.disrupted` instead, because that check runs first. Each battle round advances the clock one round through the ledger. Locked by `test_battle.py`, the split refusal by `test_battle.py::TestAFormationMoveNeedsEveryDeclarer`, and the re-check by `test_battle.py::TestTheMagicPhaseRechecksADeclaration`. +A declaration is judged twice: once when the round is accepted, and again in the magic phase immediately before it resolves, with every check it passed the first time, because the phases between the two can change what those checks read. Failing any one of them fizzles the declaration, whatever the round changed to cause the failure. Two cases that occur are an ally's *silence 15' radius* anchoring on the party's cell earlier in the same magic phase, which refuses a cast with `magic.cast.silenced_area`, and the party's last torch going out as its bearer dies in the missile phase, which refuses a scroll read with `exploration.action.requires_light`. A cast or scroll read the re-check refuses is not cast and never raises out of the round: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with `magic.cast.fizzled` on `SpellDisruptedEvent`, whose `reason` field contains the first rejection code the re-check produced. A caster the round already disrupted reports `magic.cast.disrupted` instead, because that check runs first. Each battle round advances the clock one round through the ledger. Locked by `test_battle.py`, the formation moves by `test_battle.py::TestAFormationMoveNeedsEveryDeclarer`, a member's own withdrawal by `test_single_withdrawal.py`, and the re-check by `test_battle.py::TestTheMagicPhaseRechecksADeclaration`. ### Spell effect consumption in battle diff --git a/docs/spec.md b/docs/spec.md index 994b1f7..6502377 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -303,7 +303,7 @@ The crawl framework implements the dungeon adventuring procedures: - Area-of-effect resolution against that combat space: geometric shapes map deterministically to groups and party ranks (how many of a group a 20'-radius *fire ball* catches, which ranks a breath weapon reaches), including friendly fire when an area overlaps a melee — a documented adaptation with `Ruleset` knobs - Monster actions resolve through a pluggable action policy: the default follows scripted patterns where the SRD defines them (a dragon opens with breath, then breath or melee with equal chance, three breaths per day) and otherwise picks attacks by range; games and LLM referees can substitute a policy per encounter side - Evasion and pursuit: evasion only before combat begins, speed comparison, pursuit in rounds at running speed, dropped-treasure and food distractions (3-in-6 for intelligent monsters), running exhaustion after 30 rounds (−2 to attacks, damage, and AC until rested 3 turns) -- Battle state machine wrapping kernel combat: declared spells tracked for disruption, a cast or scroll-read declaration judged again in the magic phase before it resolves, a fighting withdrawal or retreat legal only when every declarer makes it, morale checks, fleeing and pursuit outcomes, victory/TPK +- Battle state machine wrapping kernel combat: declared spells tracked for disruption, a cast or scroll-read declaration judged again in the magic phase before it resolves, a fighting withdrawal or retreat that moves the formation when every declarer makes it and otherwise trades the front-rank member who makes it for a named back-rank member, morale checks, fleeing and pursuit outcomes, victory/TPK - XP awarded at adventure end per RAW (survive and return to safety), with an immediate-award `Ruleset` adaptation for continuous CRPG play Out of scope for 1.0 (tracked for later): wilderness and sea adventuring, strongholds and domain play, hirelings/retainers as full NPCs, magical research, procedural dungeon generation (the SRD stocking tables ship as data; the generator that consumes them comes later), Advanced Fantasy content. diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 0c16804..7fea63d 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2404,8 +2404,10 @@ class BattleDeclaration(BaseModel): - `attack` takes `target_group_id` and, optionally, `weapon_id`. `None` means bare hands. - `cast` takes `spell_id`, `spell_mode`, `reversed`, and `targets`. - - `move` takes `move`, and `target_group_id` for a `close`. It changes the distance - between the party and the monsters. + - `move` takes `move`, `target_group_id` for a `close`, and `replacement_id` for a + `fighting_withdrawal` or a `retreat` that the member makes alone. A formation move + changes the distance between the party and the monsters. A member's own move changes + the party's ranks instead. - `use_item` takes `item_id`. The member drinks a potion, reads a scroll, or uses a wand, staff, or rod, or throws a flask of oil or a similar item at `target_group_id`. - `turn_undead` needs nothing else. Turning resolves with the spells in the round, but @@ -2478,21 +2480,42 @@ class BattleDeclaration(BaseModel): groups, the party closes on the group of the first `close` in [`ResolveBattleRound.declarations`][osrlib.crawl.commands.ResolveBattleRound.declarations]. - `fighting_withdrawal` backs the party away from every group at half that rate. A member - who declares it makes no attack that round, because a member declares one action per - round. - - `retreat` backs the party away at the full rate. A round in which every member retreats - ends the battle. It becomes a chase, or a clean escape when no monster can chase. - - The party moves as one formation, and no member can leave it. So `fighting_withdrawal` - and `retreat` are legal only when every member in `declarers` declares the same one. - When some members declare one of them and others don't, a member who declares `close` - included, the session refuses the whole round with `battle.declaration.formation_split`. - The result contains one rejection for each of the two moves the round splits on, and each - one lists who declared that move and who didn't. + `fighting_withdrawal` and `retreat` are the SRD's two ways out of melee. When every + member in `declarers` declares the same one, the whole formation makes it. + `fighting_withdrawal` then backs the party away from every group at half that rate. + `retreat` backs the party away at the full rate and ends the battle. It becomes a chase, + or a clean escape when no monster can chase. A round whose only declarer declares one is + a formation move too. + + Otherwise each `fighting_withdrawal` or `retreat` is the declaring member's own. It + doesn't move the party. The member, who has to stand in the front rank, steps back and + trades places in the marching order with the back-rank member named in `replacement_id`. + A retreating member is easier to hit for the whole round: attackers get +2 against them + and ignore their shield. The round goes on, and the battle with it. + + A member who declares either move makes no attack that round, whether the move is the + formation's or their own, because a member declares one action per round. [The adaptations register](https://mmacy.github.io/osrlib-python/adaptations/) - describes this rule in full.""" + describes these rules in full.""" + replacement_id: str | None = None + """The back-rank member who steps up into this member's place, for a `fighting_withdrawal` + or a `retreat` that this member makes alone, by + [`MemberView.id`][osrlib.crawl.views.MemberView.id]. Leave it `None` for every other + declaration, a formation move included. + + The two members trade places in the marching order in the party's movement step, and the + order stays that way after the battle. Each rank stands five feet behind the one in front + of it, so a replacement two ranks back is ten feet away. The withdrawing member has to + cover that distance at up to half their encounter movement rate for a fighting withdrawal, + or their full encounter rate for a retreat. The replacement has to cover it at up to their + full encounter rate. *Haste* doubles either limit for the member who bears it. + + The replacement has to be a living member behind the front rank who is in + [`EncounterView.declarers`][osrlib.crawl.views.EncounterView.declarers], isn't in + `immobile`, and hasn't declared a `cast`, a `fighting_withdrawal`, or a `retreat`. A + spell caster can't move and cast in the same round. Two withdrawing members can't name + the same replacement. From the movement step on, the replacement stands in the front + rank, so they may declare a melee attack against a group in reach this round.""" item_id: str | None = None """Which item a `use_item` declaration uses: a magic item's per-instance id for a potion, a scroll, a wand, a staff, or a rod, or a mundane item's catalog id for an item thrown at a @@ -2576,7 +2599,9 @@ class ResolveBattleRound(Command): side order. Movement: a [`GroupMovedEvent`][osrlib.crawl.events.GroupMovedEvent] each time the - distance to a group changes. + distance to a group changes, and a + [`MemberWithdrewEvent`][osrlib.crawl.events.MemberWithdrewEvent] each time a member + who withdraws alone trades places with a member from a back rank. Missiles and melee: [`AttackRolledEvent`][osrlib.core.events.AttackRolledEvent], diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 807e4b4..7ffcc7d 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -122,6 +122,7 @@ "LightEvent", "ListenedEvent", "LocationEnteredEvent", + "MemberWithdrewEvent", "MonsterDefeatedEvent", "MonsterFledEvent", "MonstersLeftBehindEvent", @@ -1169,6 +1170,41 @@ class GroupMovedEvent(Event): hand to hand.""" +class MemberWithdrewEvent(Event): + """A party member stepped back out of the front rank, and a member from behind took their place. + + Emitted by [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] in the party's + movement step, once for each member who made a fighting withdrawal or a retreat alone and + named a [`replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id]. The two + members have traded places in the marching order, so the withdrawing member now stands + where the replacement stood, and the replacement stands in the front rank. The party + hasn't moved, and no group's distance has changed. + + The events come in the marching order of the withdrawing members, before the party's + `close`, if the round has one. A swap that can't happen when the movement step comes, + because one of the two has died or can no longer move, or because the ranks shifted + and one of them no longer stands where they did, reports nothing, and the two stay where + they are. + + Redraw a battle screen's ranks from this event, or read them again from + [`PlayerView.party`][osrlib.crawl.views.PlayerView.party] and + [`EncounterView.front_rank`][osrlib.crawl.views.EncounterView.front_rank]. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.member.withdrew", "battle.member.retreated"}) + """`battle.member.withdrew` for a fighting withdrawal and `battle.member.retreated` for a + retreat.""" + + event_type: Literal["member_withdrew"] = "member_withdrew" + """The event type, `member_withdrew`, which identifies this class in serialized form.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the party sees its own ranks change.""" + character_id: str + """The member who stepped back, by party member id.""" + replacement_id: str + """The member who stepped up into the front rank in their place, by party member id.""" + + class MonsterFledEvent(Event): """A monster group broke and ran. @@ -2074,6 +2110,7 @@ class ItemsRemovedEvent(Event): BattleRoundEvent, SpellDeclaredEvent, GroupMovedEvent, + MemberWithdrewEvent, MonsterFledEvent, MonstersLeftBehindEvent, MonsterDefeatedEvent, diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index 5ee5660..a7a0166 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -356,6 +356,12 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: f"{event.caster_id} begins casting {event.spell_id}" + (" (reversed)" if event.reversed else "") + "." ), "battle.group.moved": lambda event: f"{event.group_id} is now {event.distance_feet}' away.", + "battle.member.withdrew": lambda event: ( + f"{event.character_id} falls back, and {event.replacement_id} steps up to the front rank." + ), + "battle.member.retreated": lambda event: ( + f"{event.character_id} turns and retreats, and {event.replacement_id} steps up to the front rank." + ), "battle.side.fled": lambda event: f"{event.group_id} flees the battle!", "battle.group.left_behind": lambda event: ( f"{event.source_group_id} leaves {event.count} helpless behind as {event.group_id}." diff --git a/tests/goldens/phase5_milestone.json b/tests/goldens/phase5_milestone.json index 00750e9..66d4cc4 100644 --- a/tests/goldens/phase5_milestone.json +++ b/tests/goldens/phase5_milestone.json @@ -37,6 +37,7 @@ "character_id": "character-0001", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -49,6 +50,7 @@ "character_id": "character-0002", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -61,6 +63,7 @@ "character_id": "character-0003", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -73,6 +76,7 @@ "character_id": "character-0004", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -91,6 +95,7 @@ "character_id": "character-0001", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -103,6 +108,7 @@ "character_id": "character-0002", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -115,6 +121,7 @@ "character_id": "character-0003", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -127,6 +134,7 @@ "character_id": "character-0004", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -145,6 +153,7 @@ "character_id": "character-0001", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -157,6 +166,7 @@ "character_id": "character-0002", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -169,6 +179,7 @@ "character_id": "character-0003", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -181,6 +192,7 @@ "character_id": "character-0004", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -199,6 +211,7 @@ "character_id": "character-0001", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -211,6 +224,7 @@ "character_id": "character-0002", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -223,6 +237,7 @@ "character_id": "character-0003", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -235,6 +250,7 @@ "character_id": "character-0004", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -253,6 +269,7 @@ "character_id": "character-0001", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -265,6 +282,7 @@ "character_id": "character-0002", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -277,6 +295,7 @@ "character_id": "character-0003", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -289,6 +308,7 @@ "character_id": "character-0004", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -307,6 +327,7 @@ "character_id": "character-0001", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -319,6 +340,7 @@ "character_id": "character-0002", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -331,6 +353,7 @@ "character_id": "character-0003", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -343,6 +366,7 @@ "character_id": "character-0004", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -401,6 +425,7 @@ "character_id": "character-0001", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -413,6 +438,7 @@ "character_id": "character-0002", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -425,6 +451,7 @@ "character_id": "character-0003", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -437,6 +464,7 @@ "character_id": "character-0004", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -455,6 +483,7 @@ "character_id": "character-0001", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -467,6 +496,7 @@ "character_id": "character-0002", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -479,6 +509,7 @@ "character_id": "character-0003", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -491,6 +522,7 @@ "character_id": "character-0004", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -509,6 +541,7 @@ "character_id": "character-0001", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -521,6 +554,7 @@ "character_id": "character-0002", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -533,6 +567,7 @@ "character_id": "character-0003", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -545,6 +580,7 @@ "character_id": "character-0004", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -563,6 +599,7 @@ "character_id": "character-0001", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -575,6 +612,7 @@ "character_id": "character-0002", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -587,6 +625,7 @@ "character_id": "character-0003", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -599,6 +638,7 @@ "character_id": "character-0004", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -623,6 +663,7 @@ "character_id": "character-0001", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -635,6 +676,7 @@ "character_id": "character-0002", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -647,6 +689,7 @@ "character_id": "character-0003", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -659,6 +702,7 @@ "character_id": "character-0004", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -677,6 +721,7 @@ "character_id": "character-0001", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -689,6 +734,7 @@ "character_id": "character-0003", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -701,6 +747,7 @@ "character_id": "character-0004", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -719,6 +766,7 @@ "character_id": "character-0001", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -731,6 +779,7 @@ "character_id": "character-0003", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -743,6 +792,7 @@ "character_id": "character-0004", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -761,6 +811,7 @@ "character_id": "character-0001", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -773,6 +824,7 @@ "character_id": "character-0003", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -785,6 +837,7 @@ "character_id": "character-0004", "item_id": null, "move": null, + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -803,6 +856,7 @@ "character_id": "character-0001", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -815,6 +869,7 @@ "character_id": "character-0003", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, @@ -827,6 +882,7 @@ "character_id": "character-0004", "item_id": null, "move": "close", + "replacement_id": null, "reversed": false, "spell_id": null, "spell_mode": null, diff --git a/tests/test_battle.py b/tests/test_battle.py index 392c00b..42fa547 100644 --- a/tests/test_battle.py +++ b/tests/test_battle.py @@ -1156,18 +1156,20 @@ def test_three_targets_are_refused_whole_and_one_is_accepted(self): class TestAFormationMoveNeedsEveryDeclarer: - """The party moves as one formation and a member cannot leave it (the adaptations register, battle - section). A `fighting_withdrawal` or a `retreat` is therefore a legal declaration only when every - declarer makes the same one. A round in which some declare it and some do not is refused whole with - `battle.declaration.formation_split`, one rejection per defensive move declared, whose params name - the `move`, the `declared` members who chose it, and the `others` who did not, each tuple in - marching order. The refusal costs no draw, no round, no distance, and no event, as every other - illegal declaration costs nothing. `close` is not a formation move in this sense: the first `close` - in marching order advances the formation for everyone, so a lone `close` stays legal. + """A `fighting_withdrawal` or a `retreat` that every declarer makes moves the whole formation (the + adaptations register, battle section). One that not every declarer makes is the declaring member's + own: a front-rank member trades places with the back-rank member named in `replacement_id`, and + `test_single_withdrawal.py` locks that. A round in which some members make a defensive move with no + replacement named is refused whole, with `battle.declaration.no_replacement` for each front-rank + member and `battle.declaration.not_in_front_rank` for each member behind the front rank. The + refusal costs no draw, no round, no distance, and no event, as every other illegal declaration costs + nothing. `close` needs no agreement: the first `close` in marching order advances the formation for + everyone, so a lone `close` stays legal. """ + @pytest.mark.xfail(reason="chunk: single-withdrawal", strict=True) @pytest.mark.parametrize("move", ["fighting_withdrawal", "retreat"]) - def test_a_lone_defensive_move_is_refused_whole(self, move): + def test_a_lone_defensive_move_with_no_replacement_is_refused_whole(self, move): from osrlib.core.combat import COMBAT_STREAM session = battle_session(distance=40) @@ -1178,15 +1180,14 @@ def test_a_lone_defensive_move_is_refused_whole(self, move): result = session.execute(ResolveBattleRound(declarations=hold_all(session, extra=(lone,)))) assert not result.accepted assert result.events == () - split = [rejection for rejection in result.rejections if rejection.code == "battle.declaration.formation_split"] - assert len(split) == 1 - assert split[0].params["move"] == move - assert tuple(split[0].params["declared"]) == ("character-0001",) - assert tuple(split[0].params["others"]) == ("character-0002", "character-0003", "character-0004") + assert [(rejection.code, dict(rejection.params)) for rejection in result.rejections] == [ + ("battle.declaration.no_replacement", {"character": "character-0001"}) + ] assert session.streams.get(COMBAT_STREAM).export_state() == combat_before assert session.battle.round == round_before assert session.encounter.groups[0].distance_feet == distance_before + @pytest.mark.xfail(reason="chunk: single-withdrawal", strict=True) def test_a_majority_does_not_carry_the_minority(self): session = battle_session(distance=40) retreating = tuple( @@ -1195,26 +1196,24 @@ def test_a_majority_does_not_carry_the_minority(self): ) result = session.execute(ResolveBattleRound(declarations=hold_all(session, extra=retreating))) assert not result.accepted - split = [rejection for rejection in result.rejections if rejection.code == "battle.declaration.formation_split"] - assert len(split) == 1 - assert tuple(split[0].params["declared"]) == ("character-0001", "character-0002", "character-0003") - assert tuple(split[0].params["others"]) == ("character-0004",) + assert sorted((rejection.code, rejection.params.get("character")) for rejection in result.rejections) == [ + ("battle.declaration.no_replacement", "character-0001"), + ("battle.declaration.no_replacement", "character-0002"), + ("battle.declaration.not_in_front_rank", "character-0003"), + ] assert session.battle is not None and session.battle.round == 0 - def test_two_defensive_moves_in_one_round_are_refused_once_each(self): + @pytest.mark.xfail(reason="chunk: single-withdrawal", strict=True) + def test_two_members_withdrawing_alone_each_need_a_replacement(self): session = battle_session(distance=40) first = BattleDeclaration(character_id="character-0001", action="move", move="retreat") second = BattleDeclaration(character_id="character-0002", action="move", move="fighting_withdrawal") result = session.execute(ResolveBattleRound(declarations=hold_all(session, extra=(first, second)))) assert not result.accepted - split = sorted( - (rejection.params["move"], tuple(rejection.params["declared"]), tuple(rejection.params["others"])) - for rejection in result.rejections - if rejection.code == "battle.declaration.formation_split" - ) - assert split == [ - ("fighting_withdrawal", ("character-0002",), ("character-0001", "character-0003", "character-0004")), - ("retreat", ("character-0001",), ("character-0002", "character-0003", "character-0004")), + refused = sorted(((rejection.code, dict(rejection.params)) for rejection in result.rejections), key=repr) + assert refused == [ + ("battle.declaration.no_replacement", {"character": "character-0001"}), + ("battle.declaration.no_replacement", {"character": "character-0002"}), ] def test_every_declarer_withdrawing_moves_the_formation(self): diff --git a/tests/test_events_kernel.py b/tests/test_events_kernel.py index 4b2c94b..e95f10d 100644 --- a/tests/test_events_kernel.py +++ b/tests/test_events_kernel.py @@ -113,6 +113,7 @@ def sample_event(event_class, code): "BattleRoundEvent": dict(round=2), "SpellDeclaredEvent": dict(caster_id="pc-1", spell_id="sleep"), "GroupMovedEvent": dict(group_id="group-1", distance_feet=30), + "MemberWithdrewEvent": dict(character_id="pc-1", replacement_id="pc-3"), "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), diff --git a/tests/test_single_withdrawal.py b/tests/test_single_withdrawal.py new file mode 100644 index 0000000..ad2e139 --- /dev/null +++ b/tests/test_single_withdrawal.py @@ -0,0 +1,778 @@ +"""A fighting withdrawal or a retreat that one party member makes alone (issue #171). + +The OSE SRD describes both defensive moves per combatant: "The combatant moves backwards at up to +half their encounter movement rate. There must be clear space behind the combatant." and "The +combatant turns and flees from melee, moving up to their full encounter movement rate. This +round: the retreating combatant may not attack; the opponent gains a +2 bonus to all attacks +against the retreating combatant and ignores any AC bonus due to the combatant's shield (if +applicable)." osrlib keeps the formation move for a round every declarer agrees on, and makes +every other defensive move the declaring member's own. The contract, which the adaptations +register states under "The battle round": + +- A front-rank member who withdraws alone names a back-rank member in + `BattleDeclaration.replacement_id`. In the party's movement step the two trade places in the + marching order, and a `MemberWithdrewEvent` reports it. The party doesn't move, and the order + stays changed. +- Each rank stands 5' behind the one in front of it. The withdrawing member covers the distance at + up to half their encounter rate for a fighting withdrawal or their full encounter rate for a + retreat, the replacement at up to their full encounter rate, each doubled by their own *haste*. +- The refusals: `battle.declaration.not_in_front_rank` for a back-rank member, + `battle.declaration.no_replacement`, `battle.declaration.replacement_unavailable`, and + `battle.declaration.replacement_too_far`. +- A retreat's penalties fall on the retreating member alone and last the whole round. +- The replacement may strike in melee the round they step up. A withdrawing member makes no attack. +- A swap that can't happen when the movement step comes doesn't happen and reports nothing. + +Every acceptance test here is marked expected-to-fail until the chunk lands. The classes at the end +are unmarked guards: they pin the formation moves as they are today, which the chunk must keep. +""" + +import pytest + +from crawl_fixtures import STOCK_ROSTER, build_adventure, build_party +from osrlib.core.character import party_to_document +from osrlib.core.combat import DamageSource, deal_damage +from osrlib.core.effects import ActiveCondition, Condition, EffectDefinition, has_condition +from osrlib.core.events import AttackRolledEvent, InitiativeRolledEvent, Visibility +from osrlib.core.ruleset import Ruleset +from osrlib.core.spells import MemorizedSpell, memorize_spells +from osrlib.core.tables import ReactionResult +from osrlib.crawl import encounter as encounter_module +from osrlib.crawl.battle import MonsterAction +from osrlib.crawl.commands import ( + BattleDeclaration, + EngageBattle, + EnterDungeon, + EquipItem, + GrantItem, + LightSource, + ReorderParty, + ResolveBattleRound, + SessionMode, + SpawnMonsters, +) +from osrlib.crawl.events import MemberWithdrewEvent +from osrlib.crawl.session import GameSession +from osrlib.data import load_classes, load_equipment, load_spells +from osrlib.persistence import load_game, replay_game, save_game, session_state + +CHUNK = pytest.mark.xfail(reason="chunk: single-withdrawal", strict=True) + +BRAKK = "character-0001" +"""The fighter, with a sword. Front rank in the stock marching order.""" +SABLE = "character-0002" +"""The thief, with a crossbow. Front rank.""" +WYNN = "character-0003" +"""The cleric. Second rank.""" +ELARA = "character-0004" +"""The magic-user, with *sleep* in her book. Second rank.""" +GARR = "character-0005" +HALE = "character-0006" +IVER = "character-0007" +JORY = "character-0008" + +SIX = (*STOCK_ROSTER, ("Garr", "fighter"), ("Hale", "fighter")) +"""Three ranks in the two-abreast corridor: Brakk and Sable, Wynn and Elara, Garr and Hale.""" +EIGHT = (*SIX, ("Iver", "fighter"), ("Jory", "fighter")) +"""Four ranks: Iver and Jory stand fifteen feet behind the front.""" + +MONSTERS_FIRST_SEED = 1 +"""Four goblins at 5' win initiative and strike at both Brakk and Sable before the party acts.""" +PARTY_FIRST_SEED = 0 +"""The party wins initiative over four goblins at 5', and the goblins strike at the front rank's first place.""" +GHOUL_SEED = 1 +"""Three ghouls at 5' win initiative and paralyse Brakk, and not Wynn, before the party acts.""" +REPLAY_SEED = 0 +"""Two goblins spawned at 5' by command give a battle whose first round the party declares.""" + + +# ---------------------------------------------------------------------- helpers + + +def _delve(*, roster=STOCK_ROSTER, seed=5, ruleset=None, shield=False, laden=()) -> GameSession: + """The party in the delve's entrance corridor, two abreast, torch lit, Brakk and Sable armed. + + `laden` names fighters who put on plate mail and haul treasure: 30' a turn under basic + encumbrance, so a 10' encounter rate. + """ + session = GameSession.new(build_party(roster), build_adventure(wandering_chance=0), seed=seed, ruleset=ruleset) + grants = [(BRAKK, "torch", 6), (BRAKK, "tinder_box", 1), (BRAKK, "sword", 1), (SABLE, "crossbow", 1)] + grants.append((SABLE, "crossbow_bolts", 1)) + equips = [(BRAKK, "sword"), (SABLE, "crossbow")] + if shield: + grants.append((BRAKK, "shield", 1)) + equips.append((BRAKK, "shield")) + for member_id in laden: + grants.append((member_id, "plate_mail", 1)) + equips.append((member_id, "plate_mail")) + for member_id, item_id, quantity in grants: + assert session.execute(GrantItem(character_id=member_id, item_id=item_id, quantity=quantity)).accepted + for member_id, item_id in equips: + assert session.execute(EquipItem(character_id=member_id, item_id=item_id)).accepted + for member_id in laden: + session.member(member_id).carrying_treasure = True + assert session.member(member_id).movement_rate(session.ruleset) == 30 + assert session.execute(EnterDungeon(dungeon_id="delve")).accepted + for _ in range(20): + lit = session.execute(LightSource(character_id=BRAKK, item_id="torch")) + if any(event.code == "exploration.light.lit" for event in lit.events): + break + return session + + +def _battle( + *, + roster=STOCK_ROSTER, + template_id="goblin", + count=2, + distance=40, + seed=5, + ruleset=None, + shield=False, + laden=(), + hp=50, +) -> GameSession: + """A battle at round 0 against one group, every member at `hp` so nobody falls by chance.""" + session = _delve(roster=roster, seed=seed, ruleset=ruleset, shield=shield, laden=laden) + instances = session.spawn(template_id, count) + encounter_module.start_encounter( + session, + groups=[(template_id, instances)], + kind="spawned", + distance_feet=distance, + pinned_stance=ReactionResult.HOSTILE, + party_aware=True, + ) + assert session.execute(EngageBattle()).accepted + assert session.mode is SessionMode.BATTLE + assert session.battle.round == 0 + for member in session.party.members: + member.max_hp = hp + member.current_hp = hp + return session + + +def _group(session) -> str: + return session.encounter.groups[0].id + + +def _order(session) -> list[str]: + return [member.id for member in session.party.members] + + +def _view(session): + return session.view(Visibility.PLAYER).encounter + + +def _ranks(session) -> list[list[str]]: + width = 2 # the delve's entrance corridor is ten feet wide + living = [member.id for member in session.party.living_members()] + return [living[index : index + width] for index in range(0, len(living), width)] + + +def _withdraw(character_id, replacement_id=None, move="fighting_withdrawal", **fields) -> BattleDeclaration: + return BattleDeclaration( + character_id=character_id, action="move", move=move, replacement_id=replacement_id, **fields + ) + + +def _declare(session, *declarations) -> tuple[BattleDeclaration, ...]: + """The given declarations, and a `hold` for every other member the round expects.""" + covered = {declaration.character_id for declaration in declarations} + rest = tuple( + BattleDeclaration(character_id=member_id, action="hold") + for member_id in _view(session).declarers + if member_id not in covered + ) + return (*declarations, *rest) + + +def _round(session, *declarations): + return session.execute(ResolveBattleRound(declarations=_declare(session, *declarations))) + + +def _rejections(result) -> list[tuple[str, dict]]: + return sorted(((rejection.code, dict(rejection.params)) for rejection in result.rejections), key=repr) + + +def _swaps(result) -> list[MemberWithdrewEvent]: + return [event for event in result.events if isinstance(event, MemberWithdrewEvent)] + + +def _attacks(result) -> list[AttackRolledEvent]: + return [event for event in result.events if isinstance(event, AttackRolledEvent)] + + +def _position(result, event) -> int: + """Where `event` sits in the round's events, by identity, since two attack events can be equal.""" + return next(index for index, candidate in enumerate(result.events) if candidate is event) + + +def _initiative(result) -> InitiativeRolledEvent: + return next(event for event in result.events if isinstance(event, InitiativeRolledEvent)) + + +def _monsters_acted_first(session, result) -> bool: + return _initiative(result).order[0] == _group(session) + + +def _attach(session, definition, target_id) -> None: + session.ledger.attach( + definition, target_id, clock=session.clock, allocator=session.allocator, registry=session.registry() + ) + + +def _assert_refused_cleanly(session, result, before) -> None: + """A refused round costs no draw, no round, no clock, no order change, and no event.""" + streams, round_number, rounds, order, log_size = before + assert not result.accepted + assert result.events == () + assert session.streams.export_states() == streams + assert session.battle.round == round_number + assert session.clock.rounds == rounds + assert _order(session) == order + assert len(session.event_log) == log_size + + +def _snapshot(session): + return ( + session.streams.export_states(), + session.battle.round, + session.clock.rounds, + _order(session), + len(session.event_log), + ) + + +class _StrikeAt: + """A test policy: every living monster in the group strikes one named member, wherever they stand.""" + + def __init__(self, target_id: str) -> None: + self.target_id = target_id + + def choose(self, session, group, stream): + return [ + MonsterAction(monster_id=monster_id, kind="melee", target_id=self.target_id) + for monster_id in group.monster_ids + ] + + +# ---------------------------------------------------------------------- the swap + + +@CHUNK +class TestAFightingWithdrawalAlone: + """One front-rank member falls back, a named back-rank member steps up, and the party stays put.""" + + def test_the_member_trades_places_with_the_named_replacement(self): + session = _battle(distance=5) + assert _view(session).front_rank == (BRAKK, SABLE) + assert _ranks(session) == [[BRAKK, SABLE], [WYNN, ELARA]] + result = _round(session, _withdraw(BRAKK, WYNN)) + assert result.accepted, _rejections(result) + assert _order(session) == [WYNN, SABLE, BRAKK, ELARA] + assert _view(session).front_rank == (WYNN, SABLE) + [swap] = _swaps(result) + assert (swap.code, swap.character_id, swap.replacement_id) == ("battle.member.withdrew", BRAKK, WYNN) + assert swap.visibility is Visibility.PLAYER + assert session.encounter.groups[0].distance_feet == 5 + assert "battle.group.moved" not in [event.code for event in result.events] + assert session.mode is SessionMode.BATTLE + assert session.battle.round == 1 + + def test_a_withdrawal_needs_no_group_in_melee(self): + session = _battle(distance=40) + result = _round(session, _withdraw(BRAKK, WYNN)) + assert result.accepted, _rejections(result) + assert _order(session) == [WYNN, SABLE, BRAKK, ELARA] + # The party never backs away: every distance the round reports is the goblins closing. + assert all(event.distance_feet < 40 for event in result.events if event.code == "battle.group.moved") + + def test_a_replacement_two_ranks_back_takes_the_front_place(self): + session = _battle(roster=SIX, distance=5) + assert _ranks(session) == [[BRAKK, SABLE], [WYNN, ELARA], [GARR, HALE]] + result = _round(session, _withdraw(BRAKK, GARR)) + assert result.accepted, _rejections(result) + assert _order(session) == [GARR, SABLE, WYNN, ELARA, BRAKK, HALE] + assert _view(session).front_rank == (GARR, SABLE) + + def test_the_whole_front_rank_can_step_back_behind_the_second(self): + session = _battle(distance=5) + result = _round(session, _withdraw(SABLE, ELARA), _withdraw(BRAKK, WYNN)) + assert result.accepted, _rejections(result) + assert _order(session) == [WYNN, ELARA, BRAKK, SABLE] + # One event per swap, in the marching order of the members who withdrew. + assert [(swap.character_id, swap.replacement_id) for swap in _swaps(result)] == [(BRAKK, WYNN), (SABLE, ELARA)] + + def test_a_withdrawal_shares_a_round_with_a_close_and_resolves_first(self): + session = _battle(distance=40) + close = BattleDeclaration(character_id=SABLE, action="move", move="close", target_group_id=_group(session)) + result = _round(session, _withdraw(BRAKK, WYNN), close) + assert result.accepted, _rejections(result) + assert _order(session) == [WYNN, SABLE, BRAKK, ELARA] + [swap] = _swaps(result) + at = _position(result, swap) + closing = result.events[at + 1] + assert closing.code == "battle.group.moved" + before = [event.distance_feet for event in result.events[:at] if event.code == "battle.group.moved"] + assert closing.distance_feet < (before[-1] if before else 40) + + +@CHUNK +class TestARetreatAlone: + """A retreat alone trades places as a withdrawal does, and its penalties are the retreater's alone.""" + + def test_the_member_trades_places_and_the_battle_goes_on(self): + session = _battle(distance=5) + result = _round(session, _withdraw(BRAKK, WYNN, move="retreat")) + assert result.accepted, _rejections(result) + assert _order(session) == [WYNN, SABLE, BRAKK, ELARA] + [swap] = _swaps(result) + assert (swap.code, swap.character_id, swap.replacement_id) == ("battle.member.retreated", BRAKK, WYNN) + codes = [event.code for event in result.events] + assert "battle.ended.fled" not in codes + assert session.mode is SessionMode.BATTLE + assert session.encounter.pursuit is None + assert session.encounter.groups[0].distance_feet == 5 + + def test_attackers_get_the_bonus_against_the_retreater_alone_when_they_strike_first(self): + retreating = _battle(distance=5, count=4, seed=MONSTERS_FIRST_SEED, shield=True) + holding = _battle(distance=5, count=4, seed=MONSTERS_FIRST_SEED, shield=True) + result = _round(retreating, _withdraw(BRAKK, WYNN, move="retreat")) + baseline = _round(holding) + assert result.accepted, _rejections(result) + assert baseline.accepted + # Preconditions: the goblins win initiative in both, and strike at both front-rank members. + assert _monsters_acted_first(retreating, result) + assert _monsters_acted_first(holding, baseline) + [swap] = _swaps(result) + assert all(_position(result, attack) < _position(result, swap) for attack in _attacks(result)) + pairs = list(zip(_attacks(result), _attacks(baseline), strict=True)) + assert {attack.defender_id for attack, _ in pairs} == {BRAKK, SABLE} + shield_bonus = load_equipment().get("shield").ac_bonus + for attack, held in pairs: + assert (attack.attacker_id, attack.defender_id) == (held.attacker_id, held.defender_id) + if attack.defender_id == BRAKK: + assert attack.modifier == held.modifier + 2 + assert attack.defender_ac == held.defender_ac + shield_bonus + else: + assert attack.modifier == held.modifier + assert attack.defender_ac == held.defender_ac + # The retreat still happens once the party's turn comes. + assert _order(retreating) == [WYNN, SABLE, BRAKK, ELARA] + + @pytest.mark.parametrize("move", ["fighting_withdrawal", "retreat"]) + def test_once_the_party_has_moved_melee_no_longer_reaches_the_member(self, move): + session = _battle(distance=5, count=4, seed=PARTY_FIRST_SEED, shield=True) + holding = _battle(distance=5, count=4, seed=PARTY_FIRST_SEED, shield=True) + result = _round(session, _withdraw(BRAKK, WYNN, move=move)) + baseline = _round(holding) + assert result.accepted, _rejections(result) + assert not _monsters_acted_first(session, result) + attacks = _attacks(result) + assert attacks, "precondition: the goblins struck at the front rank" + [swap] = _swaps(result) + assert all(_position(result, attack) > _position(result, swap) for attack in attacks) + assert BRAKK not in {attack.defender_id for attack in attacks} + assert WYNN in {attack.defender_id for attack in attacks} + # Nobody else pays for the retreat: every goblin strikes at its ordinary modifier. + held_modifiers = {attack.modifier for attack in _attacks(baseline)} + assert len(held_modifiers) == 1 + assert {attack.modifier for attack in attacks} == held_modifiers + + +@CHUNK +class TestTheMovementLimit: + """Each rank stands 5' behind the one in front, and both members have to cover the distance.""" + + def test_a_laden_member_withdraws_one_rank(self): + session = _battle(roster=SIX, distance=5, laden=(BRAKK,)) + result = _round(session, _withdraw(BRAKK, WYNN)) + assert result.accepted, _rejections(result) + assert _order(session)[:3] == [WYNN, SABLE, BRAKK] + + def test_a_laden_member_cannot_withdraw_two_ranks(self): + session = _battle(roster=SIX, distance=5, laden=(BRAKK,)) + before = _snapshot(session) + result = _round(session, _withdraw(BRAKK, GARR)) + assert _rejections(result) == [ + ( + "battle.declaration.replacement_too_far", + {"character": BRAKK, "replacement": GARR, "distance_feet": 10}, + ) + ] + _assert_refused_cleanly(session, result, before) + + def test_a_laden_member_retreats_two_ranks_at_the_full_rate(self): + session = _battle(roster=SIX, distance=5, laden=(BRAKK,)) + result = _round(session, _withdraw(BRAKK, GARR, move="retreat")) + assert result.accepted, _rejections(result) + assert _order(session)[0] == GARR + assert _order(session)[4] == BRAKK + + def test_haste_doubles_the_withdrawal_limit(self): + session = _battle(roster=SIX, distance=5, laden=(BRAKK,)) + _attach( + session, EffectDefinition(kind="haste", params={"attacks_multiplier": 2, "movement_multiplier": 2}), BRAKK + ) + result = _round(session, _withdraw(BRAKK, GARR)) + assert result.accepted, _rejections(result) + assert _order(session)[0] == GARR + + def test_an_unladen_member_withdraws_three_ranks(self): + session = _battle(roster=EIGHT, distance=5) + assert _ranks(session)[3] == [IVER, JORY] + result = _round(session, _withdraw(BRAKK, IVER)) + assert result.accepted, _rejections(result) + assert _order(session)[0] == IVER + assert _order(session)[6] == BRAKK + + def test_a_laden_replacement_cannot_step_up_three_ranks(self): + session = _battle(roster=EIGHT, distance=5, laden=(IVER,)) + before = _snapshot(session) + result = _round(session, _withdraw(BRAKK, IVER)) + assert _rejections(result) == [ + ( + "battle.declaration.replacement_too_far", + {"character": BRAKK, "replacement": IVER, "distance_feet": 15}, + ) + ] + _assert_refused_cleanly(session, result, before) + + +@CHUNK +class TestWhenNoOneCanStepUp: + """A member who withdraws alone needs a replacement who can take the front place this round.""" + + @pytest.mark.parametrize("move", ["fighting_withdrawal", "retreat"]) + def test_a_member_who_names_no_replacement_is_refused(self, move): + session = _battle(distance=5) + before = _snapshot(session) + result = _round(session, _withdraw(BRAKK, move=move)) + assert _rejections(result) == [("battle.declaration.no_replacement", {"character": BRAKK})] + _assert_refused_cleanly(session, result, before) + + def test_a_back_rank_member_cannot_withdraw_alone(self): + session = _battle(distance=5) + assert WYNN not in _view(session).front_rank + result = _round(session, _withdraw(WYNN, ELARA)) + assert _rejections(result) == [("battle.declaration.not_in_front_rank", {"character": WYNN})] + + @pytest.mark.parametrize("named", [SABLE, BRAKK, "character-9999"]) + def test_the_replacement_has_to_be_a_member_behind_the_front_rank(self, named): + session = _battle(distance=5) + before = _snapshot(session) + result = _round(session, _withdraw(BRAKK, named)) + assert _rejections(result) == [ + ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": named}) + ] + _assert_refused_cleanly(session, result, before) + + def test_a_dead_replacement_cannot_step_up(self): + session = _battle(distance=5) + deal_damage(session.member(WYNN), 999, source=DamageSource()) + assert has_condition(session.member(WYNN), Condition.DEAD) + result = _round(session, _withdraw(BRAKK, WYNN)) + assert _rejections(result) == [ + ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": WYNN}) + ] + + @pytest.mark.parametrize("condition", [Condition.ASLEEP, Condition.ENTANGLED]) + def test_a_replacement_who_cannot_act_or_move_cannot_step_up(self, condition): + session = _battle(distance=5) + session.member(WYNN).conditions = (ActiveCondition(condition=condition, effect_id="effect-9999"),) + view = _view(session) + assert (WYNN in view.declarers) is (condition is Condition.ENTANGLED) + assert (WYNN in view.immobile) is (condition is Condition.ENTANGLED) + result = _round(session, _withdraw(BRAKK, WYNN)) + assert _rejections(result) == [ + ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": WYNN}) + ] + + def test_a_replacement_who_casts_cannot_step_up(self): + session = _battle(distance=5) + elara = session.member(ELARA) + memorize_spells(elara, load_classes().get("magic_user"), load_spells(), [MemorizedSpell(spell_id="sleep")]) + cast = BattleDeclaration( + character_id=ELARA, action="cast", spell_id="sleep", spell_mode="hd_budget", target_group_id=_group(session) + ) + result = _round(session, _withdraw(BRAKK, ELARA), cast) + assert _rejections(result) == [ + ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": ELARA}) + ] + # The same cast with nobody naming Elara is a legal round, so the refusal is the step up's. + legal = _round(session, cast) + assert legal.accepted, _rejections(legal) + + def test_a_replacement_who_withdraws_too_cannot_step_up(self): + session = _battle(distance=5) + result = _round(session, _withdraw(BRAKK, WYNN), _withdraw(WYNN, ELARA)) + assert _rejections(result) == [ + ("battle.declaration.not_in_front_rank", {"character": WYNN}), + ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": WYNN}), + ] + + def test_two_members_cannot_name_one_replacement(self): + session = _battle(distance=5) + result = _round(session, _withdraw(BRAKK, WYNN), _withdraw(SABLE, WYNN)) + # The member earlier in marching order keeps the replacement. + assert _rejections(result) == [ + ("battle.declaration.replacement_unavailable", {"character": SABLE, "replacement": WYNN}) + ] + + def test_without_the_width_limit_nobody_stands_behind(self): + session = _battle(distance=5, ruleset=Ruleset(formation_width_limit=False)) + assert _view(session).front_rank == (BRAKK, SABLE, WYNN, ELARA) + named = _round(session, _withdraw(BRAKK, WYNN)) + assert _rejections(named) == [ + ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": WYNN}) + ] + unnamed = _round(session, _withdraw(BRAKK)) + assert _rejections(unnamed) == [("battle.declaration.no_replacement", {"character": BRAKK})] + + def test_a_formation_move_has_no_replacement_to_name(self): + session = _battle(distance=5) + declarations = [_withdraw(member_id) for member_id in (SABLE, WYNN, ELARA)] + result = _round(session, _withdraw(BRAKK, WYNN), *declarations) + # Every declarer withdraws, so the formation moves, and the named member is withdrawing too. + assert _rejections(result) == [ + ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": WYNN}) + ] + + +# ---------------------------------------------------------------------- melee around the swap + + +@CHUNK +class TestSteppingUpIntoMelee: + """From the movement step on the replacement stands in the front rank, and the withdrawn member doesn't.""" + + def test_the_replacement_may_strike_the_round_they_step_up(self): + session = _battle(distance=5) + assert WYNN not in _view(session).front_rank + strike = BattleDeclaration(character_id=WYNN, action="attack", target_group_id=_group(session)) + result = _round(session, _withdraw(BRAKK, WYNN), strike) + assert result.accepted, _rejections(result) + [swap] = _swaps(result) + wynn_attacks = [attack for attack in _attacks(result) if attack.attacker_id == WYNN] + assert wynn_attacks + assert all(_position(result, attack) > _position(result, swap) for attack in wynn_attacks) + + def test_a_back_rank_member_who_does_not_step_up_still_cannot_strike(self): + session = _battle(distance=5) + strike = BattleDeclaration(character_id=ELARA, action="attack", target_group_id=_group(session)) + result = _round(session, _withdraw(BRAKK, WYNN), strike) + assert _rejections(result) == [("battle.declaration.not_in_front_rank", {"character": ELARA})] + + def test_a_member_paralysed_before_the_movement_step_stays_in_front(self): + session = _battle(template_id="ghoul", count=3, distance=5, seed=GHOUL_SEED) + strike = BattleDeclaration(character_id=WYNN, action="attack", target_group_id=_group(session)) + result = _round(session, _withdraw(BRAKK, WYNN), strike) + assert result.accepted, _rejections(result) + # Preconditions: the ghouls act first and paralyse Brakk, and Wynn can still move. + assert _monsters_acted_first(session, result) + assert has_condition(session.member(BRAKK), Condition.PARALYSED) + assert not has_condition(session.member(WYNN), Condition.PARALYSED) + assert _swaps(result) == [] + assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] + # Wynn never stepped up, so the melee attack Wynn declared doesn't resolve. + assert WYNN not in {attack.attacker_id for attack in _attacks(result)} + + def test_a_strike_chosen_against_a_withdrawn_member_is_skipped(self): + session = _battle(distance=5, count=4, seed=PARTY_FIRST_SEED) + holding = _battle(distance=5, count=4, seed=PARTY_FIRST_SEED) + for game in (session, holding): + game.action_policies[_group(game)] = _StrikeAt(BRAKK) + result = _round(session, _withdraw(BRAKK, WYNN)) + baseline = _round(holding) + assert result.accepted, _rejections(result) + # Preconditions: the party acts first, and the policy's strikes at Brakk land when he holds. + assert not _monsters_acted_first(session, result) + assert BRAKK in {attack.defender_id for attack in _attacks(baseline)} + assert BRAKK not in {attack.defender_id for attack in _attacks(result)} + + +@CHUNK +class TestNoAttackWhileWithdrawing: + """The move is the member's one declared action: a withdrawing member makes no attack that round.""" + + @pytest.mark.parametrize("move", ["fighting_withdrawal", "retreat"]) + def test_a_member_who_withdraws_alone_does_not_shoot(self, move): + session = _battle(distance=40) + # Precondition: the same shot declared as an attack is legal and fires. + shooting = _battle(distance=40) + shot = BattleDeclaration( + character_id=SABLE, action="attack", target_group_id=_group(shooting), weapon_id="crossbow" + ) + fired = _round(shooting, shot) + assert fired.accepted, _rejections(fired) + assert SABLE in {attack.attacker_id for attack in _attacks(fired)} + withdrawal = _withdraw(SABLE, ELARA, move=move, target_group_id=_group(session), weapon_id="crossbow") + result = _round(session, withdrawal) + assert result.accepted, _rejections(result) + assert _order(session) == [BRAKK, ELARA, WYNN, SABLE] + assert SABLE not in {attack.attacker_id for attack in _attacks(result)} + assert SABLE not in session.battle.fired_last_round + + +# ---------------------------------------------------------------------- views + + +@CHUNK +class TestTheViewsAfterTheSwap: + def test_the_player_view_shows_the_new_ranks(self): + session = _battle(distance=5) + result = _round(session, _withdraw(BRAKK, WYNN)) + assert result.accepted, _rejections(result) + view = session.view(Visibility.PLAYER) + assert [member.id for member in view.party] == [WYNN, SABLE, BRAKK, ELARA] + assert view.encounter.front_rank == (WYNN, SABLE) + assert view.encounter.declarers == (WYNN, SABLE, BRAKK, ELARA) + shown = [event for event in result.events if event.visibility is Visibility.PLAYER] + assert [ + (event.character_id, event.replacement_id) for event in shown if isinstance(event, MemberWithdrewEvent) + ] == [(BRAKK, WYNN)] + + def test_the_referee_view_shows_the_new_marching_order(self): + session = _battle(distance=5) + assert _round(session, _withdraw(BRAKK, WYNN, move="retreat")).accepted + view = session.view(Visibility.REFEREE) + assert [member.id for member in view.party.members] == [WYNN, SABLE, BRAKK, ELARA] + + def test_the_new_order_holds_through_the_next_round_and_after_the_battle(self): + session = _battle(distance=5) + assert _round(session, _withdraw(BRAKK, WYNN)).accepted + assert _round(session).accepted + assert _view(session).front_rank == (WYNN, SABLE) + for monster_id in session.encounter.groups[0].monster_ids: + deal_damage(session.combatant(monster_id), 999, source=DamageSource()) + assert _round(session).accepted + assert session.mode is SessionMode.EXPLORING + assert _order(session) == [WYNN, SABLE, BRAKK, ELARA] + + +# ---------------------------------------------------------------------- determinism + + +def _commanded_battle(seed: int) -> tuple[GameSession, list]: + """A battle built by commands alone, so a replay rebuilds it, and the starting party document.""" + start = party_to_document(build_party().members) + session = GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=seed) + assert session.execute(GrantItem(character_id=BRAKK, item_id="sword")).accepted + assert session.execute(EquipItem(character_id=BRAKK, item_id="sword")).accepted + assert session.execute(EnterDungeon(dungeon_id="delve")).accepted + assert session.execute(SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=5)).accepted + if session.mode is SessionMode.ENCOUNTER: + assert session.execute(EngageBattle()).accepted + return session, start + + +@CHUNK +class TestDeterminism: + def test_a_refused_withdrawal_costs_nothing(self): + session = _battle(distance=5) + before = _snapshot(session) + distance = session.encounter.groups[0].distance_feet + result = _round(session, _withdraw(BRAKK, SABLE)) + assert _rejections(result) == [ + ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": SABLE}) + ] + _assert_refused_cleanly(session, result, before) + assert session.encounter.groups[0].distance_feet == distance + + def test_a_swap_draws_nothing(self): + # At 40' the goblins only close, so the round's one draw is initiative, in both sessions. + swapping = _battle(distance=40, seed=11) + holding = _battle(distance=40, seed=11) + assert swapping.streams.export_states() == holding.streams.export_states() + result = _round(swapping, _withdraw(BRAKK, WYNN)) + baseline = _round(holding) + assert result.accepted, _rejections(result) + assert _attacks(baseline) == [] + assert _order(swapping) == [WYNN, SABLE, BRAKK, ELARA] + assert swapping.streams.export_states() == holding.streams.export_states() + + def test_a_replay_rebuilds_the_swap(self): + session, start = _commanded_battle(REPLAY_SEED) + assert session.mode is SessionMode.BATTLE + assert _view(session).front_rank[0] == BRAKK + assert WYNN in _view(session).declarers + result = _round(session, _withdraw(BRAKK, WYNN)) + assert result.accepted, _rejections(result) + assert _swaps(result) + while session.mode is SessionMode.BATTLE and session.battle.round < 3: + assert _round(session).accepted + replayed = replay_game(REPLAY_SEED, start, session.adventure, Ruleset(), session.command_log) + assert _order(replayed) == _order(session) + assert _order(replayed)[:3] == [WYNN, SABLE, BRAKK] + assert session_state(replayed) == session_state(session) + + def test_a_save_after_the_swap_resumes_the_same_battle(self): + session = _battle(distance=5, count=4, seed=PARTY_FIRST_SEED) + assert _round(session, _withdraw(BRAKK, WYNN)).accepted + restored = load_game(save_game(session)) + assert _order(restored) == [WYNN, SABLE, BRAKK, ELARA] + assert _view(restored).front_rank == (WYNN, SABLE) + original = _round(session) + again = _round(restored) + assert original.accepted and again.accepted + assert [event.model_dump() for event in again.events] == [event.model_dump() for event in original.events] + + +# ---------------------------------------------------------------------- guards: the formation moves as they are + + +class TestTheFormationMovesStayAsTheyAre: + """Unmarked guards. A defensive move every declarer makes moves the whole formation, as before the chunk.""" + + def test_every_declarer_withdrawing_backs_the_formation_off_at_half_rate(self): + session = _battle(distance=40) + declarations = [_withdraw(member_id) for member_id in (BRAKK, SABLE, WYNN, ELARA)] + result = _round(session, *declarations) + assert result.accepted, _rejections(result) + distances = [event.distance_feet for event in result.events if event.code == "battle.group.moved"] + # The slowest member's encounter rate is 40', so the formation backs off 20'. + assert any(after == before + 20 for before, after in zip([40, *distances], distances, strict=False)) + assert _swaps(result) == [] + assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] + + def test_every_declarer_retreating_ends_the_battle(self): + session = _battle(distance=40) + declarations = [_withdraw(member_id, move="retreat") for member_id in (BRAKK, SABLE, WYNN, ELARA)] + result = _round(session, *declarations) + assert result.accepted, _rejections(result) + assert "battle.ended.fled" in [event.code for event in result.events] + assert session.battle is None + assert _swaps(result) == [] + assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] + + @pytest.mark.parametrize("move", ["fighting_withdrawal", "retreat"]) + def test_the_only_able_declarer_moves_the_formation(self, move): + session = _battle(distance=40) + for member_id in (SABLE, WYNN, ELARA): + session.member(member_id).conditions = ( + ActiveCondition(condition=Condition.ASLEEP, effect_id="effect-9999"), + ) + assert _view(session).declarers == (BRAKK,) + result = session.execute(ResolveBattleRound(declarations=(_withdraw(BRAKK, move=move),))) + assert result.accepted, _rejections(result) + moved = [event.distance_feet for event in result.events if event.code == "battle.group.moved"] + assert any(after > before for before, after in zip([40, *moved], moved, strict=False)) + assert _swaps(result) == [] + assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] + + def test_a_formation_withdrawal_makes_no_attack(self): + session = _battle(distance=40) + armed = _withdraw(SABLE, target_group_id=_group(session), weapon_id="crossbow") + declarations = [_withdraw(member_id) for member_id in (BRAKK, WYNN, ELARA)] + result = _round(session, armed, *declarations) + assert result.accepted, _rejections(result) + assert SABLE not in {attack.attacker_id for attack in _attacks(result)} + assert SABLE not in session.battle.fired_last_round + + def test_reorder_party_stays_refused_in_battle(self): + session = _battle(distance=5) + result = session.execute(ReorderParty(order=(WYNN, SABLE, BRAKK, ELARA))) + assert not result.accepted + assert [rejection.code for rejection in result.rejections] == ["session.command.wrong_mode"] + assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] From ba9d1191412df52909a38dd05cf78e8a4cb147b3 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 14:08:14 -0700 Subject: [PATCH 2/6] Contract: a formation retreat is exposed for the whole round (chunk single-withdrawal) The retreat penalty now has one rule for a member who retreats alone and for the formation. Combat.md says "This round: ... the opponent gains a +2 bonus to all attacks against the retreating combatant and ignores any AC bonus due to the combatant's shield". A formation retreat is every declarer retreating, so every member is exposed for the whole round. That includes monsters that win initiative and strike before the party moves off. Today the formation is exposed only after its own movement step. A new acceptance test, marked expected-to-fail, pins the monsters-first case: +2 on every attack and the shield ignored. An unmarked guard pins the party-first case, which works today. The register and the BattleDeclaration.move docstring state the one rule. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- docs/adaptations.md | 2 +- src/osrlib/crawl/commands.py | 7 ++-- tests/test_single_withdrawal.py | 71 ++++++++++++++++++++++++++++++++- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/docs/adaptations.md b/docs/adaptations.md index 7554d5e..bdb4ac6 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -370,7 +370,7 @@ The party moves as a formation when every declarer agrees (the Bard's Tale conve A `fighting_withdrawal` or a `retreat` that not every declarer makes is the declaring member's own, as Combat.md describes both moves: "The combatant moves backwards at up to half their encounter movement rate" and "The combatant turns and flees from melee". It changes the ranks and leaves the range track alone. The member steps back out of the front rank and trades places in the marching order with the back-rank member the declaration names in `replacement_id`. Both moves need that replacement, because a rank has no empty places: the ranks are the living marching order cut into rows of the formation width, so a member leaves the front rank only by trading places with someone behind it. For a fighting withdrawal this is where the SRD's "There must be clear space behind the combatant" lands. The marching order stays changed after the battle, until a `ReorderParty` outside an encounter sets it again. A withdrawal alone is the only way the order changes in battle, and `ReorderParty` stays refused there. Only a front-rank member may withdraw alone. A back-rank member's defensive move is refused with `battle.declaration.not_in_front_rank`, a member who names no replacement with `battle.declaration.no_replacement`, and a member whose named replacement can't step up with `battle.declaration.replacement_unavailable`. The replacement must be a living, able member behind the front rank who can move, who hasn't declared a `cast` (Combat.md: "The spell caster cannot move and cast a spell in the same round") or a defensive move of their own, and whom no withdrawing member earlier in marching order named. Each rank stands 5' behind the one in front of it, so a replacement k ranks back is k × 5' away, and both members must cover that distance. The withdrawing member covers it at up to half their encounter rate for a fighting withdrawal or their full encounter rate for a retreat. The replacement covers it at up to their full encounter rate, because outside melee "Combatants can move up to their encounter movement rate each round". Each member's own *haste* doubles that member's limit. A swap either member can't cover is refused with `battle.declaration.replacement_too_far`. Each withdrawing member gets at most one of these four rejections, the first that applies in the order given here. With `formation_width_limit` off every living member stands in the one rank, so no member can withdraw alone. A withdrawal alone needs no group in melee, and it can share a round with a `close`. -The swaps resolve in the party's movement step, in the marching order of the withdrawing members and before the formation's `close`, and each reports a `MemberWithdrewEvent` (`battle.member.withdrew` or `battle.member.retreated`). A side that wins initiative over the party therefore strikes at the ranks as they stood when the round was declared. A swap happens only if, when the movement step comes, the withdrawing member still stands in the front rank, the replacement still stands behind it, and both can still move. Otherwise the two stay where they are and nothing reports the swap. From the movement step on, the replacement stands in the front rank, where monsters acting later can strike them. The replacement may declare a melee attack the round they step up, judged against the ranks the round's swaps produce, because "Movement and attacking may be combined in the same round". That attack resolves only if the swap happened. A melee attack the monster side chose against a member who has since withdrawn is skipped as no longer possible. A retreat's penalties fall on the retreating member alone and last the whole round, whichever side acts first: "the opponent gains a +2 bonus to all attacks against the retreating combatant and ignores any AC bonus due to the combatant's shield". A retreat alone doesn't end the battle. +The swaps resolve in the party's movement step, in the marching order of the withdrawing members and before the formation's `close`, and each reports a `MemberWithdrewEvent` (`battle.member.withdrew` or `battle.member.retreated`). A side that wins initiative over the party therefore strikes at the ranks as they stood when the round was declared. A swap happens only if, when the movement step comes, the withdrawing member still stands in the front rank, the replacement still stands behind it, and both can still move. Otherwise the two stay where they are and nothing reports the swap. From the movement step on, the replacement stands in the front rank, where monsters acting later can strike them. The replacement may declare a melee attack the round they step up, judged against the ranks the round's swaps produce, because "Movement and attacking may be combined in the same round". That attack resolves only if the swap happened. A melee attack the monster side chose against a member who has since withdrawn is skipped as no longer possible. A retreat's penalties fall on each member who retreats and on no one else, and last the whole round, whichever side acts first: "This round: ... the opponent gains a +2 bonus to all attacks against the retreating combatant and ignores any AC bonus due to the combatant's shield". A formation retreat is every declarer retreating, so it exposes every member that way, also to monsters that win initiative and strike before the party moves off. A retreat alone doesn't end the battle. A member who makes a fighting withdrawal or a retreat makes no attack that round, alone or with the formation, because a member declares one thing per round and the move is that thing. For a retreat that is RAW: "the retreating combatant may not attack". A fighting withdrawal takes nothing away in RAW, and "Movement and attacking may be combined in the same round", but the round sequence resolves movement before missile attacks and melee. So by the melee step a member who withdrew alone stands behind the front rank, where melee doesn't reach, and a formation that withdrew stands out of melee range. RAW would still allow a missile attack after the move. That half is not represented, and is pinned as such: a withdrawing member's declaration that also names a weapon or a group makes no attack. Locked by `test_single_withdrawal.py`. diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 7fea63d..6359d85 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2490,11 +2490,12 @@ class BattleDeclaration(BaseModel): Otherwise each `fighting_withdrawal` or `retreat` is the declaring member's own. It doesn't move the party. The member, who has to stand in the front rank, steps back and trades places in the marching order with the back-rank member named in `replacement_id`. - A retreating member is easier to hit for the whole round: attackers get +2 against them - and ignore their shield. The round goes on, and the battle with it. + The round goes on, and the battle with it. A member who declares either move makes no attack that round, whether the move is the - formation's or their own, because a member declares one action per round. + formation's or their own, because a member declares one action per round. A member who + retreats, alone or with the formation, is easier to hit for the whole round, whichever side + acts first: attackers get +2 against them and ignore their shield. [The adaptations register](https://mmacy.github.io/osrlib-python/adaptations/) describes these rules in full.""" replacement_id: str | None = None diff --git a/tests/test_single_withdrawal.py b/tests/test_single_withdrawal.py index ad2e139..340693f 100644 --- a/tests/test_single_withdrawal.py +++ b/tests/test_single_withdrawal.py @@ -19,7 +19,8 @@ - The refusals: `battle.declaration.not_in_front_rank` for a back-rank member, `battle.declaration.no_replacement`, `battle.declaration.replacement_unavailable`, and `battle.declaration.replacement_too_far`. -- A retreat's penalties fall on the retreating member alone and last the whole round. +- A retreat's penalties fall on each member who retreats, alone or with the formation, and last the + whole round, whichever side acts first. - The replacement may strike in melee the round they step up. A withdrawing member makes no attack. - A swap that can't happen when the movement step comes doesn't happen and reports nothing. @@ -719,6 +720,58 @@ def test_a_save_after_the_swap_resumes_the_same_battle(self): assert [event.model_dump() for event in again.events] == [event.model_dump() for event in original.events] +def _everyone(move: str) -> list[BattleDeclaration]: + """The same defensive move for every stock member, which makes it the formation's.""" + return [_withdraw(member_id, move=move) for member_id in (BRAKK, SABLE, WYNN, ELARA)] + + +def _assert_shielded(session) -> int: + """Precondition: Brakk wears a shield. Returns its AC bonus.""" + shield = session.member(BRAKK).inventory.shield + assert shield is not None + assert shield.template.ac_bonus == load_equipment().get("shield").ac_bonus + return shield.template.ac_bonus + + +def _assert_exposed(result, baseline, shield_bonus: int) -> None: + """Every attack is the held twin's attack at +2, with Brakk's shield ignored.""" + pairs = list(zip(_attacks(result), _attacks(baseline), strict=True)) + assert pairs, "precondition: the monsters struck" + for attack, held in pairs: + assert (attack.attacker_id, attack.defender_id) == (held.attacker_id, held.defender_id) + assert attack.modifier == held.modifier + 2 + ignored = shield_bonus if attack.defender_id == BRAKK else 0 + assert attack.defender_ac == held.defender_ac + ignored + + +def _party_moved_off(result): + """The formation's own move: the first group move to a greater distance than melee range.""" + return next(event for event in result.events if event.code == "battle.group.moved" and event.distance_feet > 5) + + +@CHUNK +class TestAFormationRetreatIsExposedAllRound: + """Every declarer retreating is every member retreating, so the SRD's "This round" penalty covers the + whole formation for the whole round, also when the monsters strike before the party moves off.""" + + def test_monsters_who_strike_first_get_the_bonus_and_ignore_the_shield(self): + retreating = _battle(distance=5, count=4, seed=MONSTERS_FIRST_SEED, shield=True) + holding = _battle(distance=5, count=4, seed=MONSTERS_FIRST_SEED, shield=True) + shield_bonus = _assert_shielded(retreating) + result = _round(retreating, *_everyone("retreat")) + baseline = _round(holding) + assert result.accepted, _rejections(result) + # Preconditions: the goblins win initiative in both, strike at Brakk and Sable before the + # party moves off, and the round is the formation's retreat. + assert _monsters_acted_first(retreating, result) + assert _monsters_acted_first(holding, baseline) + moved = _party_moved_off(result) + assert all(_position(result, attack) < _position(result, moved) for attack in _attacks(result)) + assert {attack.defender_id for attack in _attacks(result)} == {BRAKK, SABLE} + assert "battle.ended.fled" in [event.code for event in result.events] + _assert_exposed(result, baseline, shield_bonus) + + # ---------------------------------------------------------------------- guards: the formation moves as they are @@ -770,6 +823,22 @@ def test_a_formation_withdrawal_makes_no_attack(self): assert SABLE not in {attack.attacker_id for attack in _attacks(result)} assert SABLE not in session.battle.fired_last_round + def test_a_formation_retreat_is_exposed_once_the_party_has_moved(self): + retreating = _battle(distance=5, count=4, seed=PARTY_FIRST_SEED, shield=True) + holding = _battle(distance=5, count=4, seed=PARTY_FIRST_SEED, shield=True) + for game in (retreating, holding): + game.action_policies[_group(game)] = _StrikeAt(BRAKK) + shield_bonus = _assert_shielded(retreating) + result = _round(retreating, *_everyone("retreat")) + baseline = _round(holding) + assert result.accepted, _rejections(result) + # Preconditions: the party wins initiative in both, and the goblins strike after it moves off. + assert not _monsters_acted_first(retreating, result) + assert not _monsters_acted_first(holding, baseline) + moved = _party_moved_off(result) + assert all(_position(result, attack) > _position(result, moved) for attack in _attacks(result)) + _assert_exposed(result, baseline, shield_bonus) + def test_reorder_party_stays_refused_in_battle(self): session = _battle(distance=5) result = session.execute(ReorderParty(order=(WYNN, SABLE, BRAKK, ELARA))) From f6fde0e090563900531fba647a1e1bca74c9d94d Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 14:26:50 -0700 Subject: [PATCH 3/6] Let a member withdraw or retreat alone, and expose a formation retreat all round Issue #171. A fighting withdrawal or a retreat that every declarer makes still moves the whole formation. Any other is the declaring member's own. The validation pre-phase judges each one in marching order: the member has to stand in the front rank, name a replacement in replacement_id, name one who can step up, and both have to cover 5 feet a rank at their own limits. Each gets the first rejection that applies: not_in_front_rank, no_replacement, replacement_unavailable, or replacement_too_far. A melee declaration is judged against the front rank the planned swaps leave, so the replacement may strike the round they step up. The checks read state and take no draw. The swaps run in the party's movement step, before the close, and each reports a MemberWithdrewEvent. A swap whose two members no longer stand where they did, or can't move, doesn't happen. A replacement's melee attack resolves only if their swap happened, and a monster-side melee attack chosen against a member who has since stepped back is skipped. The retreat penalty is now keyed on the declarations at the top of the round, so it holds whichever side acts first. A formation retreat exposes every member, as it did once the party had moved. A lone retreat exposes that member alone. A confused monster's attack on a retreating member now gets the penalty too, which it never did. formation_split and _formation_split_rejections are gone, and the rejection catalog describes the three new codes. The docstrings that called ReorderParty the only way to change the marching order now name the withdrawal too. No golden file moved: every golden test passes unchanged. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 5 +- src/osrlib/core/combat.py | 7 +- src/osrlib/crawl/battle.py | 374 ++++++++++++++++++++++++-------- src/osrlib/crawl/commands.py | 31 ++- src/osrlib/crawl/party.py | 17 +- src/osrlib/crawl/session.py | 7 +- src/osrlib/crawl/views.py | 12 +- tools/docs/rejection_codes.json | 6 +- 8 files changed, 343 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e37413..187faf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - [`GrantItem`][osrlib.crawl.commands.GrantItem] can grant a magic item by its catalog id, so a game can sell potions or hand out a magic reward (#173). Each copy arrives unidentified, with a separate instance id in the [`ItemAcquiredEvent`][osrlib.crawl.events.ItemAcquiredEvent]. The session rolls whatever the item's entry in the OSE SRD leaves to chance, like a wand's charges, on the treasure stream, as it does for a magic item found in a cache. A mundane grant is unchanged. - An adventure document can use `RemoveItem`, `RemoveCoins`, and a `GrantItem` of a magic item as trigger consequences and quest rewards (#173). A `RemoveItem` can name its member with [`CARRIER_SELECTOR`][osrlib.crawl.triggers.CARRIER_SELECTOR] (`"@carrier"`), which the interpreter replaces with the first living member in marching order who carries the item. When nobody does, the interpreter drops the command with a note. A quest reward written this way uses up the quest item when the quest completes. [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] checks an authored `GrantItem`'s or `RemoveItem`'s item id against the equipment and magic item catalogs, and accepts `"@carrier"` only on a `RemoveItem`. - [`Rest`][osrlib.crawl.commands.Rest] has a new field `hidden`, and [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] takes a matching `hidden` parameter, so a game can let the party hide while it rests. The OSE SRD lowers the chance of wandering monsters when the party rests quietly in an out-of-the-way place, and a game sets `hidden` to say the party does. Each wandering-monster check during a hidden rest takes 1 off the chance in 6, after noise and bright light add theirs. On a level with the usual 1 in 6 chance, the session rolls no check during a hidden rest. The field defaults to `False`, so a saved command without it still parses as a plain rest. In town no checks run, so `hidden` changes nothing there. +- [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] has a new field [`replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id], so a member can make a fighting withdrawal or a retreat alone (#171). It names the back-rank member who steps up into the front rank in the withdrawing member's place. The new event [`MemberWithdrewEvent`][osrlib.crawl.events.MemberWithdrewEvent] reports each swap, with the code `battle.member.withdrew` for a fighting withdrawal and `battle.member.retreated` for a retreat. Its `character_id` is the member who stepped back and its `replacement_id` is the member who stepped up, so a battle screen can redraw the ranks from it. The field defaults to `None`, so a saved command without it still parses and [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] stays 4. Saved command logs now include the field, so the golden file that contains battle declarations was regenerated to add it. ### Changed @@ -26,10 +27,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - A melee attack on a petrified creature hits automatically, as on a paralysed or sleeping one (#166). [`attack_roll`][osrlib.core.combat.attack_roll] reports `combat.attack.auto_hit` and draws no d20, so only the damage is rolled. A missile attack on a petrified creature is still rolled. - [`UseItem`][osrlib.crawl.commands.UseItem] with a potion and a `target_id` that names someone other than the member using it is now refused with `items.use.target_not_allowed`. Before, the session accepted the command and the member in `character_id` drank the potion, so a front end that expected the target to drink got no warning. A `target_id` of `None`, or the id of the member using the potion, drinks the potion as before, and a healing device still takes another member (#174). - A plain [`Rest`][osrlib.crawl.commands.Rest] in a dungeon now checks for wandering monsters at the level's full chance. Before, every rest took 1 off the chance, so a level with the usual 1 in 6 chance never rolled during a rest and resting was always safe. The session still adds 1 for noise and 1 for bright light, and a rest of a night or a day still checks once each turn and stops when monsters come. To rest quietly and take the 1 off, set the new `hidden` field. The `resting` keyword of [`wandering_check`][osrlib.crawl.exploration.wandering_check] still takes 1 off the chance. [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] now sets it from `hidden`, so `advance_turns(resting=True)` alone no longer lowers the chance. It still counts the turns as rest. -- Some command logs recorded under 2.0.1 don't replay under this release. A log that contains a command this release refuses makes [`replay_game`][osrlib.persistence.replay_game] raise [`ContentValidationError`][osrlib.errors.ContentValidationError]. Two commands that 2.0.1 accepted are refused now, so a log that contains one of them raises: a potion `UseItem` aimed at another member, and a temple service that would do nothing. Other logs replay without an error but play out differently. That happens with *sleep* cast at a drained monster, a lair the party left and came back to, a rest in a dungeon, a rest that a wandering monster interrupted, a trap save with a `modifier`, or a chute trap where every member passed the save. In battle, it also happens with a monster that regenerates (a troll, a thoul, or a vampire), a monster whose attack has an effect (like a ghoul's paralysing claw or a wight's level drain), a basilisk's or a medusa's gaze, or a melee attack on a petrified creature. Each of these changes has an entry in this section or under Fixed. Loading a 2.0.1 save with [`load_game`][osrlib.persistence.load_game] isn't affected, because a load restores the saved state and runs no commands. The loaded game then plays on under this release's rules. +- A party member can make a fighting withdrawal or a retreat alone, to pull a badly hurt character out of the front rank without the whole party leaving the fight (#171). Before, [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] refused a round in which only some of the members it expects to declare chose one of these moves, with `battle.declaration.formation_split`. That code is gone. A defensive move that every declarer makes still moves the whole party, as before. Any other is the declaring member's own. The member has to stand in the front rank, and steps back to trade places in the marching order with the member named in `replacement_id`. The party doesn't move and the battle goes on. The new order stays after the battle, until a [`ReorderParty`][osrlib.crawl.commands.ReorderParty] sets it again, and the session still refuses `ReorderParty` during a battle. Each rank stands 5 feet behind the one in front of it, and both members have to cover that distance. The withdrawing member covers it at up to half their encounter movement rate for a fighting withdrawal, or their full rate for a retreat. The replacement covers it at up to their full rate. *Haste* doubles either limit for the member who has it. The session refuses a member behind the front rank with `battle.declaration.not_in_front_rank`. It refuses a member who names no replacement, a replacement who can't step up, and a replacement too far back with three new codes: `battle.declaration.no_replacement`, `battle.declaration.replacement_unavailable`, and `battle.declaration.replacement_too_far`. The swap happens in the party's movement step, so monsters that win initiative strike at the ranks as they were declared. From the swap on, melee no longer reaches the member who stepped back. The replacement may declare a melee attack in the round they step up, and it resolves only if the swap happens. A member who retreats alone is easier to hit for the whole round: attackers get +2 against them and ignore their shield. A refused round costs no draw and no time, and a swap makes no draw. +- Some command logs recorded under 2.0.1 don't replay under this release. A log that contains a command this release refuses makes [`replay_game`][osrlib.persistence.replay_game] raise [`ContentValidationError`][osrlib.errors.ContentValidationError]. Two commands that 2.0.1 accepted are refused now, so a log that contains one of them raises: a potion `UseItem` aimed at another member, and a temple service that would do nothing. Other logs replay without an error but play out differently. That happens with *sleep* cast at a drained monster, a lair the party left and came back to, a rest in a dungeon, a rest that a wandering monster interrupted, a trap save with a `modifier`, or a chute trap where every member passed the save. In battle, it also happens with a monster that regenerates (a troll, a thoul, or a vampire), a monster whose attack has an effect (like a ghoul's paralysing claw or a wight's level drain), a basilisk's or a medusa's gaze, a melee attack on a petrified creature, or a round in which the whole party retreats and a monster attacks a member before the party moves off or while the monster is confused. Each of these changes has an entry in this section or under Fixed. Loading a 2.0.1 save with [`load_game`][osrlib.persistence.load_game] isn't affected, because a load restores the saved state and runs no commands. The loaded game then plays on under this release's rules. ### Fixed +- A retreat by the whole party now makes its members easier to hit for the whole round, as the OSE SRD's retreat says: "This round: ... the opponent gains a +2 bonus to all attacks against the retreating combatant and ignores any AC bonus due to the combatant's shield". Before, the penalty started only when the party moved off, so monsters that won initiative and attacked first got no bonus and counted the shield. A confused monster's attack never got it. Now every attack the monster side makes on the party in that round gets +2 and ignores the shield, whichever side acts first. No golden file changed. - A ring of regeneration no longer heals fire or acid damage, as the ring's entry in the OSE SRD says. Before, the ring healed every kind of damage at 1 hit point a round. Now [`deal_damage`][osrlib.core.combat.deal_damage] records fire and acid damage to the wearer in the new field [`Character.nonregen_damage`][osrlib.core.character.Character.nonregen_damage], and the ring heals the wearer only up to their maximum hit points less that damage. The ring still heals the rest, and a potion or a cure spell still heals all of it. [`DamageDealtEvent`][osrlib.core.events.DamageDealtEvent]`.non_regenerable` is now True when fire or acid hits a wearer. The new field defaults to 0, so a save from before this change still loads and [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] stays 4. Saves and party documents now include the field, so the golden files that contain characters were regenerated to add it. - *Sleep* judges a drained monster by the Hit Dice it has left, because energy drain takes Hit Dice away. Before, both of the spell's modes read the monster's template. The single-creature mode takes only a creature of exactly 4+1 Hit Dice: 4 Hit Dice with a bonus, no more and no fewer. An ogre drained from 4+1 to 3+1 Hit Dice is no longer a 4+1 creature, so this mode now has no effect on it. Before, the mode put that ogre to sleep. The 2d8 Hit Dice mode takes creatures of 4 Hit Dice or fewer and leaves out 4+1 creatures. It now takes the drained ogre for 3 Hit Dice of its budget. Before, it left the ogre out as a 4+1 creature. This fix covers the 4+1 test in both modes. The [`effective_hd`][osrlib.core.combat.effective_hd] fix below covers the rest of the 2d8 mode: what each creature costs from the budget, and whether it's under the 4 Hit Dice limit. With that fix, a 5 Hit Dice grizzly bear drained to 4 is eligible for the 2d8 mode too. - When a wandering monster interrupts a rest, the full turns the party rested before the monsters came now count (#169). Before, a 48-turn night broken after four full turns cleared neither fatigue nor exhaustion. Now one full turn of rest clears fatigue. Each full turn also counts toward the three that clear exhaustion from a chase, and the next rest adds to that count. A rest interrupted during its first turn still clears nothing. An interrupted night still isn't sleep for preparing spells, and an interrupted day still heals nobody. The fix makes no new draws, and no golden file changed. diff --git a/src/osrlib/core/combat.py b/src/osrlib/core/combat.py index b80d186..c85b0d9 100644 --- a/src/osrlib/core/combat.py +++ b/src/osrlib/core/combat.py @@ -391,8 +391,11 @@ class AttackContext(BaseModel): """Whether the defender is retreating. The attacker gets +2 to hit and the defender's shield doesn't count. A - [`GameSession`][osrlib.crawl.session.GameSession] sets it for a monster group that's - fleeing and for a party that declared a retreat. + [`GameSession`][osrlib.crawl.session.GameSession] sets it for an attack on a monster + group that's fleeing. It also sets it for every attack the monster side makes on a party + member who declared a retreat that round, alone or with the whole party. It's set from the + start of the round, so it holds against monsters that act before the member moves. A + retreat by the whole party sets it for every member. """ braced: bool = False diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index d368a6c..38aed10 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -25,6 +25,8 @@ - [`SpellDeclaredEvent`][osrlib.crawl.events.SpellDeclaredEvent] for each spell declared that round. - [`GroupMovedEvent`][osrlib.crawl.events.GroupMovedEvent] when a group's distance changes. +- [`MemberWithdrewEvent`][osrlib.crawl.events.MemberWithdrewEvent] when a member who withdraws + alone trades places with a member from a back rank. - [`MonsterFledEvent`][osrlib.crawl.events.MonsterFledEvent] when a group breaks and runs, and when the turned members of a group break away as a new group. - [`MonstersLeftBehindEvent`][osrlib.crawl.events.MonstersLeftBehindEvent] for helpless members a @@ -60,7 +62,9 @@ marching order. Under the ruleset's `formation_width_limit` flag, a rank is as wide as the space the party stands in, divided by [`FIGHTER_FRONTAGE_FEET`][osrlib.crawl.battle.FIGHTER_FRONTAGE_FEET]: two abreast in a ten-foot -corridor, and in a room, as many as the room's shorter side allows. +corridor, and in a room, as many as the room's shorter side allows. A front-rank member steps back +out of melee by trading places with a member behind, whom the member names in +[`BattleDeclaration.replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id]. During a round, the session also applies these rules without a command for them: @@ -190,8 +194,8 @@ """ import math -from collections.abc import Mapping, Sequence -from typing import Any, Protocol +from collections.abc import Collection, Mapping, Sequence +from typing import Any, NamedTuple, Protocol from pydantic import BaseModel, ConfigDict @@ -249,6 +253,7 @@ BattleRoundEvent, BattleStartedEvent, GroupMovedEvent, + MemberWithdrewEvent, MonsterFledEvent, MonstersLeftBehindEvent, SpellDeclaredEvent, @@ -1362,61 +1367,187 @@ def _group_by_id(session, group_id: str | None): _DEFENSIVE_MOVES = ("fighting_withdrawal", "retreat") -"""The two `move` values the whole formation has to agree on, the SRD's two ways out of melee. +"""The SRD's two ways out of melee, the `move` values a member makes either with the formation or alone. -The order is the order the rejections come back in, the fighting withdrawal's first. `close` is not -one of them, because it needs no agreement: the first `close` in marching order advances the -formation whenever the round is accepted. A member who declares `close` while others declare a -defensive move is one of the declarers who didn't make that move, so the round is split. +When every declarer makes the same one, the whole formation makes it. Otherwise each is the +declaring member's own: a swap with a member from a back rank. `close` is not one of them, because +it needs no agreement: the first `close` advances the formation whenever the round is accepted. """ +_RANK_DEPTH_FEET = 5 +"""How far apart the party's ranks stand, front to back, in feet. -def _formation_split_rejections(declarers, declarations: Sequence[BattleDeclaration]) -> list[Rejection]: - """Refuse a defensive move that some of the round's declarers made and the rest did not. +A member who withdraws alone and the replacement who steps up into their place each cover this +distance once for every rank between them, so a replacement two ranks back is ten feet away. +""" + + +class _Withdrawal(NamedTuple): + """A fighting withdrawal or a retreat that one member makes alone, as the movement step makes it.""" + + member_id: str + """The front-rank member who steps back.""" + replacement_id: str + """The back-rank member who steps up into their place.""" + move: str + """`fighting_withdrawal` or `retreat`, which picks the event's code.""" + + +def _formation_move(declarations: Sequence[BattleDeclaration]) -> str | None: + """Return the defensive move the whole formation makes this round, or `None` when it makes none. + + The formation makes a `fighting_withdrawal` or a `retreat` when every declaration is that same + move. A round whose only declarer declares one is a formation move too, because everyone agreed. + A member who can't declare at all, being dead or incapacitated, doesn't count. Any other + defensive move is the declaring member's own (see `_plan_withdrawals`). + """ + moves = [declaration.move if declaration.action == "move" else None for declaration in declarations] + first = moves[0] if moves else None + if first in _DEFENSIVE_MOVES and all(move == first for move in moves): + return first + return None - The party moves as one formation and a member cannot leave it (see the - [adaptations register](https://mmacy.github.io/osrlib-python/adaptations/), under the Bard's - Tale convention), so a `fighting_withdrawal` or a `retreat` is a legal declaration only when - every declarer makes the same one. A round whose only declarer declares one is legal, because - everyone agreed, and a member who cannot declare at all, being dead or incapacitated, is no - declarer and does not count. + +def _plan_withdrawals( + session, declarers, declarations: Sequence[BattleDeclaration] +) -> tuple[list[Rejection], list[_Withdrawal]]: + """Judge each defensive move a member makes alone, and plan the swaps the movement step makes. + + A `fighting_withdrawal` or a `retreat` that not every declarer makes is the declaring member's + own (see the [adaptations register](https://mmacy.github.io/osrlib-python/adaptations/), under + the battle round). The member has to stand in the front rank and name a replacement who can step + up (see `_can_step_up`). Both members have to cover the distance between their ranks: the + withdrawing member at up to half their encounter rate for a fighting withdrawal or their full + rate for a retreat, and the replacement at up to their full rate, each doubled by their own + *haste*. Each withdrawing member gets the first of the four rejections that applies, in the + order this function checks them. + + In a formation move every declarer is withdrawing, so nobody is free to step up, and a + declaration in it that names a replacement is refused with + `battle.declaration.replacement_unavailable`. + + The checks read state and take no draw, so a refused round costs nothing. Args: + session (osrlib.crawl.session.GameSession): The battle's session. declarers: The living, able members who have to declare this round, in marching order. - declarations: The round's declarations, one per declarer, in the order the caller sent them. + declarations: The round's declarations, one per declarer. Returns: - One `battle.declaration.formation_split` rejection per defensive move at least one declarer - chose and at least one did not, naming the `move`, the `declared` ids, and the `others`, - each id tuple in marching order. `others` is every other declarer of the round, the one who - chose the other defensive move included, so a round that splits on both moves comes back - with two rejections, each naming the other's declarers among its `others`. Those two arrive - in the order `_DEFENSIVE_MOVES` lists them, the fighting withdrawal first and the retreat - second. Empty when the formation agrees. + The rejections, in marching order, and the swaps of the members who withdraw alone, in + marching order of the withdrawing members. The swaps stand only when there are no + rejections. """ - moves = {declaration.character_id: declaration.move for declaration in declarations if declaration.action == "move"} - order = [member.id for member in declarers] + by_id = {declaration.character_id: declaration for declaration in declarations} rejections: list[Rejection] = [] - for move in _DEFENSIVE_MOVES: - declared = tuple(member_id for member_id in order if moves.get(member_id) == move) - others = tuple(member_id for member_id in order if moves.get(member_id) != move) - if declared and others: + if _formation_move(declarations) is not None: + for member in declarers: + replacement_id = by_id[member.id].replacement_id + if replacement_id is not None: + rejections.append( + Rejection( + code="battle.declaration.replacement_unavailable", + params={"character": member.id, "replacement": replacement_id}, + ) + ) + return rejections, [] + rank_of = {member.id: index for index, rank in enumerate(_party_ranks(session)) for member in rank} + able = {member.id for member in declarers} + named: set[str] = set() + plan: list[_Withdrawal] = [] + for member in declarers: + declaration = by_id[member.id] + if declaration.action != "move" or declaration.move not in _DEFENSIVE_MOVES: + continue + if rank_of[member.id] != 0: + rejections.append(Rejection(code="battle.declaration.not_in_front_rank", params={"character": member.id})) + continue + replacement_id = declaration.replacement_id + if replacement_id is None: + rejections.append(Rejection(code="battle.declaration.no_replacement", params={"character": member.id})) + continue + claimed = replacement_id in named + named.add(replacement_id) + if claimed or not _can_step_up(session, replacement_id, rank_of, able, by_id): rejections.append( Rejection( - code="battle.declaration.formation_split", - params={"move": move, "declared": declared, "others": others}, + code="battle.declaration.replacement_unavailable", + params={"character": member.id, "replacement": replacement_id}, ) ) - return rejections + continue + distance = rank_of[replacement_id] * _RANK_DEPTH_FEET + own_rate = _encounter_rate(member, session) + if declaration.move == "fighting_withdrawal": + own_rate //= 2 + own_reach = own_rate * _haste_multiplier(session, member.id, "movement_multiplier") + replacement = session.member(replacement_id) + their_reach = _encounter_rate(replacement, session) * _haste_multiplier( + session, replacement_id, "movement_multiplier" + ) + if distance > min(own_reach, their_reach): + rejections.append( + Rejection( + code="battle.declaration.replacement_too_far", + params={"character": member.id, "replacement": replacement_id, "distance_feet": distance}, + ) + ) + continue + plan.append(_Withdrawal(member_id=member.id, replacement_id=replacement_id, move=declaration.move)) + return rejections, plan + + +def _can_step_up( + session, + replacement_id: str, + rank_of: Mapping[str, int], + able: set[str], + by_id: Mapping[str, BattleDeclaration], +) -> bool: + """Whether a named replacement can step up into the front rank this round. + + The replacement has to be a living member behind the front rank, so an unknown id, a dead + member, and a member of the front rank are all out. They have to be able to declare and to move. + Their own declaration can't be a `cast`, because "The spell caster cannot move and cast a spell + in the same round", or a defensive move, which takes a member out of the front rank, not into it. + """ + if rank_of.get(replacement_id, 0) == 0 or replacement_id not in able: + return False + if cannot_move(session.member(replacement_id)): + return False + declaration = by_id[replacement_id] + if declaration.action == "cast": + return False + return not (declaration.action == "move" and declaration.move in _DEFENSIVE_MOVES) + +def _projected_front_rank(session, plan: Sequence[_Withdrawal]) -> set[str]: + """The ids of the front rank as the round's planned swaps leave it, for judging melee declarations. -def _validate_declaration(session, declaration: BattleDeclaration, member) -> list[Rejection]: + A replacement stands in the front rank from the movement step on, so they may declare a melee + attack in the round they step up. The members the swaps leave in place keep their ranks. + """ + order = [member.id for member in session.party.living_members()] + for swap in plan: + front, back = order.index(swap.member_id), order.index(swap.replacement_id) + order[front], order[back] = order[back], order[front] + width = _formation_width(session) + return set(order if width is None else order[:width]) + + +def _validate_declaration( + session, declaration: BattleDeclaration, member, *, front_rank: set[str] | None = None +) -> list[Rejection]: """Judge one declaration against the state it refers to, and return every reason it cannot stand. This runs twice on the declarations the round accepts. Once in the validation pre-phase, where a rejection refuses the whole command, and again in the magic phase, immediately before a `cast` resolves, because the phases between the two can change what these checks read. It reads state and takes no draw, so running it a second time costs nothing and changes nothing. + + The pre-phase passes `front_rank`, the ids of the front rank as the round's swaps will leave it + (see `_projected_front_rank`), so a replacement who steps up may declare a melee attack. Without + it, a melee attack is judged against the front rank as it stands. """ state = session.battle if declaration.action == "hold": @@ -1446,7 +1577,9 @@ def _validate_declaration(session, declaration: BattleDeclaration, member) -> li missile = _is_missile_declaration(weapon, group.distance_feet) if not missile: width = _formation_width(session) - if width is not None and member not in _party_front_rank(session): + if front_rank is None: + front_rank = {front.id for front in _party_front_rank(session)} + if width is not None and member.id not in front_rank: return [Rejection(code="battle.declaration.not_in_front_rank", params={"character": member.id})] if group.distance_feet > MELEE_RANGE_FEET: return [Rejection(code="combat.attack.out_of_reach", params={"distance_feet": group.distance_feet})] @@ -1879,10 +2012,12 @@ def _handle_resolve_battle_round(session, command: ResolveBattleRound) -> tuple[ return rejections, [] by_member = {} for declaration in command.declarations: - member = session.member(declaration.character_id) - by_member[declaration.character_id] = (member, declaration) - rejections.extend(_validate_declaration(session, declaration, member)) - rejections.extend(_formation_split_rejections(declarers, command.declarations)) + by_member[declaration.character_id] = (session.member(declaration.character_id), declaration) + withdrawal_rejections, swaps = _plan_withdrawals(session, declarers, command.declarations) + front_rank = _projected_front_rank(session, swaps) + for member, declaration in by_member.values(): + rejections.extend(_validate_declaration(session, declaration, member, front_rank=front_rank)) + rejections.extend(withdrawal_rejections) if rejections: # The session rejects the whole command and lists every rejection. Partial # acceptance would complicate replay (see the adaptations register). @@ -1924,14 +2059,21 @@ def _handle_resolve_battle_round(session, command: ResolveBattleRound) -> tuple[ acted: set[str] = set() fired_this_round: list[str] = [] fire_damaged_groups: set[str] = set() - party_retreating = False + formation = _formation_move(command.declarations) + retreating = _retreating_members(session, command.declarations, formation) + # The members who stepped back out of the front rank this round, and the replacements + # whose swap didn't happen. The movement step fills both. + withdrawn: set[str] = set() + stranded: set[str] = set() slow_attacks: list[tuple[Creature, BattleDeclaration]] = [] def party_block() -> list[Event]: - nonlocal party_retreating block: list[Event] = [] - block.extend(_party_movement(session, by_member)) - party_retreating = _party_is_retreating(session, by_member) + block.extend(_party_movement(session, by_member, swaps)) + swapped = [event for event in block if isinstance(event, MemberWithdrewEvent)] + withdrawn.update(event.character_id for event in swapped) + stranded.update(swap.replacement_id for swap in swaps) + stranded.difference_update(event.replacement_id for event in swapped) block.extend( _party_attacks( session, @@ -1940,6 +2082,7 @@ def party_block() -> list[Event]: slow_attacks=slow_attacks, fired=fired_this_round, fire_damaged=fire_damaged_groups, + stranded=stranded, ) ) block.extend(_party_magic(session, by_member, pending_casters, disrupted, acted, state)) @@ -1951,6 +2094,7 @@ def party_block() -> list[Event]: slow_attacks=slow_attacks, fired=fired_this_round, fire_damaged=fire_damaged_groups, + stranded=stranded, ) ) block.extend(_confused_party_overrides(session, by_member, fire_damaged_groups)) @@ -1970,7 +2114,8 @@ def monster_block() -> list[Event]: disrupted=disrupted, acted=acted, fire_damaged=fire_damaged_groups, - party_retreating=party_retreating, + retreating=retreating, + withdrawn=withdrawn, npc_actions=npc_actions, ) @@ -1997,7 +2142,7 @@ def monster_block() -> list[Event]: if session.battle is not None: state.fired_last_round = fired_this_round events.extend(session.advance_rounds(1)) - end = _check_ends(session, party_retreating=party_retreating) + end = _check_ends(session, party_retreating=formation == "retreat") if end is not None: events.extend(end) return [], events @@ -2011,52 +2156,53 @@ def _party_acts_first(initiative, by_member) -> bool: return True -def _party_is_retreating(session, by_member) -> bool: - declarations = [declaration for _, declaration in by_member.values()] - return bool(declarations) and all( - declaration.action == "move" and declaration.move == "retreat" for declaration in declarations +def _retreating_members(session, declarations: Sequence[BattleDeclaration], formation: str | None) -> frozenset[str]: + """Return the ids of the party members a retreat exposes for the whole round. + + The OSE SRD's retreat lasts the round: "This round: ... the opponent gains a +2 bonus to all + attacks against the retreating combatant and ignores any AC bonus due to the combatant's shield". + The exposure starts when the round is declared, so it holds against a side that acts before the + party moves. A formation retreat moves the whole party off, so it exposes every member. Otherwise + it exposes each member who retreats alone, and no one else. + """ + if formation == "retreat": + return frozenset(member.id for member in session.party.members if member.id is not None) + return frozenset( + declaration.character_id + for declaration in declarations + if declaration.action == "move" and declaration.move == "retreat" ) -def _party_movement(session, by_member) -> list[Event]: - """Consolidated formation movement, in order of precedence: retreat, fighting withdrawal, close. +def _party_movement(session, by_member, swaps: Sequence[_Withdrawal]) -> list[Event]: + """Resolve the party's movement step: the formation's defensive move, or each swap and then a `close`. - The party moves as a single formation and an individual member cannot leave - it, as a documented adaptation (see the - [adaptations register](https://mmacy.github.io/osrlib-python/adaptations/), - under the Bard's Tale convention). Every member retreating moves the party off at the full encounter - rate, the OSE SRD's "full encounter movement rate", and the running pursuit - begins once the battle converts. Every member declaring a fighting withdrawal - backs the party off at half encounter rate, and that declaration is a move on - its own, so the withdrawing party attacks nobody that round. Otherwise the first - `close` declaration in marching order advances the formation on its named - group at encounter rate, stopping at 5'. + A defensive move that every declarer makes moves the whole formation (see the + [adaptations register](https://mmacy.github.io/osrlib-python/adaptations/), under the battle + round). Every member retreating moves the party off at the full encounter rate, the OSE SRD's + "full encounter movement rate", and the running pursuit begins once the battle converts. Every + member declaring a fighting withdrawal backs the party off at half encounter rate, and that + declaration is a move on its own, so the withdrawing party attacks nobody that round. - A round the formation does not agree on never gets here: `_formation_split_rejections` refuses a - defensive move some declarers made and the rest did not, in the validation pre-phase, with - `battle.declaration.formation_split`. + Otherwise the members who withdraw alone trade places with their replacements first (see + `_swap_ranks`). Then the first `close` in the round's declarations advances the formation on its + named group at encounter rate, stopping at 5'. """ declarations = [declaration for _, declaration in by_member.values()] events: list[Event] = [] multiplier = _party_move_multiplier(session) - if declarations and all(d.action == "move" and d.move == "retreat" for d in declarations): + formation = _formation_move(declarations) + if formation is not None: rates = [_encounter_rate(member, session) for member in session.party.living_members()] - rate = min(rates, default=0) * multiplier - for group in session.encounter.groups: - if group.fled: - continue - group.distance_feet += rate - events.append(GroupMovedEvent(group_id=group.id, distance_feet=group.distance_feet)) - return events - if declarations and all(d.action == "move" and d.move == "fighting_withdrawal" for d in declarations): - rates = [_encounter_rate(member, session) for member in session.party.living_members()] - rate = (min(rates, default=0) // 2) * multiplier + slowest = min(rates, default=0) + rate = (slowest if formation == "retreat" else slowest // 2) * multiplier for group in session.encounter.groups: if group.fled: continue group.distance_feet += rate events.append(GroupMovedEvent(group_id=group.id, distance_feet=group.distance_feet)) return events + events.extend(_swap_ranks(session, swaps)) for _member, declaration in by_member.values(): if declaration.action == "move" and declaration.move == "close": group = _group_by_id(session, declaration.target_group_id) @@ -2070,6 +2216,37 @@ def _party_movement(session, by_member) -> list[Event]: return events +def _swap_ranks(session, swaps: Sequence[_Withdrawal]) -> list[Event]: + """Trade each withdrawing member's place in the marching order with their replacement's. + + The swaps run in the marching order of the withdrawing members, as `_plan_withdrawals` planned + them. A swap happens only if, when the movement step comes, the withdrawing member still stands + in the front rank, the replacement still stands behind it, and both can still move. A side that + acted first can change any of that. Otherwise the two stay where they are and nothing reports + the swap. The order stays changed after the round. A swap takes no draw. + + Returns: + A `MemberWithdrewEvent` for each swap that happened. + """ + events: list[Event] = [] + for swap in swaps: + ranks = _party_ranks(session) + front = {member.id for member in ranks[0]} if ranks else set() + behind = {member.id for rank in ranks[1:] for member in rank} + if swap.member_id not in front or swap.replacement_id not in behind: + continue + if cannot_move(session.member(swap.member_id)) or cannot_move(session.member(swap.replacement_id)): + continue + members = list(session.party.members) + ids = [member.id for member in members] + here, there = ids.index(swap.member_id), ids.index(swap.replacement_id) + members[here], members[there] = members[there], members[here] + session.party.members = members + code = "battle.member.retreated" if swap.move == "retreat" else "battle.member.withdrew" + events.append(MemberWithdrewEvent(code=code, character_id=swap.member_id, replacement_id=swap.replacement_id)) + return events + + def _party_move_multiplier(session) -> int: """*Haste*'s movement multiplier applies only when every living party member is under it. @@ -2082,7 +2259,14 @@ def _party_move_multiplier(session) -> int: return min(_haste_multiplier(session, member.id, "movement_multiplier") for member in living) -def _party_attacks(session, by_member, *, missile: bool, slow_attacks, fired, fire_damaged) -> list[Event]: +def _party_attacks( + session, by_member, *, missile: bool, slow_attacks, fired, fire_damaged, stranded: Collection[str] = () +) -> list[Event]: + """Resolve the party's missile or melee attacks, and queue the slow ones for the end of the round. + + `stranded` names the replacements whose swap didn't happen. They declared a melee attack from the + front rank they were to step into, so they never reach it and their melee attack doesn't resolve. + """ events: list[Event] = [] for member, declaration in by_member.values(): if declaration.action != "attack" and not (declaration.action == "use_item" and missile): @@ -2101,6 +2285,8 @@ def _party_attacks(session, by_member, *, missile: bool, slow_attacks, fired, fi is_missile = _is_missile_declaration(weapon, group.distance_feet) if is_missile != missile: continue + if not is_missile and member.id in stranded: + continue combat_facet = getattr(weapon, "combat", None) facet = combat_facet if combat_facet is not None else weapon if facet is not None and WeaponQuality.SLOW in getattr(facet, "qualities", ()): @@ -2600,9 +2786,17 @@ def _monster_block( disrupted: set | None = None, acted: set | None = None, fire_damaged: set | None = None, - party_retreating: bool = False, + retreating: Collection[str] = frozenset(), + withdrawn: Collection[str] = frozenset(), npc_actions: dict[str, list[MonsterAction]] | None = None, ) -> list[Event]: + """Resolve the monster side's block: morale, flight, gazes, and the actions each group's policy chose. + + `retreating` names the party members a retreat exposes this round (see `_retreating_members`), + and every attack against one of them gets the retreat's penalties. `withdrawn` names the members + who stepped back out of the front rank earlier in the round. A melee attack chosen against one of + them is skipped, because melee no longer reaches them. + """ from osrlib.crawl.session import MONSTER_ACTION_STREAM pending_casters = pending_casters or {} @@ -2633,7 +2827,7 @@ def _monster_block( actions = npc_actions[group.id] else: actions = _policy_for(session, group).choose(session, group, session.streams.get(MONSTER_ACTION_STREAM)) - events.extend(_confused_overrides(session, group, actions)) + events.extend(_confused_overrides(session, group, actions, retreating=retreating)) moved = False for action in actions: monster = session.combatant(action.monster_id) @@ -2653,21 +2847,24 @@ def _monster_block( _watch_disruption(events, pending_casters, disrupted, acted) elif action.kind == "melee": target = session.registry().get(action.target_id) - if target is None or has_condition(target, Condition.DEAD): + if target is None or has_condition(target, Condition.DEAD) or action.target_id in withdrawn: continue + exposed = action.target_id in retreating if getattr(monster, "definition", None) is not None: events.extend( - _resolve_npc_attack(session, monster, target, group, missile=False, retreating=party_retreating) + _resolve_npc_attack(session, monster, target, group, missile=False, retreating=exposed) ) else: - events.extend(_resolve_monster_melee(session, monster, target, party_retreating=party_retreating)) + events.extend(_resolve_monster_melee(session, monster, target, retreating=exposed)) _watch_disruption(events, pending_casters, disrupted, acted) elif action.kind == "npc_shoot": target = session.registry().get(action.target_id) if target is None or has_condition(target, Condition.DEAD): continue events.extend( - _resolve_npc_attack(session, monster, target, group, missile=True, retreating=party_retreating) + _resolve_npc_attack( + session, monster, target, group, missile=True, retreating=action.target_id in retreating + ) ) _watch_disruption(events, pending_casters, disrupted, acted) elif action.kind == "npc_cast": @@ -2929,12 +3126,13 @@ def _group_morale(session, group, fire_damaged) -> list[Event]: return events -def _confused_overrides(session, group, actions) -> list[Event]: +def _confused_overrides(session, group, actions, *, retreating: Collection[str] = frozenset()) -> list[Event]: """The confusion check the round handler runs each round: re-save, then the 2d6 behavior roll. The re-save runs on the magic stream, and the behavior roll and its pick of a target in its group run on the combat stream. The round handler makes these rolls, - distinct from the ledger's tick-time effects. + distinct from the ledger's tick-time effects. A confused monster that attacks a party + member in `retreating` gets the retreat's penalties, as every attack on that member does. """ events: list[Event] = [] for monster in _living_monsters(session, group): @@ -2970,12 +3168,12 @@ def _confused_overrides(session, group, actions) -> list[Event]: targets = _reachable_targets(session, monster, pool) if targets and group.distance_feet <= MELEE_RANGE_FEET: target = targets[session.streams.get(COMBAT_STREAM).randbelow(len(targets))] - events.extend(_resolve_monster_melee(session, monster, target, party_retreating=False)) + events.extend(_resolve_monster_melee(session, monster, target, 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, party_retreating=False)) + events.extend(_resolve_monster_melee(session, monster, target, retreating=False)) # no_action: the confused creature babbles. return events @@ -2989,7 +3187,7 @@ def _behaviour_outcome(params, total: int) -> str: return "no_action" -def _resolve_monster_melee(session, monster, target, *, party_retreating: bool) -> list[Event]: +def _resolve_monster_melee(session, monster, target, *, retreating: bool) -> list[Event]: """Resolve a monster's attack routine against one target, with what each hit does beyond damage. An attack named `gaze` takes no attack roll, so this function skips it. `_resolve_gazes` runs a @@ -3013,7 +3211,7 @@ def _resolve_monster_melee(session, monster, target, *, party_retreating: bool) continue context = AttackContext( distance_feet=MELEE_RANGE_FEET, - defender_retreating=party_retreating, + defender_retreating=retreating, defender_ally_ac_bonus=_ally_protection_bonus(session, target), ) result = resolve_attack( diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 6359d85..38df8f7 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -533,8 +533,12 @@ class ReorderParty(Command): Marching order is the order the members walk in, front first. It determines who a door trap springs on, who reaches into a treasure cache, and who stands in the front rank when a - fight starts. This command is the only way to change it. It's legal in town and while - exploring, and the order is fixed once an encounter or battle has begun. + fight starts. This command sets it. It's legal in town and while exploring, and the + session refuses it once an encounter or battle has begun. In a battle, the order changes + only when a member makes a fighting withdrawal or a retreat alone and trades places with + the member named in + [`BattleDeclaration.replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id]. + That change stays after the battle, until this command sets the order again. Modes: `town`, `exploring` @@ -2555,16 +2559,23 @@ class ResolveBattleRound(Command): recognize. A `BattleDeclaration` with one already fails validation when it's constructed, so normal play never reaches this check. - Move declarations: `battle.declaration.missing_move`, - `battle.declaration.unknown_group`, `battle.declaration.cannot_move`, and - `battle.declaration.formation_split` when some declarers make a - `fighting_withdrawal` or a `retreat` and others don't. There is one - `formation_split` for each of the two moves the round splits on. Its `others` - lists every other declarer of the round, including any who chose the other - move. So a round that splits on both moves comes back with two, the fighting - withdrawal's first and the retreat's second. + `battle.declaration.unknown_group`, and `battle.declaration.cannot_move`. + - A `fighting_withdrawal` or a `retreat` that a member makes alone gets at most + one of these, the first that applies: + `battle.declaration.not_in_front_rank` when the member stands behind the front + rank, `battle.declaration.no_replacement` when the declaration names no + `replacement_id`, `battle.declaration.replacement_unavailable` when the named + member can't step up, and `battle.declaration.replacement_too_far` when either + member can't cover the distance between their ranks. Each has the withdrawing + member's id as its `character` param. The last two also have the replacement's + id as `replacement`, and `replacement_too_far` has the distance as + `distance_feet`. A declaration in a formation move that names a + `replacement_id` gets `replacement_unavailable`, because every declarer is + moving with the formation. - Attack declarations: `battle.declaration.unknown_group`, `battle.declaration.no_target`, `battle.declaration.weapon_not_wielded`, - `battle.declaration.not_in_front_rank`, and the kernel attack checks + `battle.declaration.not_in_front_rank` for a melee attack from outside the front + rank as the round's swaps leave it, and the kernel attack checks `combat.attack.out_of_reach`, `combat.attack.out_of_range`, `combat.attack.reload`, `combat.attack.attacker_incapacitated`, `combat.attack.attacker_blind`. diff --git a/src/osrlib/crawl/party.py b/src/osrlib/crawl/party.py index bd5c409..c18c568 100644 --- a/src/osrlib/crawl/party.py +++ b/src/osrlib/crawl/party.py @@ -7,9 +7,12 @@ session moves the party as one group. `session.party` is the same object you passed. The order of the member list is the marching order, and it isn't recorded anywhere else, so the -first member is the one in front. [`ReorderParty`][osrlib.crawl.commands.ReorderParty] is the -only command that changes that order. The session calls -[`reorder`][osrlib.crawl.party.Party.reorder] when it accepts one. +first member is the one in front. [`ReorderParty`][osrlib.crawl.commands.ReorderParty] sets that +order outside a fight, and the session calls [`reorder`][osrlib.crawl.party.Party.reorder] when it +accepts one. In a battle, a member who makes a fighting withdrawal or a retreat alone trades places +with the member named in +[`BattleDeclaration.replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id], and +the order stays that way after the battle. Dead members stay in the list, with their gear still on them. No command removes a member. The rules that count party members skip the dead: movement rate, combat ranks, traps, and food and @@ -74,9 +77,11 @@ class Party(BaseModel): """The party's characters, in marching order: index 0 walks in front and meets whatever the party walks into first. At least one member is required. [`GameSession.new`][osrlib.crawl.session.GameSession.new] assigns ids when the party joins a - session, so a member you just built has `None` as its `id` until then. Change the order by - executing [`ReorderParty`][osrlib.crawl.commands.ReorderParty], not by assigning to this field, - so the change is logged and a replay reproduces it.""" + session, so a member you just built has `None` as its `id` until then. Change the order with a + command, not by assigning to this field, so the change is logged and a replay reproduces it. + Outside a fight, execute [`ReorderParty`][osrlib.crawl.commands.ReorderParty]. In a battle, a + member who withdraws alone names the member to trade places with in + [`BattleDeclaration.replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id].""" def living_members(self) -> list[Character]: """Return the living members, in marching order. diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index 9121343..9ea5af7 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -615,9 +615,10 @@ def __init__( which doesn't validate the rest of a saved adventure. """ self.party = party - """The party, in marching order. The order determines who is in the front rank in a fight, and - [`ReorderParty`][osrlib.crawl.commands.ReorderParty] is the only command that changes - it.""" + """The party, in marching order. The order determines who is in the front rank in a fight. + [`ReorderParty`][osrlib.crawl.commands.ReorderParty] sets it outside a fight. In a battle, a + member who withdraws alone trades places with the member named in + [`BattleDeclaration.replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id].""" self.adventure = adventure """The adventure being played: its town, dungeons, quests, and any content it bundles. It's frozen, and a save contains a copy of it, so a saved game needs no other file to diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 5ee0d49..6610e3e 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -359,14 +359,18 @@ class EncounterView(BaseModel): front_rank: tuple[str, ...] = () """The living members close enough to attack in melee, in marching order. That is the party's first rank at the current formation width, or every living member when the - ruleset's `formation_width_limit` flag is off. A melee attack declared for anyone - else is rejected with `battle.declaration.not_in_front_rank`, and inside melee reach a - weapon that is both melee and missile counts as a melee weapon.""" + ruleset's `formation_width_limit` flag is off. Only these members can make a + `fighting_withdrawal` or a `retreat` alone. A melee attack declared for anyone else is + rejected with `battle.declaration.not_in_front_rank`, and inside melee reach a weapon + that is both melee and missile counts as a melee weapon. The one exception is a member + whom a front-rank member names in + [`BattleDeclaration.replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id] + to step up this round, who may declare a melee attack.""" immobile: tuple[str, ...] = () """The declarers who can't move this round. In practice they're the entangled members, because the other conditions that stop a move stop every declaration as well. A `move` declaration of any kind from one of them is rejected with - `battle.declaration.cannot_move`.""" + `battle.declaration.cannot_move`, and none of them can step up as a replacement.""" reloading: tuple[str, ...] = () """The members who may not fire a `reload` weapon this round, because they fired one last round (`combat.attack.reload`). Empty when the ruleset's `weapon_reload` flag is diff --git a/tools/docs/rejection_codes.json b/tools/docs/rejection_codes.json index 3caa125..7877deb 100644 --- a/tools/docs/rejection_codes.json +++ b/tools/docs/rejection_codes.json @@ -1,12 +1,14 @@ { "battle.declaration.cannot_move": "A character declares a move while dead, paralyzed, petrified, asleep, or entangled (such as by a web spell), any of which makes movement impossible this round.", - "battle.declaration.formation_split": "Some of the characters declaring this round chose a fighting withdrawal or a retreat and the rest chose something else. The party moves as one formation, so a defensive move is a legal declaration only when every character declaring that round makes the same one. Closing on a group is not a defensive move and needs no such agreement.", "battle.declaration.invisible_target": "A spell or item-use declaration names a specific target that is currently invisible, and the engine won't let the caster target something they can't see.", "battle.declaration.item_unusable": "The declared item has no usable combat effect: it isn't a weapon-capable item in the character's inventory, or it's a magic item category (other than a potion, scroll, rod, staff, or wand) that has no defined combat use.", "battle.declaration.missing_move": "A move action is declared without specifying which kind of movement (closing distance, retreating, or a fighting withdrawal) the character is taking.", "battle.declaration.missing_spell": "A cast action is declared without specifying which spell to cast or which mode of that spell to use.", + "battle.declaration.no_replacement": "A front-rank character declares a fighting withdrawal or a retreat that not every character declaring that round makes, and names no member to step up into their place. A character who withdraws alone trades places with a member from a back rank, because a rank has no empty places.", "battle.declaration.no_target": "The chosen enemy group has no living, visible monster in its front rank that can currently be targeted.", - "battle.declaration.not_in_front_rank": "A character who isn't in the party's front rank declares a melee attack, and the formation-width rule in effect this round means only front-rank members are within melee range.", + "battle.declaration.not_in_front_rank": "A character who isn't in the party's front rank declares a melee attack, or a fighting withdrawal or a retreat that not every character declaring that round makes. The formation-width rule in effect this round means only front-rank members are within melee range, and only they can step back out of it. A member who steps up into the front rank this round as another character's replacement may declare a melee attack.", + "battle.declaration.replacement_too_far": "The member a character names to step up stands too many ranks back. Each rank stands 5 feet behind the one in front of it. The withdrawing character covers that distance at up to half their encounter movement rate for a fighting withdrawal, or their full rate for a retreat, and the replacement covers it at up to their full encounter rate. Haste doubles either limit for the member who has it.", + "battle.declaration.replacement_unavailable": "The member a character names to step up for their fighting withdrawal or retreat can't take the front place this round. The member isn't a living member behind the front rank, can't act or move, declared a spell or a defensive move of their own, or was already named by a withdrawing character earlier in marching order. A declaration that names a replacement in a fighting withdrawal or retreat the whole party makes gets this code too, because every member is moving with the party.", "battle.declaration.roster_mismatch": "The set of characters submitting declarations for the round doesn't exactly match the party members who are currently able to act and are expected to declare.", "battle.declaration.unknown_action": "The declaration's action type isn't one the engine recognizes as a legal move for the round (such as hold, move, attack, use_item, turn_undead, or cast).", "battle.declaration.unknown_group": "The declaration references a target group that doesn't exist in the current encounter, or that has already fled.", From 87bf50697e96803f61e3d259679cf2cbe9e33e7d Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 14:36:06 -0700 Subject: [PATCH 4/6] Contract: a charmed or confused replacement does not step up (chunk single-withdrawal) The movement step judges a swap again before it trades two members' places. A replacement who can no longer act on the party's behalf by then, because they are incapacitated, charmed, or confused, doesn't step up. Nothing reports the swap, the marching order stays, and the replacement's melee attack doesn't resolve. New acceptance tests, marked expected-to-fail, cover a charmed and a confused replacement. A wrapper around _swap_ranks sets the condition at the start of the movement step. An unmarked guard covers a paralysed replacement, which the implementation already handles. The register now says this in its movement-step sentence. It also says that any earlier withdrawing member's naming reserves the replacement, even when that member's own claim is refused, which matches the code. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- docs/adaptations.md | 4 +-- tests/test_single_withdrawal.py | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/docs/adaptations.md b/docs/adaptations.md index bcf1d24..058f1b2 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -370,9 +370,9 @@ Battle declarations arrive as one command per round with one declaration per liv The party moves as a formation when every declarer agrees (the Bard's Tale convention): all-retreat moves off at the full encounter rate (Combat.md's "full encounter movement rate", with the running pursuit beginning when the battle converts at round end), all-fighting-withdrawal backs off at half encounter rate, and the first `close` declaration advances the formation on its named group, stopping at 5'. A round whose only declarer declares a defensive move is a formation move, because everyone agreed. `close` needs no agreement, so a lone `close` advances the formation. The move set is the SRD's two defensive moves and `close`. Schema version 4 removed a fourth value, `withdraw`, and its lossless migration clears `withdraw` off a logged declaration and leaves a member who declared it holding, as that round played out. -A `fighting_withdrawal` or a `retreat` that not every declarer makes is the declaring member's own, as Combat.md describes both moves: "The combatant moves backwards at up to half their encounter movement rate" and "The combatant turns and flees from melee". It changes the ranks and leaves the range track alone. The member steps back out of the front rank and trades places in the marching order with the back-rank member the declaration names in `replacement_id`. Both moves need that replacement, because a rank has no empty places: the ranks are the living marching order cut into rows of the formation width, so a member leaves the front rank only by trading places with someone behind it. For a fighting withdrawal this is where the SRD's "There must be clear space behind the combatant" lands. The marching order stays changed after the battle, until a `ReorderParty` outside an encounter sets it again. A withdrawal alone is the only way the order changes in battle, and `ReorderParty` stays refused there. Only a front-rank member may withdraw alone. A back-rank member's defensive move is refused with `battle.declaration.not_in_front_rank`, a member who names no replacement with `battle.declaration.no_replacement`, and a member whose named replacement can't step up with `battle.declaration.replacement_unavailable`. The replacement must be a living, able member behind the front rank who can move, who hasn't declared a `cast` (Combat.md: "The spell caster cannot move and cast a spell in the same round") or a defensive move of their own, and whom no withdrawing member earlier in marching order named. Each rank stands 5' behind the one in front of it, so a replacement k ranks back is k × 5' away, and both members must cover that distance. The withdrawing member covers it at up to half their encounter rate for a fighting withdrawal or their full encounter rate for a retreat. The replacement covers it at up to their full encounter rate, because outside melee "Combatants can move up to their encounter movement rate each round". Each member's own *haste* doubles that member's limit. A swap either member can't cover is refused with `battle.declaration.replacement_too_far`. Each withdrawing member gets at most one of these four rejections, the first that applies in the order given here. With `formation_width_limit` off every living member stands in the one rank, so no member can withdraw alone. A withdrawal alone needs no group in melee, and it can share a round with a `close`. +A `fighting_withdrawal` or a `retreat` that not every declarer makes is the declaring member's own, as Combat.md describes both moves: "The combatant moves backwards at up to half their encounter movement rate" and "The combatant turns and flees from melee". It changes the ranks and leaves the range track alone. The member steps back out of the front rank and trades places in the marching order with the back-rank member the declaration names in `replacement_id`. Both moves need that replacement, because a rank has no empty places: the ranks are the living marching order cut into rows of the formation width, so a member leaves the front rank only by trading places with someone behind it. For a fighting withdrawal this is where the SRD's "There must be clear space behind the combatant" lands. The marching order stays changed after the battle, until a `ReorderParty` outside an encounter sets it again. A withdrawal alone is the only way the order changes in battle, and `ReorderParty` stays refused there. Only a front-rank member may withdraw alone. A back-rank member's defensive move is refused with `battle.declaration.not_in_front_rank`, a member who names no replacement with `battle.declaration.no_replacement`, and a member whose named replacement can't step up with `battle.declaration.replacement_unavailable`. The replacement must be a living, able member behind the front rank who can move, who hasn't declared a `cast` (Combat.md: "The spell caster cannot move and cast a spell in the same round") or a defensive move of their own, and whom no withdrawing member earlier in marching order named. Any earlier withdrawing member's naming reserves the replacement, even when that member's own claim is refused. Each rank stands 5' behind the one in front of it, so a replacement k ranks back is k × 5' away, and both members must cover that distance. The withdrawing member covers it at up to half their encounter rate for a fighting withdrawal or their full encounter rate for a retreat. The replacement covers it at up to their full encounter rate, because outside melee "Combatants can move up to their encounter movement rate each round". Each member's own *haste* doubles that member's limit. A swap either member can't cover is refused with `battle.declaration.replacement_too_far`. Each withdrawing member gets at most one of these four rejections, the first that applies in the order given here. With `formation_width_limit` off every living member stands in the one rank, so no member can withdraw alone. A withdrawal alone needs no group in melee, and it can share a round with a `close`. -The swaps resolve in the party's movement step, in the marching order of the withdrawing members and before the formation's `close`, and each reports a `MemberWithdrewEvent` (`battle.member.withdrew` or `battle.member.retreated`). A side that wins initiative over the party therefore strikes at the ranks as they stood when the round was declared. A swap happens only if, when the movement step comes, the withdrawing member still stands in the front rank, the replacement still stands behind it, and both can still move. Otherwise the two stay where they are and nothing reports the swap. From the movement step on, the replacement stands in the front rank, where monsters acting later can strike them. The replacement may declare a melee attack the round they step up, judged against the ranks the round's swaps produce, because "Movement and attacking may be combined in the same round". That attack resolves only if the swap happened. A melee attack the monster side chose against a member who has since withdrawn is skipped as no longer possible. A retreat's penalties fall on each member who retreats and on no one else, and last the whole round, whichever side acts first: "This round: ... the opponent gains a +2 bonus to all attacks against the retreating combatant and ignores any AC bonus due to the combatant's shield". A formation retreat is every declarer retreating, so it exposes every member that way, also to monsters that win initiative and strike before the party moves off. A retreat alone doesn't end the battle. +The swaps resolve in the party's movement step, in the marching order of the withdrawing members and before the formation's `close`, and each reports a `MemberWithdrewEvent` (`battle.member.withdrew` or `battle.member.retreated`). A side that wins initiative over the party therefore strikes at the ranks as they stood when the round was declared. A swap happens only if, when the movement step comes, the withdrawing member still stands in the front rank, the replacement still stands behind it, both can still move, and the replacement can still act on the party's behalf, which a member who is incapacitated, charmed, or confused can't. Otherwise the two stay where they are and nothing reports the swap. From the movement step on, the replacement stands in the front rank, where monsters acting later can strike them. The replacement may declare a melee attack the round they step up, judged against the ranks the round's swaps produce, because "Movement and attacking may be combined in the same round". That attack resolves only if the swap happened. A melee attack the monster side chose against a member who has since withdrawn is skipped as no longer possible. A retreat's penalties fall on each member who retreats and on no one else, and last the whole round, whichever side acts first: "This round: ... the opponent gains a +2 bonus to all attacks against the retreating combatant and ignores any AC bonus due to the combatant's shield". A formation retreat is every declarer retreating, so it exposes every member that way, also to monsters that win initiative and strike before the party moves off. A retreat alone doesn't end the battle. A member who makes a fighting withdrawal or a retreat makes no attack that round, alone or with the formation, because a member declares one thing per round and the move is that thing. For a retreat that is RAW: "the retreating combatant may not attack". A fighting withdrawal takes nothing away in RAW, and "Movement and attacking may be combined in the same round", but the round sequence resolves movement before missile attacks and melee. So by the melee step a member who withdrew alone stands behind the front rank, where melee doesn't reach, and a formation that withdrew stands out of melee range. RAW would still allow a missile attack after the move. That half is not represented, and is pinned as such: a withdrawing member's declaration that also names a weapon or a group makes no attack. Locked by `test_single_withdrawal.py`. diff --git a/tests/test_single_withdrawal.py b/tests/test_single_withdrawal.py index 340693f..3707c84 100644 --- a/tests/test_single_withdrawal.py +++ b/tests/test_single_withdrawal.py @@ -38,6 +38,7 @@ from osrlib.core.ruleset import Ruleset from osrlib.core.spells import MemorizedSpell, memorize_spells from osrlib.core.tables import ReactionResult +from osrlib.crawl import battle as battle_module from osrlib.crawl import encounter as encounter_module from osrlib.crawl.battle import MonsterAction from osrlib.crawl.commands import ( @@ -593,6 +594,63 @@ def test_a_strike_chosen_against_a_withdrawn_member_is_skipped(self): assert BRAKK not in {attack.defender_id for attack in _attacks(result)} +class TestAReplacementWhoCannotActWhenTheMomentComes: + """The swap is judged again in the movement step. A replacement who can no longer act on the party's + behalf by then doesn't step up: one who is incapacitated, charmed, or confused. The two stay where + they are, nothing reports the swap, and the replacement's melee attack doesn't resolve, because the + replacement never reached the front rank. + + The condition lands between acceptance and the movement step through a wrapper around the battle + module's `_swap_ranks`, which the party block calls to start its movement step. The confusion is a + bare condition with no ledger effect behind it, so the behavior roll that would override a confused + member's declaration never runs, and the outcome takes no draw. + """ + + @staticmethod + def _afflict_before_the_move(monkeypatch, member_id: str, condition: Condition) -> list[bool]: + original = battle_module._swap_ranks + ran: list[bool] = [] + + def afflicted(session, swaps): + member = session.member(member_id) + member.conditions = (*member.conditions, ActiveCondition(condition=condition, effect_id="effect-9999")) + ran.append(True) + return original(session, swaps) + + monkeypatch.setattr(battle_module, "_swap_ranks", afflicted) + return ran + + def _round_with_a_late_condition(self, monkeypatch, condition: Condition): + session = _battle(distance=5) + # Preconditions: Wynn stands behind the front rank, free to act, when the round is declared. + assert WYNN in _view(session).declarers + assert WYNN not in _view(session).front_rank + assert not has_condition(session.member(WYNN), condition) + ran = self._afflict_before_the_move(monkeypatch, WYNN, condition) + strike = BattleDeclaration(character_id=WYNN, action="attack", target_group_id=_group(session)) + result = _round(session, _withdraw(BRAKK, WYNN), strike) + assert result.accepted, _rejections(result) + # Preconditions: the condition landed in the movement step, and Wynn still bears it. + assert ran == [True] + assert has_condition(session.member(WYNN), condition) + return session, result + + @CHUNK + @pytest.mark.parametrize("condition", [Condition.CHARMED, Condition.CONFUSED]) + def test_a_charmed_or_confused_replacement_does_not_step_up(self, monkeypatch, condition): + session, result = self._round_with_a_late_condition(monkeypatch, condition) + assert _swaps(result) == [] + assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] + assert WYNN not in {attack.attacker_id for attack in _attacks(result)} + + def test_a_paralysed_replacement_does_not_step_up(self, monkeypatch): + """Unmarked guard: the implemented re-check already holds for a replacement who can't move.""" + session, result = self._round_with_a_late_condition(monkeypatch, Condition.PARALYSED) + assert _swaps(result) == [] + assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] + assert WYNN not in {attack.attacker_id for attack in _attacks(result)} + + @CHUNK class TestNoAttackWhileWithdrawing: """The move is the member's one declared action: a withdrawing member makes no attack that round.""" From 3f3287b2add00dcc8401db49231013840056123a Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 14:38:22 -0700 Subject: [PATCH 5/6] Keep a charmed or confused replacement from stepping up A replacement has to be able to act on the party's behalf: not incapacitated, charmed, or confused. One predicate, _acts_for_party, states that. _can_step_up asks it in the validation pre-phase, so a replacement who is already charmed or confused is refused with replacement_unavailable and the refusal takes no draw. _swap_ranks asks it again in the movement step, so a replacement who became charmed or confused after the round was accepted stays where they are. Nothing reports the swap, and their melee attack doesn't resolve. The MemberWithdrewEvent, _swap_ranks, and BattleDeclaration.replacement_id docstrings and the replacement_unavailable description state the rule. No golden file moved. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- src/osrlib/crawl/battle.py | 39 +++++++++++++++++++++++++-------- src/osrlib/crawl/commands.py | 15 +++++++++---- src/osrlib/crawl/events.py | 9 ++++---- tools/docs/rejection_codes.json | 2 +- 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 45fe9c7..110605f 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -1595,13 +1595,15 @@ def _can_step_up( """Whether a named replacement can step up into the front rank this round. The replacement has to be a living member behind the front rank, so an unknown id, a dead - member, and a member of the front rank are all out. They have to be able to declare and to move. - Their own declaration can't be a `cast`, because "The spell caster cannot move and cast a spell - in the same round", or a defensive move, which takes a member out of the front rank, not into it. + member, and a member of the front rank are all out. They have to be able to declare, to move, + and to act on the party's behalf (see `_acts_for_party`). Their own declaration can't be a + `cast`, because "The spell caster cannot move and cast a spell in the same round", or a + defensive move, which takes a member out of the front rank, not into it. """ if rank_of.get(replacement_id, 0) == 0 or replacement_id not in able: return False - if cannot_move(session.member(replacement_id)): + replacement = session.member(replacement_id) + if cannot_move(replacement) or not _acts_for_party(replacement): return False declaration = by_id[replacement_id] if declaration.action == "cast": @@ -1609,6 +1611,20 @@ def _can_step_up( return not (declaration.action == "move" and declaration.move in _DEFENSIVE_MOVES) +def _acts_for_party(member) -> bool: + """Whether a member can still act on the party's behalf, as a replacement who steps up has to. + + An incapacitated member can't act at all. A charmed member acts for the charmer, and a confused + member acts on a behavior roll, so neither steps up into the front rank for the party. Both the + validation pre-phase and the movement step ask this, so a replacement who is already charmed or + confused is refused when the round is declared, and one who becomes so before the movement step + stays where they are. + """ + return not ( + incapacitated(member) or has_condition(member, Condition.CHARMED) or has_condition(member, Condition.CONFUSED) + ) + + def _projected_front_rank(session, plan: Sequence[_Withdrawal]) -> set[str]: """The ids of the front rank as the round's planned swaps leave it, for judging melee declarations. @@ -2319,10 +2335,12 @@ def _swap_ranks(session, swaps: Sequence[_Withdrawal]) -> list[MemberWithdrewEve The swaps run in the marching order of the withdrawing members, as `_plan_withdrawals` planned them. A swap happens only if, when the movement step comes, the withdrawing member still stands - in the front rank, the replacement still stands behind it, and both can still move. A side that - acted first can change any of that. Otherwise the two stay where they are and nothing reports - the swap. The order stays changed after the round. A swap takes no draw. The party block runs - this at the start of its movement step, before `_party_movement` moves the formation. + in the front rank, the replacement still stands behind it, both can still move, and the + replacement can still act on the party's behalf (see `_acts_for_party`), which a member who is + incapacitated, charmed, or confused can't. A side that acted first can change any of that. + Otherwise the two stay where they are and nothing reports the swap. The order stays changed + after the round. A swap takes no draw. The party block runs this at the start of its movement + step, before `_party_movement` moves the formation. Returns: A `MemberWithdrewEvent` for each swap that happened. @@ -2334,7 +2352,10 @@ def _swap_ranks(session, swaps: Sequence[_Withdrawal]) -> list[MemberWithdrewEve behind = {member.id for rank in ranks[1:] for member in rank} if swap.member_id not in front or swap.replacement_id not in behind: continue - if cannot_move(session.member(swap.member_id)) or cannot_move(session.member(swap.replacement_id)): + replacement = session.member(swap.replacement_id) + if cannot_move(session.member(swap.member_id)) or cannot_move(replacement): + continue + if not _acts_for_party(replacement): continue members = list(session.party.members) ids = [member.id for member in members] diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 945d359..70bd73e 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2543,10 +2543,17 @@ class BattleDeclaration(BaseModel): The replacement has to be a living member behind the front rank who is in [`EncounterView.declarers`][osrlib.crawl.views.EncounterView.declarers], isn't in - `immobile`, and hasn't declared a `cast`, a `fighting_withdrawal`, or a `retreat`. A - spell caster can't move and cast in the same round. Two withdrawing members can't name - the same replacement. From the movement step on, the replacement stands in the front - rank, so they may declare a melee attack against a group in reach this round.""" + `immobile`, isn't charmed or confused, and hasn't declared a `cast`, a + `fighting_withdrawal`, or a `retreat`. A charmed member acts for the charmer and a + confused one acts at random, so neither steps up for the party. A spell caster can't + move and cast in the same round. Two withdrawing members can't name the same + replacement. From the movement step on, the replacement stands in the front rank, so + they may declare a melee attack against a group in reach this round. + + The session checks the replacement again when the movement step comes. A replacement + who has died, can no longer move, or has become incapacitated, charmed, or confused by + then doesn't step up. The two members stay where they are, no event reports the swap, + and the replacement's melee attack doesn't resolve.""" item_id: str | None = None """Which item a `use_item` declaration uses: a magic item's per-instance id for a potion, a scroll, a wand, a staff, or a rod, or a mundane item's catalog id for an item thrown at a diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 61e479a..12269dd 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -1182,10 +1182,11 @@ class MemberWithdrewEvent(Event): hasn't moved, and no group's distance has changed. The events come in the marching order of the withdrawing members, before the party's - `close`, if the round has one. A swap that can't happen when the movement step comes, - because one of the two has died or can no longer move, or because the ranks shifted - and one of them no longer stands where they did, reports nothing, and the two stay where - they are. + `close`, if the round has one. A swap that can't happen when the movement step comes + reports nothing, and the two stay where they are. That happens when one of the two has + died or can no longer move, when the replacement can no longer act on the party's behalf + because they are incapacitated, charmed, or confused, or when the ranks shifted and one + of them no longer stands where they did. Redraw a battle screen's ranks from this event, or read them again from [`PlayerView.party`][osrlib.crawl.views.PlayerView.party] and diff --git a/tools/docs/rejection_codes.json b/tools/docs/rejection_codes.json index 8509830..b673e13 100644 --- a/tools/docs/rejection_codes.json +++ b/tools/docs/rejection_codes.json @@ -8,7 +8,7 @@ "battle.declaration.no_target": "The chosen enemy group has no living, visible monster in its front rank that can currently be targeted.", "battle.declaration.not_in_front_rank": "A character who isn't in the party's front rank declares a melee attack, or a fighting withdrawal or a retreat that not every character declaring that round makes. The formation-width rule in effect this round means only front-rank members are within melee range, and only they can step back out of it. A member who steps up into the front rank this round as another character's replacement may declare a melee attack.", "battle.declaration.replacement_too_far": "The member a character names to step up stands too many ranks back. Each rank stands 5 feet behind the one in front of it. The withdrawing character covers that distance at up to half their encounter movement rate for a fighting withdrawal, or their full rate for a retreat, and the replacement covers it at up to their full encounter rate. Haste doubles either limit for the member who has it.", - "battle.declaration.replacement_unavailable": "The member a character names to step up for their fighting withdrawal or retreat can't take the front place this round. The member isn't a living member behind the front rank, can't act or move, declared a spell or a defensive move of their own, or was already named by a withdrawing character earlier in marching order. A declaration that names a replacement in a fighting withdrawal or retreat the whole party makes gets this code too, because every member is moving with the party.", + "battle.declaration.replacement_unavailable": "The member a character names to step up for their fighting withdrawal or retreat can't take the front place this round. The member isn't a living member behind the front rank, can't act or move, is charmed or confused, declared a spell or a defensive move of their own, or was already named by a withdrawing character earlier in marching order. A declaration that names a replacement in a fighting withdrawal or retreat the whole party makes gets this code too, because every member is moving with the party.", "battle.declaration.roster_mismatch": "The set of characters submitting declarations for the round doesn't exactly match the party members who are currently able to act and are expected to declare.", "battle.declaration.unknown_action": "The declaration's action type isn't one the engine recognizes as a legal move for the round (such as hold, move, attack, use_item, turn_undead, or cast).", "battle.declaration.unknown_group": "The declaration references a target group that doesn't exist in the current encounter, or that has already fled.", From dac2470a72fb8922a1fbda16d3be78bbc73cff22 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 14:40:06 -0700 Subject: [PATCH 6/6] Remove the single-withdrawal markers and trim two changelog sentences Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 4 ++-- tests/test_battle.py | 3 --- tests/test_single_withdrawal.py | 19 +++---------------- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f7b48..621ef19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - [`GrantItem`][osrlib.crawl.commands.GrantItem] can grant a magic item by its catalog id, so a game can sell potions or hand out a magic reward (#173). Each copy arrives unidentified, with a separate instance id in the [`ItemAcquiredEvent`][osrlib.crawl.events.ItemAcquiredEvent]. The session rolls whatever the item's entry in the OSE SRD leaves to chance, like a wand's charges, on the treasure stream, as it does for a magic item found in a cache. A mundane grant is unchanged. - An adventure document can use `RemoveItem`, `RemoveCoins`, and a `GrantItem` of a magic item as trigger consequences and quest rewards (#173). A `RemoveItem` can name its member with [`CARRIER_SELECTOR`][osrlib.crawl.triggers.CARRIER_SELECTOR] (`"@carrier"`), which the interpreter replaces with the first living member in marching order who carries the item. When nobody does, the interpreter drops the command with a note. A quest reward written this way uses up the quest item when the quest completes. [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] checks an authored `GrantItem`'s or `RemoveItem`'s item id against the equipment and magic item catalogs, and accepts `"@carrier"` only on a `RemoveItem`. - [`Rest`][osrlib.crawl.commands.Rest] has a new field `hidden`, and [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] takes a matching `hidden` parameter, so a game can let the party hide while it rests. The OSE SRD lowers the chance of wandering monsters when the party rests quietly in an out-of-the-way place, and a game sets `hidden` to say the party does. Each wandering-monster check during a hidden rest takes 1 off the chance in 6, after noise and bright light add theirs. On a level with the usual 1 in 6 chance, the session rolls no check during a hidden rest. The field defaults to `False`, so a saved command without it still parses as a plain rest. In town no checks run, so `hidden` changes nothing there. -- [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] has a new field [`replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id], so a member can make a fighting withdrawal or a retreat alone (#171). It names the back-rank member who steps up into the front rank in the withdrawing member's place. The new event [`MemberWithdrewEvent`][osrlib.crawl.events.MemberWithdrewEvent] reports each swap, with the code `battle.member.withdrew` for a fighting withdrawal and `battle.member.retreated` for a retreat. Its `character_id` is the member who stepped back and its `replacement_id` is the member who stepped up, so a battle screen can redraw the ranks from it. The field defaults to `None`, so a saved command without it still parses and [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] stays 4. Saved command logs now include the field, so the golden file that contains battle declarations was regenerated to add it. +- [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] has a new field [`replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id], so a member can make a fighting withdrawal or a retreat alone (#171). It names the back-rank member who steps up into the front rank in the withdrawing member's place. The new event [`MemberWithdrewEvent`][osrlib.crawl.events.MemberWithdrewEvent] reports each swap, with the code `battle.member.withdrew` for a fighting withdrawal and `battle.member.retreated` for a retreat. Its `character_id` is the member who stepped back and its `replacement_id` is the member who stepped up, so a battle screen can redraw the ranks from it. The field defaults to `None`, so a saved command without it still parses and [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] stays 4. - Each attack routine has a new field, [`AttackRoutine.use`][osrlib.core.monsters.AttackRoutine.use], that gives when the monster uses the routine. Its value is a [`RoutineUse`][osrlib.core.monsters.RoutineUse]. The first routine is `PRIMARY`. A later routine is `ALTERNATIVE` when the OSE SRD lets the monster use it in place of the first in any round, like a thoul's weapon. It's `SITUATIONAL` when the SRD ties it to a situation osrlib doesn't model, like an elephant's trample or a giant's thrown boulder. It's `SPECIAL` when it's a breath, a gaze, or a spell. Every routine in the built-in monster and spell catalogs has its label. A routine with no label is `PRIMARY`, so in a monster template of your own, label each later routine, or the default policy picks it as often as the first. - A policy of your own can choose which attack routine a monster uses, in the new field [`MonsterAction.routine`][osrlib.crawl.battle.MonsterAction.routine]. The field is an index into the template's `attacks`, and `None` uses the first routine. So your policy can have an elephant trample when your game decides it charges. Battle rolls the routine's attacks as melee attacks, and rolls nothing for a `SPECIAL` routine. An index the template doesn't have makes the action impossible, so the session skips it. - [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] reports a wand, staff, or rod use or a turning that fizzled in a battle round (#145). Its code is `magic.device.fizzled` for a device and `magic.turning.fizzled` for a turning. `actor_id` is the member who declared it, `item_id` is the device's instance id (`None` for a turning), and `reason` is the rejection code of the check that failed. Tell the player the action failed. A device that fizzles keeps its charge. @@ -38,7 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed -- A retreat by the whole party now makes its members easier to hit for the whole round, as the OSE SRD's retreat says: "This round: ... the opponent gains a +2 bonus to all attacks against the retreating combatant and ignores any AC bonus due to the combatant's shield". Before, the penalty started only when the party moved off, so monsters that won initiative and attacked first got no bonus and counted the shield. A confused monster's attack never got it. Now every attack the monster side makes on the party in that round gets +2 and ignores the shield, whichever side acts first. No golden file changed. +- A retreat by the whole party now makes its members easier to hit for the whole round, as the OSE SRD's retreat says: "This round: ... the opponent gains a +2 bonus to all attacks against the retreating combatant and ignores any AC bonus due to the combatant's shield". Before, the penalty started only when the party moved off, so monsters that won initiative and attacked first got no bonus and counted the shield. A confused monster's attack never got it. Now every attack the monster side makes on the party in that round gets +2 and ignores the shield, whichever side acts first. - A ring of regeneration no longer heals fire or acid damage, as the ring's entry in the OSE SRD says. Before, the ring healed every kind of damage at 1 hit point a round. Now [`deal_damage`][osrlib.core.combat.deal_damage] records fire and acid damage to the wearer in the new field [`Character.nonregen_damage`][osrlib.core.character.Character.nonregen_damage], and the ring heals the wearer only up to their maximum hit points less that damage. The ring still heals the rest, and a potion or a cure spell still heals all of it. [`DamageDealtEvent`][osrlib.core.events.DamageDealtEvent]`.non_regenerable` is now True when fire or acid hits a wearer. The new field defaults to 0, so a save from before this change still loads and [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] stays 4. Saves and party documents now include the field, so the golden files that contain characters were regenerated to add it. - *Sleep* judges a drained monster by the Hit Dice it has left, because energy drain takes Hit Dice away. Before, both of the spell's modes read the monster's template. The single-creature mode takes only a creature of exactly 4+1 Hit Dice: 4 Hit Dice with a bonus, no more and no fewer. An ogre drained from 4+1 to 3+1 Hit Dice is no longer a 4+1 creature, so this mode now has no effect on it. Before, the mode put that ogre to sleep. The 2d8 Hit Dice mode takes creatures of 4 Hit Dice or fewer and leaves out 4+1 creatures. It now takes the drained ogre for 3 Hit Dice of its budget. Before, it left the ogre out as a 4+1 creature. This fix covers the 4+1 test in both modes. The [`effective_hd`][osrlib.core.combat.effective_hd] fix below covers the rest of the 2d8 mode: what each creature costs from the budget, and whether it's under the 4 Hit Dice limit. With that fix, a 5 Hit Dice grizzly bear drained to 4 is eligible for the 2d8 mode too. - Three more rules judge a drained monster by the Hit Dice it has left, because energy drain takes Hit Dice away. Before, each used the monster's template. *Confusion*'s re-save each round uses the drained count, so a 3 Hit Dice boar drained to 2 Hit Dice is a subject of 2 Hit Dice and no longer saves. The Hit Dice bands of the protection scrolls use it too, so a 4 Hit Dice wraith drained to 3 Hit Dice counts in the 1 to 3 Hit Dice band of a scroll of protection from undead. Last, the optional rule that lets a monster of 5 Hit Dice or more harm a creature that only magic or silver can hurt uses it, so a grizzly drained from 5 to 4 Hit Dice no longer qualifies. This is [`MonsterInstance.hit_dice_count`][osrlib.core.monsters.MonsterInstance.hit_dice_count], the same count that sets its THAC0 and saves. The turning table and a monster's experience value still use the template, because the OSE SRD ties them to the kind of monster. diff --git a/tests/test_battle.py b/tests/test_battle.py index 42fa547..1b0dfb3 100644 --- a/tests/test_battle.py +++ b/tests/test_battle.py @@ -1167,7 +1167,6 @@ class TestAFormationMoveNeedsEveryDeclarer: everyone, so a lone `close` stays legal. """ - @pytest.mark.xfail(reason="chunk: single-withdrawal", strict=True) @pytest.mark.parametrize("move", ["fighting_withdrawal", "retreat"]) def test_a_lone_defensive_move_with_no_replacement_is_refused_whole(self, move): from osrlib.core.combat import COMBAT_STREAM @@ -1187,7 +1186,6 @@ def test_a_lone_defensive_move_with_no_replacement_is_refused_whole(self, move): assert session.battle.round == round_before assert session.encounter.groups[0].distance_feet == distance_before - @pytest.mark.xfail(reason="chunk: single-withdrawal", strict=True) def test_a_majority_does_not_carry_the_minority(self): session = battle_session(distance=40) retreating = tuple( @@ -1203,7 +1201,6 @@ def test_a_majority_does_not_carry_the_minority(self): ] assert session.battle is not None and session.battle.round == 0 - @pytest.mark.xfail(reason="chunk: single-withdrawal", strict=True) def test_two_members_withdrawing_alone_each_need_a_replacement(self): session = battle_session(distance=40) first = BattleDeclaration(character_id="character-0001", action="move", move="retreat") diff --git a/tests/test_single_withdrawal.py b/tests/test_single_withdrawal.py index 3707c84..657ad19 100644 --- a/tests/test_single_withdrawal.py +++ b/tests/test_single_withdrawal.py @@ -24,8 +24,7 @@ - The replacement may strike in melee the round they step up. A withdrawing member makes no attack. - A swap that can't happen when the movement step comes doesn't happen and reports nothing. -Every acceptance test here is marked expected-to-fail until the chunk lands. The classes at the end -are unmarked guards: they pin the formation moves as they are today, which the chunk must keep. +The classes at the end check that the formation moves stay as they were. """ import pytest @@ -58,8 +57,6 @@ from osrlib.data import load_classes, load_equipment, load_spells from osrlib.persistence import load_game, replay_game, save_game, session_state -CHUNK = pytest.mark.xfail(reason="chunk: single-withdrawal", strict=True) - BRAKK = "character-0001" """The fighter, with a sword. Front rank in the stock marching order.""" SABLE = "character-0002" @@ -262,7 +259,6 @@ def choose(self, session, group, stream): # ---------------------------------------------------------------------- the swap -@CHUNK class TestAFightingWithdrawalAlone: """One front-rank member falls back, a named back-rank member steps up, and the party stays put.""" @@ -320,7 +316,6 @@ def test_a_withdrawal_shares_a_round_with_a_close_and_resolves_first(self): assert closing.distance_feet < (before[-1] if before else 40) -@CHUNK class TestARetreatAlone: """A retreat alone trades places as a withdrawal does, and its penalties are the retreater's alone.""" @@ -383,7 +378,6 @@ def test_once_the_party_has_moved_melee_no_longer_reaches_the_member(self, move) assert {attack.modifier for attack in attacks} == held_modifiers -@CHUNK class TestTheMovementLimit: """Each rank stands 5' behind the one in front, and both members have to cover the distance.""" @@ -442,7 +436,6 @@ def test_a_laden_replacement_cannot_step_up_three_ranks(self): _assert_refused_cleanly(session, result, before) -@CHUNK class TestWhenNoOneCanStepUp: """A member who withdraws alone needs a replacement who can take the front place this round.""" @@ -545,7 +538,6 @@ def test_a_formation_move_has_no_replacement_to_name(self): # ---------------------------------------------------------------------- melee around the swap -@CHUNK class TestSteppingUpIntoMelee: """From the movement step on the replacement stands in the front rank, and the withdrawn member doesn't.""" @@ -635,7 +627,6 @@ def _round_with_a_late_condition(self, monkeypatch, condition: Condition): assert has_condition(session.member(WYNN), condition) return session, result - @CHUNK @pytest.mark.parametrize("condition", [Condition.CHARMED, Condition.CONFUSED]) def test_a_charmed_or_confused_replacement_does_not_step_up(self, monkeypatch, condition): session, result = self._round_with_a_late_condition(monkeypatch, condition) @@ -644,14 +635,13 @@ def test_a_charmed_or_confused_replacement_does_not_step_up(self, monkeypatch, c assert WYNN not in {attack.attacker_id for attack in _attacks(result)} def test_a_paralysed_replacement_does_not_step_up(self, monkeypatch): - """Unmarked guard: the implemented re-check already holds for a replacement who can't move.""" + """The re-check also holds for a replacement who can't move.""" session, result = self._round_with_a_late_condition(monkeypatch, Condition.PARALYSED) assert _swaps(result) == [] assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] assert WYNN not in {attack.attacker_id for attack in _attacks(result)} -@CHUNK class TestNoAttackWhileWithdrawing: """The move is the member's one declared action: a withdrawing member makes no attack that round.""" @@ -677,7 +667,6 @@ def test_a_member_who_withdraws_alone_does_not_shoot(self, move): # ---------------------------------------------------------------------- views -@CHUNK class TestTheViewsAfterTheSwap: def test_the_player_view_shows_the_new_ranks(self): session = _battle(distance=5) @@ -726,7 +715,6 @@ def _commanded_battle(seed: int) -> tuple[GameSession, list]: return session, start -@CHUNK class TestDeterminism: def test_a_refused_withdrawal_costs_nothing(self): session = _battle(distance=5) @@ -807,7 +795,6 @@ def _party_moved_off(result): return next(event for event in result.events if event.code == "battle.group.moved" and event.distance_feet > 5) -@CHUNK class TestAFormationRetreatIsExposedAllRound: """Every declarer retreating is every member retreating, so the SRD's "This round" penalty covers the whole formation for the whole round, also when the monsters strike before the party moves off.""" @@ -834,7 +821,7 @@ def test_monsters_who_strike_first_get_the_bonus_and_ignore_the_shield(self): class TestTheFormationMovesStayAsTheyAre: - """Unmarked guards. A defensive move every declarer makes moves the whole formation, as before the chunk.""" + """A defensive move every declarer makes moves the whole formation, as before.""" def test_every_declarer_withdrawing_backs_the_formation_off_at_half_rate(self): session = _battle(distance=40)