Skip to content

Let a member withdraw or retreat alone - #195

Merged
mmacy merged 8 commits into
mainfrom
chunk/single-withdrawal
Oct 30, 2026
Merged

mmacy merged 8 commits into
mainfrom
chunk/single-withdrawal

Conversation

@mmacy

@mmacy mmacy commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Players of a game built on osrlib wanted to pull one badly hurt character out of the front rank without the whole party leaving the fight. osrlib refused any round where only some members declared a fighting withdrawal or a retreat. The SRD describes both as one combatant's move, so this makes them per-member.

What changed

  • A front-rank member can make a fighting withdrawal or a retreat alone by naming a back-rank member in the new BattleDeclaration.replacement_id. The two trade places in the party's movement step, and the new order stays after the battle. A new MemberWithdrewEvent reports each swap.
  • Both members have to cover the 5 feet between ranks: the withdrawing member at half their encounter rate for a fighting withdrawal or their full rate for a retreat, and the replacement at their full rate.
  • The replacement may attack in the round they step up, because "Movement and attacking may be combined in the same round". The withdrawing member may not. A retreating combatant "may not attack", and after a fighting withdrawal the member stands where melee doesn't reach.
  • A replacement who is incapacitated, charmed, or confused doesn't step up. That's checked when the round is declared and again when the party moves.
  • When every declarer makes the same defensive move, the whole formation moves as before.
  • battle.declaration.formation_split is gone. Three new codes replace it: no_replacement, replacement_unavailable, and replacement_too_far.

Fixed along the way

A whole-party retreat now gives the monsters +2 to hit and ignores shields for the whole round, as the SRD says ("This round: ..."). Before, monsters that won initiative and attacked before the party moved off got no bonus. A confused monster attacking a retreating member now gets it too.

What to check

  • tests/goldens/phase5_milestone.json changes only by "replacement_id": null on each logged declaration. No outcome changed.
  • The adaptations register under "The battle round" states the rules, with their SRD quotes.

Fixes #171.

https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD

mmacy added 8 commits October 30, 2026 14:08
…gle-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
…ingle-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
…t 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
Brings in #191 (AddMember), #192 (attack routines), and #193 (default weapon, device and turning re-check). The round's validation now applies the default weapon to each declaration first, then plans the withdrawals and the projected front rank from those declarations, and passes front_rank to _validate_declaration. Monster melee passes both the per-member retreat exposure and the action's routine.

_party_movement keeps its two-argument signature, because test_weapon_default_and_device_recheck.py wraps it with one. The swaps now run from _swap_ranks just before it in the party block's movement step, so the order of events is unchanged: swaps, then the formation's move or close.

The party and session docstrings name both AddMember and the withdrawal swap. The changelog keeps every bullet, and the 2.0.1 replay bullet carries main's cases and the formation retreat case. No golden file moved beyond the contract's replacement_id lines.

Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD
…ingle-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
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
@mmacy
mmacy merged commit b448ef9 into main Oct 30, 2026
5 checks passed
@mmacy
mmacy deleted the chunk/single-withdrawal branch October 30, 2026 21:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The formation adaptation leaves out the SRD's per-combatant fighting withdrawal and retreat

1 participant