diff --git a/CHANGELOG.md b/CHANGELOG.md index 87dca95..621ef19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +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. - 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. @@ -30,12 +31,14 @@ 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. +- 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. - An `attack` in a [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] with no `weapon_id` now uses the wielded weapon that suits the attack (#162). Before, the member struck unarmed, even with a sword in hand. The group's distance when the session accepts the round decides the attack: melee at 5 feet or less, missile beyond. The session takes the first wielded weapon, in the order the member equipped them, that can make that attack. For a missile attack it prefers the first whose long range reaches the group. A real weapon comes before an improvised one, like a torch, so a member who lit a torch and then drew a sword swings the sword. The torch still counts when the member wields nothing else that suits the attack. The session passes over a splash weapon, like a flask of oil, and an item with no weapon stats, like a wand. A member with no suitable weapon still strikes unarmed. The round plays out as if you had named the chosen weapon, and the command log records the declaration as you sent it. [`BattleDeclaration.weapon_id`][osrlib.crawl.commands.BattleDeclaration.weapon_id] describes the choice. A declaration that names a weapon works as before. - A wand, staff, or rod use and a turning declared in [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] are checked again in the magic phase, just before they resolve, as a spell cast and a scroll read are (#145). Before, they resolved even when the round had changed something they depend on. For example, a staff of striking struck a group that had already run out of melee reach. Now a declaration that fails the second check fizzles, and the session reports it with a [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent]. A device that fizzles was never used, so it keeps its charge, isn't identified, and doesn't break the user's invisibility. A turning that fizzles rolls nothing. A device use or turning that passes the second check resolves with the same rolls as before. -- 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 *confusion* re-save, a protection scroll, or the 5 Hit Dice rule met with a drained monster, a *death spell* cast at a group, 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, a monster that can choose between attack routines (a spitting cobra, a thoul, a mule, a minotaur, a wererat, or a monster of your own with more than one unlabelled routine), an attack declared with no `weapon_id` by a member who wields a weapon, or a device use (a wand, staff, or rod) or a turning that now fizzles. 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. +- 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 *confusion* re-save, a protection scroll, or the 5 Hit Dice rule met with a drained monster, a *death spell* cast at a group, 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, a monster that can choose between attack routines (a spitting cobra, a thoul, a mule, a minotaur, a wererat, or a monster of your own with more than one unlabelled routine), an attack declared with no `weapon_id` by a member who wields a weapon, or a device use (a wand, staff, or rod) or a turning that now fizzles. It also happens in 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. - 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/docs/adaptations.md b/docs/adaptations.md index f69fa0b..32c9515 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -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 diff --git a/docs/spec.md b/docs/spec.md index 9f1cede..de3d8f4 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/core/combat.py b/src/osrlib/core/combat.py index 2eabace..fca3f08 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 d0342e8..aa86d24 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 @@ -255,6 +259,7 @@ BattleRoundEvent, BattleStartedEvent, GroupMovedEvent, + MemberWithdrewEvent, MonsterFledEvent, MonstersLeftBehindEvent, SpellDeclaredEvent, @@ -1450,55 +1455,193 @@ 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. + +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 -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. - 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, 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 + 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": + return False + 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 _validate_declaration(session, declaration: BattleDeclaration, member) -> list[Rejection]: +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. + + 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 @@ -1510,6 +1653,10 @@ def _validate_declaration(session, declaration: BattleDeclaration, member) -> li An `attack` with no `weapon_id` is judged with the weapon that `BattleDeclaration.weapon_id` says the session chooses, from the group's distance when the round is accepted. `_handle_resolve_battle_round` fills that weapon in with `_with_default_weapon` before it calls this. + + 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": @@ -1539,7 +1686,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})] @@ -1977,10 +2126,13 @@ def _handle_resolve_battle_round(session, command: ResolveBattleRound) -> tuple[ member = session.member(declaration.character_id) # The default weapon is chosen once, here, so the round plays out as if the player had named it. # The command log keeps the declaration as it was sent. - declaration = _with_default_weapon(session, declaration, member) - 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] = (member, _with_default_weapon(session, declaration, member)) + declarations = [declaration for _, declaration in by_member.values()] + withdrawal_rejections, swaps = _plan_withdrawals(session, declarers, 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). @@ -2022,14 +2174,23 @@ 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(declarations) + retreating = _retreating_members(session, 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] = [] + # The movement step: the swaps of members who withdraw alone, then the formation's move. + swapped = _swap_ranks(session, swaps) + block.extend(swapped) block.extend(_party_movement(session, by_member)) - party_retreating = _party_is_retreating(session, by_member) + 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, @@ -2038,6 +2199,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)) @@ -2049,6 +2211,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)) @@ -2068,7 +2231,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, ) @@ -2095,7 +2259,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 @@ -2109,46 +2273,46 @@ 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. - - 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 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`. + """Move the formation: its defensive move, or else the first `close`. + + 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. + + Otherwise the first `close` in the round's declarations advances the formation on its named + group at encounter rate, stopping at 5'. The members who withdraw alone have already traded places + with their replacements by then, earlier in the same movement step (see `_swap_ranks`). """ 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): - 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): + 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) // 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 @@ -2168,6 +2332,43 @@ def _party_movement(session, by_member) -> list[Event]: return events +def _swap_ranks(session, swaps: Sequence[_Withdrawal]) -> list[MemberWithdrewEvent]: + """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, 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. + """ + events: list[MemberWithdrewEvent] = [] + 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 + 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] + 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. @@ -2180,7 +2381,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): @@ -2199,6 +2407,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", ()): @@ -2734,9 +2944,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 {} @@ -2767,7 +2985,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) @@ -2787,17 +3005,16 @@ 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, routine=action.routine - ) + _resolve_monster_melee(session, monster, target, retreating=exposed, routine=action.routine) ) _watch_disruption(events, pending_casters, disrupted, acted) elif action.kind == "npc_shoot": @@ -2805,7 +3022,9 @@ def _monster_block( 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": @@ -3067,13 +3286,14 @@ 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. Whether a monster re-saves depends on the Hit Dice it has left, so a drained monster may lose the re-save. 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): @@ -3111,12 +3331,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 @@ -3138,9 +3358,7 @@ def _behaviour_outcome(params, total: int) -> str: """ -def _resolve_monster_melee( - session, monster, target, *, party_retreating: bool, routine: int | None = None -) -> list[Event]: +def _resolve_monster_melee(session, monster, target, *, retreating: bool, routine: int | None = None) -> list[Event]: """Resolve one of a monster's attack routines against one target, with what each hit does beyond damage. `routine` is an index into the template's `attacks`, and None resolves the first routine, as a @@ -3167,7 +3385,7 @@ def _resolve_monster_melee( 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 3012dd2..826c66b 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -539,8 +539,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` @@ -2410,8 +2414,10 @@ class BattleDeclaration(BaseModel): - `attack` takes `target_group_id` and, optionally, `weapon_id`. `None` means the wielded weapon that suits the attack, or bare hands when the member wields none. - `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 @@ -2507,21 +2513,50 @@ 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`. + 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. 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 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`, 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 @@ -2560,16 +2595,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`. @@ -2605,7 +2647,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 27845c7..12269dd 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -123,6 +123,7 @@ "ListenedEvent", "LocationEnteredEvent", "MemberAddedEvent", + "MemberWithdrewEvent", "MonsterDefeatedEvent", "MonsterFledEvent", "MonstersLeftBehindEvent", @@ -1170,6 +1171,42 @@ 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 + 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 + [`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. @@ -2104,6 +2141,7 @@ class MemberAddedEvent(Event): BattleRoundEvent, SpellDeclaredEvent, GroupMovedEvent, + MemberWithdrewEvent, MonsterFledEvent, MonstersLeftBehindEvent, MonsterDefeatedEvent, diff --git a/src/osrlib/crawl/party.py b/src/osrlib/crawl/party.py index 85b6dd5..d95f035 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 rearranges that order. The session calls -[`reorder`][osrlib.crawl.party.Party.reorder] when it accepts one. The referee command +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. The referee command [`AddMember`][osrlib.crawl.commands.AddMember] adds a new member at the back. Dead members stay in the list, with their gear still on them. No command removes a member. The @@ -78,9 +81,11 @@ class Party(BaseModel): [`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. To add a member during play, execute [`AddMember`][osrlib.crawl.commands.AddMember], which assigns the new member's - id and puts them at the back, rather than appending to this list. 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.""" + id and puts them at the back, rather than appending to this list. 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 9adb906..1eb676d 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -618,9 +618,11 @@ def __init__( """ self.party = party """The party, in marching order. The order determines who is in the front rank in a fight. - [`ReorderParty`][osrlib.crawl.commands.ReorderParty] is the only command that rearranges - it, and [`AddMember`][osrlib.crawl.commands.AddMember] is the only command that adds a - member, at the back.""" + [`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]. + [`AddMember`][osrlib.crawl.commands.AddMember] is the only command that adds a member, at + the back.""" 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/src/osrlib/messages.py b/src/osrlib/messages.py index 296ded3..14e9c0b 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -360,6 +360,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..1b0dfb3 100644 --- a/tests/test_battle.py +++ b/tests/test_battle.py @@ -1156,18 +1156,19 @@ 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.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,11 +1179,9 @@ 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 @@ -1195,26 +1194,23 @@ 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): + 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 217a11b..28430bd 100644 --- a/tests/test_events_kernel.py +++ b/tests/test_events_kernel.py @@ -117,6 +117,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..657ad19 --- /dev/null +++ b/tests/test_single_withdrawal.py @@ -0,0 +1,892 @@ +"""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 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. + +The classes at the end check that the formation moves stay as they were. +""" + +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 battle as battle_module +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 + +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 + + +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) + + +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 + + +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) + + +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 + + +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)} + + +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 + + @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): + """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)} + + +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 + + +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 + + +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] + + +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) + + +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 + + +class TestTheFormationMovesStayAsTheyAre: + """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) + 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_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))) + assert not result.accepted + assert [rejection.code for rejection in result.rejections] == ["session.command.wrong_mode"] + assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] diff --git a/tools/docs/rejection_codes.json b/tools/docs/rejection_codes.json index 9a19804..b673e13 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, 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.",