Skip to content
5 changes: 4 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions docs/adaptations.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,13 +368,19 @@ 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. 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, 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`.

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`.

An `attack` declaration that names no weapon attacks with the wielded weapon that suits the attack (#162). The SRD decides melee or missile by distance alone ("Melee: Close quarters weapon (5' or less)" and "Missile: Thrown or fired weapon (greater than 5' distance)"), so the group's distance when the round is accepted decides it: a melee attack at 5' or less, a missile attack beyond. The session takes the first wielded item, in the order the member equipped them, with the `melee` or `missile` quality. For a missile attack it prefers the first whose long range reaches the group, and when none reaches it takes the first missile weapon, which the session then refuses with `combat.attack.out_of_range`. A real weapon comes before an improvised one, a gear item whose stats come from its combat facet, like a torch, and equip order holds within each kind. A member who lights a torch and then draws a sword, the usual dungeon setup, swings the sword. The torch still counts when nothing else suits the attack, because the weapon table gives it the melee quality. A splash weapon never counts, because a `use_item` declaration throws it, and a magic item with no base weapon, like a wand, has no weapon stats. A member with nothing that suits the attack strikes unarmed. The choice is made once, when the round is accepted, and the round then plays out exactly as if the player had named that weapon, even when the distance changes before the attack resolves. The command log keeps the declaration as it was sent. Locked by `test_weapon_default_and_device_recheck.py`.

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. A wand, staff, or rod use and a turning get the same re-check (#145). A staff of striking aimed at a group that ran out of melee reach fizzles with `combat.attack.out_of_reach`, and a wand aimed at a group that fled the battle fizzles with `battle.declaration.unknown_group`. One that fizzles is reported by `DeclarationFizzledEvent`, at `magic.device.fizzled` or `magic.turning.fizzled`, with the same `reason` field. `SpellDisruptedEvent` stays for spells and scrolls, so its `spell_id` always names a spell. A fizzled device use costs what a `hold` costs: the device was never used, so it keeps its charge, isn't identified, and breaks no invisibility. A fizzled turning draws nothing and costs nothing. 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` and, for devices and turnings, `test_weapon_default_and_device_recheck.py`.
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. A wand, staff, or rod use and a turning get the same re-check (#145). A staff of striking aimed at a group that ran out of melee reach fizzles with `combat.attack.out_of_reach`, and a wand aimed at a group that fled the battle fizzles with `battle.declaration.unknown_group`. One that fizzles is reported by `DeclarationFizzledEvent`, at `magic.device.fizzled` or `magic.turning.fizzled`, with the same `reason` field. `SpellDisruptedEvent` stays for spells and scrolls, so its `spell_id` always names a spell. A fizzled device use costs what a `hold` costs: the device was never used, so it keeps its charge, isn't identified, and breaks no invisibility. A fizzled turning draws nothing and costs nothing. 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` and, for devices and turnings, `test_weapon_default_and_device_recheck.py`.

### Spell effect consumption in battle

Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions src/osrlib/core/combat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading