diff --git a/CHANGELOG.md b/CHANGELOG.md index 01328c8..b5d0295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - 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. The attack events name the weapon the member used. [`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, the session doesn't identify it, and the member who declared it stays invisible if they were. 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 replay differently under this release. 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. Replaying a 2.0.1 log with [`replay_game`][osrlib.persistence.replay_game] can differ in two ways. Each change named below has an entry in this section or under Fixed. - - The replay raises [`ContentValidationError`][osrlib.errors.ContentValidationError] when the log contains a command this release refuses. Two commands that 2.0.1 accepted are refused now: a potion `UseItem` aimed at another member, and a temple service that would do nothing. + - The replay raises [`ContentValidationError`][osrlib.errors.ContentValidationError] when the log contains a command this release refuses. Three commands that 2.0.1 accepted are refused now: a potion `UseItem` aimed at another member, a temple service that would do nothing, and a battle round in which a member behind the front rank strikes with a staff of striking. - The replay runs without an error but plays out differently when the log contains any of these: - A lair the party left and came back to. - A rest in a dungeon, or a rest that a wandering monster interrupted. @@ -51,6 +51,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - An attack declared with no `weapon_id` by a member who wields a weapon. - A wand, staff, rod, or turning that now fizzles. - 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. + - A turning after a monster in the encounter died or its group fled the battle. + - A staff of striking used by a member who was to step up into the front rank but whose swap didn't happen. + - A level drain on a character, whose drain event now reports different `hp_lost` and `spawn_consequence` values. The drain itself takes the same levels and hit points. ### Fixed @@ -79,6 +82,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - The [`RollResult`][osrlib.core.dice.RollResult] that [`damage_roll`][osrlib.core.combat.damage_roll] returns obeys its documented rule, `total == (sum(rolls) + modifier) * multiplier`, before the total is raised to 1. `modifier` includes the Strength modifier, the magic weapon's bonus, spell bonuses, and a fixed amount. `multiplier` includes the doublings, like a back-stab or a braced spear. Before, both fields were always 0 and 1, so a front end that showed the breakdown showed the wrong numbers. Totals and draw sequences are unchanged (#166). - A front end built on the player view can cast *sleep* at a group in battle (#162). When a [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] for an `hd_budget` mode has a `target_group_id` and no `targets`, the session now spends the spell's Hit Dice budget on that group's living members, weakest first. Before, the session refused it with `magic.cast.target_count`, and the player view shows no monster ids to name instead. It's the same cast as one that lists every living member of the group in spawn order, and it isn't limited to an area footprint. When both are set, the session still uses the named `targets`, so a saved command log replays as it did. No golden file changed. - A front end can tell that a monster group has fled, and the session refuses every declaration aimed at it (#162). [`EncounterGroupView`][osrlib.crawl.views.EncounterGroupView] has a new `fled` field, which is true once the group has left the battle. A fled group stays in the player view's `groups`, in order. Before, the session refused an attack or an item aimed at a fled group but accepted a `close` move or an area spell. Now the session refuses each of these with `battle.declaration.unknown_group`, with the declarer's id as the `character` param. You can still declare against a group that is running away but hasn't left the battle. A spell aimed at a group that flees earlier in the same round fizzles in the magic phase, as a spell does when any of its checks fails there. The field defaults to `False`, so [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] stays 4. No golden file changed. +- A turning reaches only the monsters still in the fight. Before, a `turn_undead` in [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] reached every monster in the encounter, the dead and the fled included. A skeleton shot dead in the missile phase was still read on the turning table, so the turning could report `magic.turning.turned` with no monster affected. Now it reaches the living monsters of the groups that haven't fled. When the magic phase finds none left, the turning fizzles with a [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] at `magic.turning.fizzled`, whose `reason` is `battle.declaration.no_target`, and it draws nothing. [`TurnUndead`][osrlib.crawl.commands.TurnUndead] before a battle skips a monster killed earlier in the encounter in the same way. +- Only a member in the front rank can strike with a staff of striking, because the OSE SRD's entry says it's "Used as a magical melee weapon". Before, a member in the back rank could use one in [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound]. Now the session refuses that use with `battle.declaration.not_in_front_rank`, as it refuses a melee attack. A member who steps up this round, named in [`replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id], may strike. If that swap doesn't happen, the use fizzles in the magic phase at `magic.device.fizzled`, and the staff keeps its charge. With the ruleset's `formation_width_limit` off, every member stands in the front rank and may strike. +- The `hp_lost` of a [`LevelDrainedEvent`][osrlib.core.events.LevelDrainedEvent] is how far the victim's maximum hit points fell. Before, it was the hit points rolled for the lost levels, which overstated the loss when the maximum stopped at 1. A level 2 dwarf with 3 maximum hit points who rolled a 7 for the lost level was reported to lose 7, and now is reported to lose 2. The `hp_lost` of [`DrainResult`][osrlib.core.classes.DrainResult] is still the hit points rolled. +- The `spawn_consequence` of a [`LevelDrainedEvent`][osrlib.core.events.LevelDrainedEvent] appears only on a `combat.drain.slain` event, because it says what a character drained of all levels becomes. Before, a drain the character survived carried it too, as if the character were about to rise as a wight. ## [2.0.1] - 2026-10-30 diff --git a/docs/adaptations.md b/docs/adaptations.md index 6e4b671..d155f92 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -76,7 +76,7 @@ The RAW trigger text says "slain, paralysed, etc". Pinned for the morale trigger ### Energy drain reverses level_up -Per level drained, mirroring `level_up` exactly in reverse: above name level subtract the flat-bonus delta (no roll, no CON), otherwise roll the class hit die plus the CON modifier (minimum 1 per die) and subtract it. Rolling the lost die is the RAW-faithful reading of "loses one Hit Die of hit points" that keeps the model stateless, with no per-level HP history field. Drain never reduces max or current HP below 1 while the character retains a level. Death by drain happens only by losing the last level. XP policy is per-monster data: the wight sets XP to the floored midpoint of the former and new levels' thresholds, while wraith, spectre, and vampire set it to the new level's threshold exactly. Two-level drains apply the procedure twice and set XP once. On the terminal drain the killing level counts in `levels_lost` (a level-1 victim loses 1 level, and a spectre draining a level-2 fighter reports 2). A drained monster loses Hit Dice symmetrically: the instance re-derives THAC0 and saves from the reduced HD and loses a rolled d8, and below 1 HD it dies. It also counts its remaining Hit Dice, with its template's modifier, wherever a rule measures a creature by its Hit Dice: where *sleep* spends a Hit Dice budget or decides whether the monster is a 4+1 creature, where *confusion* decides whether a subject of 2+1 HD or greater re-saves, where a protection scroll's ward counts the monster in its 1–3, 4–5, or 6+ HD band, and where the `hd5_counts_as_magical` optional rule asks for "a monster with 5 HD or greater". The wight page's drain takes "all other benefits due to the drained level". The turning column is the exception, because the turning table reads "Hit Dice of Monster Type" (see [The turning column](#the-turning-column-is-the-hd-count-2-only-at-count-2-counts-above-9-unturnable)). Experience value stays with the template too, because a stat block's XP is "Pre-calculated XP award for defeating the monster". The spawn consequence (rises as a wight in 1d4 days) is a field on the drain event that the engine doesn't act on: the kernel kills the victim, and the game narrates what it becomes. Locked by `test_drain.py`, `test_drain_and_regen.py::TestSleepOnDrainedMonsters`, and `test_drain_and_area.py`. +Per level drained, mirroring `level_up` exactly in reverse: above name level subtract the flat-bonus delta (no roll, no CON), otherwise roll the class hit die plus the CON modifier (minimum 1 per die) and subtract it. Rolling the lost die is the RAW-faithful reading of "loses one Hit Die of hit points" that keeps the model stateless, with no per-level HP history field. Drain never reduces max or current HP below 1 while the character retains a level. Death by drain happens only by losing the last level. XP policy is per-monster data: the wight sets XP to the floored midpoint of the former and new levels' thresholds, while wraith, spectre, and vampire set it to the new level's threshold exactly. Two-level drains apply the procedure twice and set XP once. On the terminal drain the killing level counts in `levels_lost` (a level-1 victim loses 1 level, and a spectre draining a level-2 fighter reports 2). A drained monster loses Hit Dice symmetrically: the instance re-derives THAC0 and saves from the reduced HD and loses a rolled d8, and below 1 HD it dies. It also counts its remaining Hit Dice, with its template's modifier, wherever a rule measures a creature by its Hit Dice: where *sleep* spends a Hit Dice budget or decides whether the monster is a 4+1 creature, where *confusion* decides whether a subject of 2+1 HD or greater re-saves, where a protection scroll's ward counts the monster in its 1–3, 4–5, or 6+ HD band, and where the `hd5_counts_as_magical` optional rule asks for "a monster with 5 HD or greater". The wight page's drain takes "all other benefits due to the drained level". The turning column is the exception, because the turning table reads "Hit Dice of Monster Type" (see [The turning column](#the-turning-column-is-the-hd-count-2-only-at-count-2-counts-above-9-unturnable)). Experience value stays with the template too, because a stat block's XP is "Pre-calculated XP award for defeating the monster". The spawn consequence (rises as a wight in 1d4 days) is a field on the drain event that the engine doesn't act on: the kernel kills the victim, and the game narrates what it becomes. It describes "A person drained of all levels", so only a `combat.drain.slain` event for a character carries it, and a drain the character survives leaves it `None`. The event's `hp_lost` is the drop in maximum hit points, the maximum before the drain less the maximum after, so the floor of 1 can make it smaller than the hit points rolled for the lost levels. Locked by `test_drain.py`, `test_turning_and_staff.py`, `test_drain_and_regen.py::TestSleepOnDrainedMonsters`, and `test_drain_and_area.py`. ### The damage pipeline order @@ -284,7 +284,7 @@ A monster's column is its `hit_dice.count` as a string, except count 2 with a sp ### The turning pool spends lowest-first, stops at the first unaffordable monster, and always affects one -The engine compares one 2d6 turn roll per candidate *type* against its cell, and on any success one 2d6 HD pool follows. Eligible monsters are affected lowest-HD-first (stable input order on ties), each costing its HD count (minimum 1 and fixed bonuses dropped, the *sleep* convention). The pool stops at the first unaffordable monster (RAW: excess Hit Dice "are wasted", not reallocated), and at least one undead is always affected on a success, pinned to the cheapest eligible monster. `D` results are permanent destruction, and the rest gain an indefinite, non-dispellable `turned` condition the encounter releases. Only `undead`-category monsters are candidates, and non-undead resolve as unaffected. Locked by `test_turning.py` and `test_spell_properties.py`. +The engine compares one 2d6 turn roll per candidate *type* against its cell, and on any success one 2d6 HD pool follows. Eligible monsters are affected lowest-HD-first (stable input order on ties), each costing its HD count (minimum 1 and fixed bonuses dropped, the *sleep* convention). The pool stops at the first unaffordable monster (RAW: excess Hit Dice "are wasted", not reallocated), and at least one undead is always affected on a success, pinned to the cheapest eligible monster. `D` results are permanent destruction, and the rest gain an indefinite, non-dispellable `turned` condition the encounter releases. Only `undead`-category monsters are candidates, and non-undead resolve as unaffected. In a session, a turning's candidates are the living monsters of the encounter's groups that haven't fled, in group order and then monster order, both in battle and before it. A monster killed earlier in the round or the encounter, or one whose group left the battle, never puts its kind on the table, because the SRD's "At least one undead monster will always be affected on a successful turning" can't hold for a kind with nobody left to affect. Locked by `test_turning.py`, `test_spell_properties.py`, and `test_turning_and_staff.py`. ### The `magic` RNG stream, and the attach-time/tick-time split @@ -376,11 +376,11 @@ The swaps resolve in the party's movement step, in the marching order of the wit A member who makes a fighting withdrawal or a retreat makes no attack that round, alone or with the formation, because a member declares one thing per round and the move is that thing. For a retreat that is RAW: "the retreating combatant may not attack". A fighting withdrawal takes nothing away in RAW, and "Movement and attacking may be combined in the same round", but the round sequence resolves movement before missile attacks and melee. So by the melee step a member who withdrew alone stands behind the front rank, where melee doesn't reach, and a formation that withdrew stands out of melee range. RAW would still allow a missile attack after the move. That half is not represented, and is pinned as such: a withdrawing member's declaration that also names a weapon or a group makes no attack. Locked by `test_single_withdrawal.py`. -A party melee attack hits the first living, visible monster of the group's reachable rank (deterministic, no draw), and monsters pick uniformly on the monster-action stream. Under individual initiative the machine still resolves side blocks, ordered by the best individual total (the SRD's phase sequence is per side). Turn undead resolves in the magic phase, needs no declaration posting, and cannot be disrupted, because it is a class ability rather than a spell. The machine detects disruption per the RAW trigger (a declared caster successfully attacked or failing a save before acting) and releases concentration on any other declared action. A turning is one of those actions, so a cleric who declares one stops concentrating, even when confusion stops the turning before it resolves. A caster maintains one concentration spell at a time: casting a second ends the first, and a spell that needs no concentration leaves it alone (#167). Locked by `test_rules_calls.py::TestTurningEndsConcentration` and `test_rules_calls.py::TestOneConcentrationSpellAtATime`. +A 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. A staff of striking is "Used as a magical melee weapon", so under `formation_width_limit` only a front-rank member may use one, judged against the ranks the round's swaps produce, as a melee attack is. A back-rank member's use is refused with `battle.declaration.not_in_front_rank`. 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. It names no group and reaches every living monster in the groups that haven't fled when it resolves (see [the turning pool](#the-turning-pool-spends-lowest-first-stops-at-the-first-unaffordable-monster-and-always-affects-one)). 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 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`. +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`. A staff of striking in the hands of a replacement whose swap didn't happen fizzles with `battle.declaration.not_in_front_rank`, because the re-check reads the ranks as they stand. A turning with no living monster left in a group that hasn't fled, because the missile phase or an earlier declaration in the magic phase killed them or drove them off, fizzles with `battle.declaration.no_target`. That check asks whether any monster is left, not whether any undead is, so it tells the player nothing about what is undead. 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` and `test_turning_and_staff.py`. ### Spell effect consumption in battle diff --git a/src/osrlib/core/classes.py b/src/osrlib/core/classes.py index 9f35cc9..2158b40 100644 --- a/src/osrlib/core/classes.py +++ b/src/osrlib/core/classes.py @@ -1183,6 +1183,7 @@ class DrainResult(BaseModel): """The hit points the drain takes, from both maximum and current hit points. Neither total drops below 1 while the character has a level left, so the totals can fall by less than this. + The `hp_lost` of the drain event in `events` is how far the maximum actually fell, so the two can differ. """ xp_after: int | None = None @@ -1249,8 +1250,10 @@ def drain_levels( `streams.get(`[`ADVANCEMENT_STREAM`][osrlib.core.character.ADVANCEMENT_STREAM]`)`, the stream that level gains draw from. spawn_consequence: A description of what a victim drained of all levels becomes, like the - description of the monster's `energy_drain` ability. This function copies it onto the - drain event for you to show, and osrlib doesn't act on it. `None` leaves it off. + description of the monster's `energy_drain` ability. When the drain kills the character, + this function copies it onto the `combat.drain.slain` event for you to show, and osrlib + doesn't act on it. A drain the character survives leaves it off its event, because the + character hasn't become anything. `None` leaves it off. Returns: What the character lost, and the events that describe it. @@ -1301,6 +1304,7 @@ def drain_levels( if xp_policy not in ("halfway", "level_minimum"): raise ValueError(f"unknown xp policy {xp_policy!r}") former_level = character.level + former_max_hp = character.max_hp hp_rolls: list[int] = [] hp_lost = 0 slain = False @@ -1334,7 +1338,7 @@ def drain_levels( target_id=character.id or character.name, levels_lost=levels_lost, new_level=0, - hp_lost=hp_lost, + hp_lost=former_max_hp - character.max_hp, spawn_consequence=spawn_consequence, ) ) @@ -1360,9 +1364,8 @@ def drain_levels( target_id=character.id or character.name, levels_lost=former_level - character.level, new_level=character.level, - hp_lost=hp_lost, + hp_lost=former_max_hp - character.max_hp, xp_after=xp_after, - spawn_consequence=spawn_consequence, ) ) events.append( diff --git a/src/osrlib/core/combat.py b/src/osrlib/core/combat.py index c037de5..d16a496 100644 --- a/src/osrlib/core/combat.py +++ b/src/osrlib/core/combat.py @@ -3301,7 +3301,9 @@ def drain_monster_hd(monster: MonsterInstance, *, levels: int = 1, stream: RngSt For each Hit Die drained, the monster loses a rolled d8 from both its maximum and its current hit points, and its THAC0 and saving throws become those of its new Hit Dice. - Neither hit point total falls below 1, so losing hit points never kills a monster. + Neither hit point total falls below 1, so losing hit points never kills a monster. The + event's `hp_lost` is how far the maximum fell, which is less than the d8s rolled when the + floor of 1 stops it. The function kills a monster already at 1 Hit Die instead. It rolls no die for that step and takes no Hit Die off, and the event counts the last one as lost. @@ -3347,7 +3349,7 @@ def drain_monster_hd(monster: MonsterInstance, *, levels: int = 1, stream: RngSt monster_id = _entity_id(monster) former = monster.hit_dice_count - hp_lost = 0 + former_max_hp = monster.max_hp slain = False for _ in range(levels): if monster.hit_dice_count <= 1: @@ -3357,7 +3359,6 @@ def drain_monster_hd(monster: MonsterInstance, *, levels: int = 1, stream: RngSt monster.drained_hd += 1 monster.current_hp = max(1, monster.current_hp - lost) monster.max_hp = max(1, monster.max_hp - lost) - hp_lost += lost events: list[Event] = [] if slain: # The killing Hit Die counts as lost, mirroring character drain. @@ -3367,7 +3368,7 @@ def drain_monster_hd(monster: MonsterInstance, *, levels: int = 1, stream: RngSt target_id=monster_id, levels_lost=former - monster.hit_dice_count + 1, new_level=0, - hp_lost=hp_lost, + hp_lost=former_max_hp - monster.max_hp, ) ) events.extend(kill(monster)) @@ -3378,7 +3379,7 @@ def drain_monster_hd(monster: MonsterInstance, *, levels: int = 1, stream: RngSt target_id=monster_id, levels_lost=former - monster.hit_dice_count, new_level=monster.hit_dice_count, - hp_lost=hp_lost, + hp_lost=former_max_hp - monster.max_hp, ) ) events.append(HitPointsReportedEvent(target_id=monster_id, current_hp=monster.current_hp, max_hp=monster.max_hp)) @@ -3395,8 +3396,8 @@ def resolve_energy_drain(attacker: MonsterInstance, target: Creature, *, stream: [`Character`][osrlib.core.character.Character], which has a class, loses levels through [`drain_levels`][osrlib.core.classes.drain_levels]. A [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] loses Hit Dice through - [`drain_monster_hd`][osrlib.core.combat.drain_monster_hd]. For a character, the - ability's text about what the victim becomes appears in the drain event. + [`drain_monster_hd`][osrlib.core.combat.drain_monster_hd]. When the drain kills a + character, the ability's text about what the victim becomes appears in the drain event. Args: attacker: The draining [`MonsterInstance`][osrlib.core.monsters.MonsterInstance], diff --git a/src/osrlib/core/events.py b/src/osrlib/core/events.py index d439e02..94b296f 100644 --- a/src/osrlib/core/events.py +++ b/src/osrlib/core/events.py @@ -870,7 +870,13 @@ class LevelDrainedEvent(Event): """The victim's level, or Hit Dice, after the drain. 0 when the drain killed it.""" hp_lost: int - """The maximum hit points the victim lost with the levels.""" + """How far the drain lowered the victim's maximum hit points: the maximum before the drain less the maximum + after it. + + While the victim has a level or a Hit Die left, maximum hit points don't drop below 1, so this can be less than + the hit points rolled for the lost levels. A level-2 character with 3 maximum hit points who rolls a 7 for the + lost level is left with 1, so `hp_lost` is 2. A drain that kills doesn't lower the maximum for the killing + level, so it counts only the levels lost before that one.""" xp_after: int | None = None """The character's experience points after the drain. Depending on the draining monster, they're either @@ -880,7 +886,10 @@ class LevelDrainedEvent(Event): spawn_consequence: str | None = None """What the rules say becomes of a character this undead creature kills, like rising as a wight in 1d4 days under the control of its killer. osrlib kills the victim, and playing out what it becomes is up to you. - `None` for a monster victim, and when the undead creature's rules say nothing about it.""" + + It's set only on a `combat.drain.slain` event for a character, because it describes a victim the drain killed. + `None` on every `combat.drain.drained` event, for a monster victim, and when the undead creature's rules say + nothing about it.""" class MonsterRevivedEvent(Event): @@ -1156,8 +1165,9 @@ class DeclarationFizzledEvent(Event): """Why the declaration fizzled: the code of the first check that failed when the session checked it again. Any check the declaration passed at the start of the round can fail, so read the code and don't assume a - cause. A staff of striking whose group ran out of reach gives `combat.attack.out_of_reach`, and a wand aimed at - a group that left the battle gives `battle.declaration.unknown_group`. Match on it as you match on the code of a + cause. A staff of striking whose group ran out of reach gives `combat.attack.out_of_reach`, a wand aimed at + a group that left the battle gives `battle.declaration.unknown_group`, and a turning with no living monster + left in the battle gives `battle.declaration.no_target`. Match on it as you match on the code of a [`Rejection`][osrlib.core.validation.Rejection]. [The rejection code reference][rejection-codes] explains each code. """ diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 9868814..3f85cfb 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -951,6 +951,18 @@ def _living_monsters(session, group) -> list: ] +def _turning_candidates(session) -> list: + """The monsters a turning reaches: the living members of every group that hasn't fled. + + They come in the encounter's group order and each group's monster order, so a replay passes them to + [`turn_undead`][osrlib.core.spells.turn_undead] in the same order. A dead monster, or one whose group + left the battle, isn't present to be turned. + """ + return [ + monster for group in session.encounter.groups if not group.fled for monster in _living_monsters(session, group) + ] + + FIGHTER_FRONTAGE_FEET = 5 """The width, in feet, that one combatant needs to fight side by side with the next. @@ -1669,6 +1681,21 @@ def _validate_declaration( 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. + + A staff of striking `use_item` is a melee attack too, because the staff is "Used as a magical melee + weapon", so it gets the same rank check: a member outside `front_rank`, or outside the front rank as it + stands when `front_rank` isn't passed, is refused with `battle.declaration.not_in_front_rank` and the + member's id as the `character` param. The check applies only while `formation_width_limit` is on, as + for a melee attack. `_validate_magic_item_declaration` makes it after the fled-group check and before + the reach check, the order a melee attack's rank and reach checks take. + + A `turn_undead` names no group. It reaches every living monster in every group that hasn't fled, undead + or not, as `_party_magic` passes them to [`turn_undead`][osrlib.core.spells.turn_undead]. After the + checks of [`validate_turn_undead`][osrlib.core.spells.validate_turn_undead], a turning with no such + monster left is refused with `battle.declaration.no_target` and the member's id as the `character` + param. A battle always has a living monster when a round is accepted, so only the magic phase's + re-check meets this refusal, when the phases before it killed or drove off every monster. The check + doesn't ask whether any monster is undead, so it never tells the player what is undead for free. """ state = session.battle if declaration.action == "hold": @@ -1716,7 +1743,7 @@ def _validate_declaration( magic = member.inventory.magic_item(declaration.item_id) if declaration.item_id else None if magic is not None: - return _validate_magic_item_declaration(session, declaration, member, magic) + return _validate_magic_item_declaration(session, declaration, member, magic, front_rank=front_rank) group = _group_by_id(session, declaration.target_group_id) if group is None or group.fled: return [Rejection(code="battle.declaration.unknown_group", params={"character": member.id})] @@ -1729,7 +1756,12 @@ def _validate_declaration( context = AttackContext(distance_feet=group.distance_feet, lit=True) return validate_attack(member, pool[0], instance.template, context, ruleset=session.ruleset) if declaration.action == "turn_undead": - return validate_turn_undead(member, load_classes().get(member.class_id)) + rejections = validate_turn_undead(member, load_classes().get(member.class_id)) + if rejections: + return rejections + if not _turning_candidates(session): + return [Rejection(code="battle.declaration.no_target", params={"character": member.id})] + return [] if declaration.action == "cast": if declaration.spell_id is None or declaration.spell_mode is None: return [Rejection(code="battle.declaration.missing_spell", params={"character": member.id})] @@ -1857,7 +1889,9 @@ def _area_candidates(session, group, shape: str | None, dimensions: dict) -> lis return candidates -def _validate_magic_item_declaration(session, declaration: BattleDeclaration, member, instance) -> list[Rejection]: +def _validate_magic_item_declaration( + session, declaration: BattleDeclaration, member, instance, *, front_rank: set[str] | None = None +) -> list[Rejection]: """The `use_item` declaration widened: potions, scrolls, and devices (magic phase). A potion targets the drinker. Wands, staves, and rods target a group and @@ -1870,6 +1904,13 @@ def _validate_magic_item_declaration(session, declaration: BattleDeclaration, me validation pre-phase, and again in the magic phase immediately before the read or the use resolves, because the phases between the two can change what these checks read. It reads state and takes no draw, so the second run costs nothing and changes nothing. + + A staff of striking (an effect of kind `striking`) is "Used as a magical melee weapon", so only a member + in the front rank may use it, as for a melee attack (see `_validate_declaration`). In the pre-phase that is + the front rank as the round's swaps will leave it, so a replacement who steps up may strike. In the magic + phase it is the front rank as it stands, so a replacement whose swap didn't happen fizzles with + `battle.declaration.not_in_front_rank`. The rank check comes after `battle.declaration.unknown_group`, so a + staff aimed at a fled group is still refused for the group. """ from osrlib.crawl import exploration @@ -1934,6 +1975,11 @@ def _validate_magic_item_declaration(session, declaration: BattleDeclaration, me if group is None or group.fled: return [Rejection(code="battle.declaration.unknown_group", params={"character": member.id})] if effect_spec.kind == "striking": + if _formation_width(session) is not None: + if front_rank is None: + front_rank = {front.id for front in _party_front_rank(session)} + if 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})] if not _monster_pool(session, group): @@ -2719,6 +2765,12 @@ def _party_magic(session, by_member, pending_casters, disrupted, acted, state) - `items.device.activated`, and breaks no invisibility. A turning that fizzles costs nothing and draws nothing. A fizzled device use or turning still releases the actor's concentration, as every declaration other than a cast or a hold does. + + A turning reaches only the living monsters of the groups that haven't fled, in the encounter's group order + and each group's monster order. A dead monster, or one in a fled group, is never a candidate, so its kind is + never read on the turning table and never appears in the event's `types`. A turning with no such monster + left, because the missile phase or a declaration earlier in this magic phase killed them or drove them from + the battle, fizzles with `battle.declaration.no_target`. """ events: list[Event] = [] for member, declaration in by_member.values(): @@ -2765,13 +2817,10 @@ def _party_magic(session, by_member, pending_casters, disrupted, acted, state) - events.extend(_release_concentration(session, member.id, state)) acted.add(member.id) continue - candidates = [ - session.combatant(monster_id) for group in session.encounter.groups for monster_id in group.monster_ids - ] result = turn_undead( member, load_classes().get(member.class_id), - candidates, + _turning_candidates(session), ledger=session.ledger, clock=session.clock, allocator=session.allocator, diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index d915060..bff98ce 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2362,6 +2362,10 @@ class TurnUndead(Command): monsters that weren't turned attack at once and battle starts. The turned members of a partly turned group break away as a new group and flee. + The turning reaches every living monster in the encounter's groups that haven't fled. + A monster killed earlier in the encounter, by a wand for example, isn't read on the + turning table. + Modes: `encounter` @@ -2420,7 +2424,10 @@ class BattleDeclaration(BaseModel): 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 + A staff of striking is a melee weapon, so only a member in `front_rank` can strike + with it. + - `turn_undead` needs nothing else. It reaches every living monster in the groups that + haven't fled when it resolves. Turning resolves with the spells in the round, but a hit can't disrupt it, because it's a class ability rather than a spell. - `hold` does nothing. A member with no legal action declares it, because every member in `declarers` needs a declaration. @@ -2660,7 +2667,10 @@ class ResolveBattleRound(Command): `magic.cast.not_reversible`, `magic.cast.target_count`, `magic.cast.out_of_range`. - Turn-undead declarations: `magic.turning.not_a_turner`, - `magic.turning.caster_incapacitated`. + `magic.turning.caster_incapacitated`, and `battle.declaration.no_target` when no + living monster is left in a group that hasn't fled. A round is never accepted with + no such monster, so only the magic phase's check meets this last one, and the + turning fizzles. - `battle.declaration.no_mirror` - a member who carries no mirror declares a `gaze_defense` of `mirror`, with any action. The rejection has the member's id as its `character` param. @@ -2668,8 +2678,10 @@ class ResolveBattleRound(Command): `battle.declaration.unknown_group`, `battle.declaration.no_target`, `items.use.not_usable`, `items.device.inert`, `items.scroll.spent`, `items.scroll.no_such_spell`, `items.scroll.wrong_caster`, - `exploration.action.requires_light`, and `combat.attack.out_of_reach`. A - scroll read can also get the cast checks on its spell and targets + `exploration.action.requires_light`, and `combat.attack.out_of_reach`. A staff of + striking is a melee weapon, so its use also gets + `battle.declaration.not_in_front_rank` from a member outside the front rank as the + round's swaps leave it. A scroll read can also get the cast checks on its spell and targets (`magic.cast.unknown_mode`, `magic.cast.unknown_target`, `battle.declaration.invisible_target`, `magic.cast.target_count`, `magic.cast.out_of_range`, `magic.cast.caster_incapacitated`, diff --git a/src/osrlib/crawl/encounter.py b/src/osrlib/crawl/encounter.py index f6006af..9f82056 100644 --- a/src/osrlib/crawl/encounter.py +++ b/src/osrlib/crawl/encounter.py @@ -815,7 +815,11 @@ def _handle_turn_undead(session, command: TurnUndead) -> tuple[list[Rejection], turning_rejections = validate_turn_undead(member, definition) if turning_rejections: return turning_rejections, [] - candidates = _monsters(session, state) + from osrlib.crawl import battle as battle_module + + # A wand fired in an earlier encounter round can leave a dead monster in a group, and a dead + # monster isn't present to be turned. + candidates = battle_module._turning_candidates(session) result = turn_undead( member, definition, @@ -836,8 +840,6 @@ def _handle_turn_undead(session, command: TurnUndead) -> tuple[list[Rejection], return [], events # Presenting the symbol is an aggressive act: surviving unturned monsters # attack (see the adaptations register), and battle begins at once. - from osrlib.crawl import battle as battle_module - state.stance = ReactionResult.ATTACKS.value events.append(StanceChangedEvent(stance=state.stance)) events.extend(session.advance_rounds(1)) diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 6610e3e..262b897 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -362,10 +362,11 @@ class EncounterView(BaseModel): 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 + that is both melee and missile counts as a melee weapon. A staff of striking is a melee + weapon, so a `use_item` of one is refused the same way. 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.""" + to step up this round, who may declare a melee attack or strike with the staff.""" 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` diff --git a/tests/goldens/phase2_wight_battle.json b/tests/goldens/phase2_wight_battle.json index 21ad721..bf63777 100644 --- a/tests/goldens/phase2_wight_battle.json +++ b/tests/goldens/phase2_wight_battle.json @@ -121,7 +121,7 @@ "hp_lost": 4, "levels_lost": 1, "new_level": 1, - "spawn_consequence": "A successfully hit target permanently loses one experience level (or Hit Die). This incurs a loss of one Hit Die of hit points, as well as all other benefits due to the drained level (e.g. spells, saving throws, etc.). A character’s XP is reduced to halfway between the former and new levels. A person drained of all levels becomes a wight in 1d4 days, under the control of the wight that killed them.", + "spawn_consequence": null, "target_id": "pc-fighter", "visibility": "player", "xp_after": 1000 diff --git a/tests/test_battle_player_view.py b/tests/test_battle_player_view.py index 4451567..2f499d2 100644 --- a/tests/test_battle_player_view.py +++ b/tests/test_battle_player_view.py @@ -465,7 +465,9 @@ def _arm_for_every_kind(session: GameSession) -> None: state={"spell_list": "magic_user", "spells": ("fire_ball",)}, ) ) - session.member(CLERIC).inventory.items.append( + # The staff is a melee weapon, so it goes to the elf, who stands in the front rank. + assert ELF in _encounter(session).front_rank + session.member(ELF).inventory.items.append( MagicItemInstance( instance_id="magic-item-9003", template_id="staff_of_striking", identified=True, charges_remaining=5 ) @@ -486,7 +488,7 @@ def _arm_for_every_kind(session: GameSession) -> None: character_id=MAGIC_USER, action="use_item", item_id="magic-item-9001", target_group_id=group_id ), "staff that strikes": lambda group_id: BattleDeclaration( - character_id=CLERIC, action="use_item", item_id="magic-item-9003", target_group_id=group_id + character_id=ELF, action="use_item", item_id="magic-item-9003", target_group_id=group_id ), "area spell": lambda group_id: BattleDeclaration( character_id=MAGIC_USER, action="cast", spell_id="fire_ball", spell_mode="damage", target_group_id=group_id diff --git a/tests/test_drain.py b/tests/test_drain.py index d50d889..65bf0ac 100644 --- a/tests/test_drain.py +++ b/tests/test_drain.py @@ -174,8 +174,15 @@ def test_wight_touch_drains_by_tag(self): streams = RngStreams(master_seed=9) wight = spawn_monster(load_monsters().get("wight"), id="m-w", stream=streams.get("monster_spawn")) fighter = make_fighter(4) - events = resolve_energy_drain(wight, fighter, stream=FixedStream([5])) + resolve_energy_drain(wight, fighter, stream=FixedStream([5])) assert fighter.level == 3 and fighter.xp == 6_000 + + def test_a_wight_touch_that_kills_carries_the_spawn_consequence(self): + streams = RngStreams(master_seed=9) + wight = spawn_monster(load_monsters().get("wight"), id="m-w", stream=streams.get("monster_spawn")) + fighter = make_fighter(1) + events = resolve_energy_drain(wight, fighter, stream=FixedStream([])) + assert events[0].code == "combat.drain.slain" assert "becomes a wight" in events[0].spawn_consequence def test_spectre_touch_drains_two_by_tag(self): diff --git a/tests/test_phase2_goldens.py b/tests/test_phase2_goldens.py index ac4fe15..7240467 100644 --- a/tests/test_phase2_goldens.py +++ b/tests/test_phase2_goldens.py @@ -332,7 +332,8 @@ def test_milestone_behaviors(self): # Fighter L2 threshold 2,000, L1 threshold 0: floored halfway is 1,000. assert drained["xp_after"] == 1_000 and document["fighter_xp"] == 1_000 assert document["fighter_level"] == 1 - assert "becomes a wight" in drained["spawn_consequence"] + # The fighter survives this drain. Which drain events carry the spawn consequence is pinned in + # test_turning_and_staff.py. assert codes[-2:] == ["combat.death.died", "combat.state.hit_points"] or "combat.death.died" in codes def test_replay_determinism(self): diff --git a/tests/test_turning_and_staff.py b/tests/test_turning_and_staff.py new file mode 100644 index 0000000..5542866 --- /dev/null +++ b/tests/test_turning_and_staff.py @@ -0,0 +1,789 @@ +"""A turning with nothing left to turn, a staff of striking swung from the back rank, and two drain event fields. + +Three findings from a playtest of a game built on osrlib, pinned here before the code. + +**A turning reaches only the monsters still in the fight.** A battle `turn_undead` names no group. It reaches every +monster in the encounter, undead or not, and a monster that isn't undead comes out `unaffected`. That is why a +turning declared against a medusa is accepted (see `test_avert_eyes.py`). The turning resolves in the magic phase, +after the party's missiles, so the missiles can kill what it was meant for. A turning reaches only the living +monsters of the groups that haven't fled. A skeleton shot dead in the missile phase isn't read on the turning table, +because the SRD says "At least one undead monster will always be affected on a successful turning". +The magic phase's re-check refuses a turning with none left, with the code the other re-checks use for a missing +target, `battle.declaration.no_target`, and the turning fizzles at `magic.turning.fizzled`. It draws nothing and +costs what a `hold` costs. The check asks whether any monster is left, not whether any undead is left, so it never +tells the player for free what is undead. When a dead or fled undead leaves living monsters of other kinds behind, +the turning resolves against those, as a turning declared against them alone would. + +**The staff of striking is a melee weapon.** The SRD's entry reads "Used as a magical melee weapon", and its +"Staves in Melee" note says magic-users "are able to use the staff of power, staff of striking, and staff of +wizardry in melee". So a staff of striking use needs the member in the front rank, as a melee attack does, and is +refused with `battle.declaration.not_in_front_rank` otherwise. At acceptance the rank is the one the round's swaps +will leave, so a replacement who steps up may strike, and in the magic phase's re-check it is the rank as it stands. +A strike that misses still spends its charge, because "Only one charge may be spent per strike". + +**The drain event's `hp_lost` and `spawn_consequence`.** Maximum hit points stop at 1 while a level is left, so the +hit points rolled for a lost level can exceed what the maximum actually lost. `LevelDrainedEvent.hp_lost` is the +drop in maximum hit points. `spawn_consequence` describes what becomes of a character the undead kills ("A person +drained of all levels becomes a wight in 1d4 days"), so it is set only on a drain that kills. +""" + +from collections.abc import Callable + +import pytest + +from crawl_fixtures import build_adventure, build_party +from osrlib.core.abilities import AbilityScore +from osrlib.core.alignment import Alignment +from osrlib.core.character import Character +from osrlib.core.classes import drain_levels +from osrlib.core.combat import drain_monster_hd, incapacitated, resolve_energy_drain +from osrlib.core.effects import ActiveCondition, Condition, has_condition +from osrlib.core.events import ( + AttackRolledEvent, + DamageDealtEvent, + DeathEvent, + DeclarationFizzledEvent, + Event, + LevelDrainedEvent, + SpellDisruptedEvent, + UndeadTurnedEvent, + Visibility, +) +from osrlib.core.items import MagicItemInstance +from osrlib.core.monsters import spawn_monster +from osrlib.core.rng import RngStreams +from osrlib.core.ruleset import Ruleset +from osrlib.core.spells import MAGIC_STREAM +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 MELEE_RANGE_FEET +from osrlib.crawl.commands import ( + BattleDeclaration, + CommandResult, + EngageBattle, + EnterDungeon, + EquipItem, + GrantItem, + LightSource, + ReorderParty, + ResolveBattleRound, + SessionMode, + TurnUndead, + UseItem, +) +from osrlib.crawl.events import MemberWithdrewEvent +from osrlib.crawl.session import GameSession +from osrlib.data import load_classes, load_monsters +from osrlib.messages import format_message +from test_phase2_goldens import run_wight_battle + +FIGHTER = "character-0001" +THIEF = "character-0002" +CLERIC = "character-0003" +MAGIC_USER = "character-0004" + +CLERIC_LEADS = (CLERIC, FIGHTER, THIEF, MAGIC_USER) +"""A marching order that puts the cleric in the front rank of the stock party.""" + +SCAN_SEEDS = range(16) +"""Seeds to scan for a case that depends on the dice. Each scanning test asserts it found what it needs.""" + +NO_TARGET = "battle.declaration.no_target" +NOT_IN_FRONT_RANK = "battle.declaration.not_in_front_rank" + + +# ---------------------------------------------------------------------- battle helpers + + +def _light_and_enter(session: GameSession) -> None: + session.execute(GrantItem(character_id=FIGHTER, item_id="torch", quantity=6)) + session.execute(GrantItem(character_id=FIGHTER, item_id="tinder_box")) + assert session.execute(EnterDungeon(dungeon_id="delve")).accepted + for _ in range(20): + lit = session.execute(LightSource(character_id=FIGHTER, item_id="torch")) + if any(event.code == "exploration.light.lit" for event in lit.events): + return + pytest.fail("the torch never lit") + + +def battle( + seed: int, + groups: list[tuple[str, int]], + *, + distance: int, + order: tuple[str, ...] | None = None, + archers: tuple[str, ...] = (), + ruleset: Ruleset | None = None, +) -> GameSession: + """A battle of the stock party against `groups`, all at `distance`, in the one-cell entrance corridor. + + The corridor fits two abreast, so the stock party's front rank is the fighter and the thief unless `order` + sets another marching order. Each of `archers` equips a short bow and carries arrows. + """ + session = GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=seed, ruleset=ruleset) + for member_id in archers: + assert session.execute(GrantItem(character_id=member_id, item_id="short_bow")).accepted + assert session.execute(GrantItem(character_id=member_id, item_id="arrows", quantity=20)).accepted + assert session.execute(EquipItem(character_id=member_id, item_id="short_bow")).accepted + _light_and_enter(session) + if order is not None: + assert session.execute(ReorderParty(order=order)).accepted + spawned = [(template_id, session.spawn(template_id, count)) for template_id, count in groups] + encounter_module.start_encounter( + session, + groups=spawned, + kind="spawned", + distance_feet=distance, + pinned_stance=ReactionResult.HOSTILE, + party_aware=True, + ) + session.execute(EngageBattle()) + assert session.mode is SessionMode.BATTLE + return session + + +def declarations(session: GameSession, *extra: BattleDeclaration) -> tuple[BattleDeclaration, ...]: + """The round's declarations: `extra` first, in the order given, then a `hold` for every other declarer. + + The party's magic phase resolves in declaration order, so a declaration listed before the turning resolves + before it. + """ + named = {declaration.character_id for declaration in extra} + holds = tuple( + BattleDeclaration(character_id=member.id, action="hold") + for member in session.party.living_members() + if not incapacitated(member) and member.id not in named + ) + return (*extra, *holds) + + +def front_rank(session: GameSession) -> tuple[str, ...]: + return session.view(Visibility.PLAYER).encounter.front_rank + + +def outcome(session: GameSession, result: CommandResult, *, drop: Callable[[Event], bool] = lambda _event: False): + """Everything a round produced or changed that a player or a replay could tell apart.""" + return { + "accepted": result.accepted, + "rejections": [rejection.model_dump(mode="json") for rejection in result.rejections], + "events": [event.model_dump(mode="json") for event in result.events if not drop(event)], + "streams": {key: state.model_dump(mode="json") for key, state in session.streams.export_states().items()}, + "round": session.battle.round if session.battle is not None else None, + "party": [member.model_dump(mode="json") for member in session.party.members], + "ledger": session.ledger.model_dump(mode="json"), + } + + +def stream_states(session: GameSession): + return {key: state.model_dump(mode="json") for key, state in session.streams.export_states().items()} + + +def fizzles(events, actor_id: str, code: str) -> list[DeclarationFizzledEvent]: + """The actor's `DeclarationFizzledEvent`s at `code`. A device or turning fizzle is never a spell event.""" + assert not any(isinstance(event, SpellDisruptedEvent) and event.caster_id == actor_id for event in events), ( + "a device use or turning reported as a lost spell" + ) + return [ + event + for event in events + if isinstance(event, DeclarationFizzledEvent) and event.code == code and event.actor_id == actor_id + ] + + +def is_fizzle(event: Event) -> bool: + return isinstance(event, DeclarationFizzledEvent) + + +def party_acted_first(session: GameSession, result: CommandResult) -> bool: + initiative = next(event for event in result.events if event.code == "combat.initiative.rolled") + return initiative.order[0] in {member.id for member in session.party.members} + + +def death_index(events, monster_id: str) -> int | None: + for index, event in enumerate(events): + if isinstance(event, DeathEvent) and event.target_id == monster_id: + return index + return None + + +def turning_index(events) -> int | None: + """Where the turning resolved or fizzled: the first event with a `magic.turning.` code.""" + for index, event in enumerate(events): + if event.code.startswith("magic.turning."): + return index + return None + + +def turnings(events) -> list[UndeadTurnedEvent]: + return [event for event in events if isinstance(event, UndeadTurnedEvent)] + + +def assert_turning_fizzled_for_want_of_a_target(events) -> None: + found = fizzles(events, CLERIC, "magic.turning.fizzled") + assert len(found) == 1, [event.code for event in events] + fizzle = found[0] + assert fizzle.event_type == "declaration_fizzled" + assert fizzle.actor_id == CLERIC + assert fizzle.item_id is None + assert fizzle.reason == NO_TARGET + assert fizzle.visibility is Visibility.PLAYER + message = format_message(fizzle) + assert message and message != fizzle.code + assert not turnings(events), "a turning with nothing to turn still resolved" + + +TURN = BattleDeclaration(character_id=CLERIC, action="turn_undead") + + +# ---------------------------------------------------------------------- the turning: scenarios + + +def skeleton_under_fire(seed: int) -> tuple[GameSession, str]: + """One skeleton at 40 feet, wounded to 1 hit point, and the fighter and the thief with short bows. + + Returns the session and the skeleton's id. + """ + session = battle(seed, [("skeleton", 1)], distance=40, archers=(FIGHTER, THIEF)) + skeleton_id = session.encounter.groups[0].monster_ids[0] + session.combatant(skeleton_id).current_hp = 1 + return session, skeleton_id + + +def shoot(session: GameSession, member_id: str, group_index: int = 0) -> BattleDeclaration: + return BattleDeclaration( + character_id=member_id, + action="attack", + target_group_id=session.encounter.groups[group_index].id, + weapon_id="short_bow", + ) + + +def give_wand(session: GameSession) -> MagicItemInstance: + wand = MagicItemInstance(instance_id="magic-item-9001", template_id="wand_of_cold", charges_remaining=5) + session.member(MAGIC_USER).inventory.items.append(wand) + return wand + + +def wand_at(session: GameSession, wand: MagicItemInstance, group_index: int) -> BattleDeclaration: + return BattleDeclaration( + character_id=MAGIC_USER, + action="use_item", + item_id=wand.instance_id, + target_group_id=session.encounter.groups[group_index].id, + ) + + +def lone_skeleton_under_the_wand(seed: int) -> tuple[GameSession, str, MagicItemInstance]: + """One skeleton at 40 feet with 1 hit point, and a wand of cold that the magic-user fires before the turning. + + Cold damage of 6d6, halved on a save, always kills it, so the wand kills it earlier in the same magic phase + whatever the dice say. + """ + session = battle(seed, [("skeleton", 1)], distance=40) + skeleton_id = session.encounter.groups[0].monster_ids[0] + session.combatant(skeleton_id).current_hp = 1 + return session, skeleton_id, give_wand(session) + + +# ---------------------------------------------------------------------- the turning: tests + + +class TestATurningWithNothingLeftToTurnFizzles: + """A turning whose every monster died or left the battle earlier in the round fizzles in the magic phase.""" + + def test_a_skeleton_shot_dead_in_the_missile_phase_leaves_nothing_to_turn(self): + shot_dead, standing = [], [] + for seed in SCAN_SEEDS: + session, skeleton_id = skeleton_under_fire(seed) + result = session.execute( + ResolveBattleRound( + declarations=declarations(session, shoot(session, FIGHTER), shoot(session, THIEF), TURN) + ) + ) + assert result.accepted, [rejection.code for rejection in result.rejections] + died_at = death_index(result.events, skeleton_id) + turned_at = turning_index(result.events) + assert turned_at is not None, "the turning neither resolved nor fizzled" + if died_at is not None and died_at < turned_at: + assert_turning_fizzled_for_want_of_a_target(result.events) + shot_dead.append(seed) + else: + assert len(turnings(result.events)) == 1, "a turning with a living skeleton to face resolves" + standing.append(seed) + assert shot_dead, "no seed let the missiles kill the skeleton first" + assert standing, "no seed left the skeleton standing for the turning" + + def test_a_skeleton_killed_earlier_in_the_magic_phase_leaves_nothing_to_turn(self): + session, skeleton_id, wand = lone_skeleton_under_the_wand(seed=0) + result = session.execute( + ResolveBattleRound(declarations=declarations(session, wand_at(session, wand, 0), TURN)) + ) + assert result.accepted, [rejection.code for rejection in result.rejections] + assert wand.charges_remaining == 4, "precondition: the wand went off" + died_at = death_index(result.events, skeleton_id) + assert died_at is not None, "precondition: the wand killed the skeleton" + assert died_at < turning_index(result.events) + assert_turning_fizzled_for_want_of_a_target(result.events) + + def test_a_turning_with_nothing_to_turn_costs_what_a_hold_costs(self): + turning, skeleton_id, wand = lone_skeleton_under_the_wand(seed=0) + turned = turning.execute( + ResolveBattleRound(declarations=declarations(turning, wand_at(turning, wand, 0), TURN)) + ) + assert turned.accepted + assert len(fizzles(turned.events, CLERIC, "magic.turning.fizzled")) == 1 + + holding, _, held_wand = lone_skeleton_under_the_wand(seed=0) + hold = BattleDeclaration(character_id=CLERIC, action="hold") + # The cleric's hold sits where the turning did, because the initiative event lists the party in + # declaration order. + held = holding.execute( + ResolveBattleRound(declarations=declarations(holding, wand_at(holding, held_wand, 0), hold)) + ) + assert held.accepted + assert has_condition(holding.combatant(skeleton_id), Condition.DEAD), "precondition: the wand killed it" + assert turning.streams.get(MAGIC_STREAM).export_state() == holding.streams.get(MAGIC_STREAM).export_state(), ( + "the fizzled turning drew from the magic stream" + ) + assert outcome(turning, turned, drop=is_fizzle) == outcome(holding, held) + + def test_the_same_round_plays_out_the_same_twice(self): + def play(): + session, _, wand = lone_skeleton_under_the_wand(seed=0) + result = session.execute( + ResolveBattleRound(declarations=declarations(session, wand_at(session, wand, 0), TURN)) + ) + return outcome(session, result) + + assert play() == play() + + +class TestATurningReachesOnlyMonstersStillInTheFight: + """A dead monster, or one whose group fled the battle, is never a candidate for a turning, even when living + monsters remain for it to reach.""" + + def test_a_kind_whose_last_monster_died_this_round_is_not_read_on_the_table(self): + for seed in range(4): + session = battle(seed, [("zombie", 1), ("skeleton", 2)], distance=40) + zombies, skeletons = session.encounter.groups + zombie_id = zombies.monster_ids[0] + session.combatant(zombie_id).current_hp = 1 + wand = give_wand(session) + result = session.execute( + ResolveBattleRound(declarations=declarations(session, wand_at(session, wand, 0), TURN)) + ) + assert result.accepted, [rejection.code for rejection in result.rejections] + died_at = death_index(result.events, zombie_id) + assert died_at is not None and died_at < turning_index(result.events), "precondition: the zombie died first" + assert all(not has_condition(session.combatant(mid), Condition.DEAD) for mid in skeletons.monster_ids), ( + "precondition: the skeletons were still standing" + ) + (turned,) = turnings(result.events) + assert [kind.template_id for kind in turned.types] == ["skeleton"], f"seed {seed}" + assert zombie_id not in turned.affected_ids + assert set(turned.affected_ids) <= set(skeletons.monster_ids) + + def test_a_dead_monster_beside_living_ones_of_its_kind_is_never_affected(self): + for seed in range(4): + session = battle(seed, [("skeleton", 1), ("skeleton", 2)], distance=40) + fallen, standing = session.encounter.groups + fallen_id = fallen.monster_ids[0] + session.combatant(fallen_id).current_hp = 1 + wand = give_wand(session) + result = session.execute( + ResolveBattleRound(declarations=declarations(session, wand_at(session, wand, 0), TURN)) + ) + assert result.accepted + assert death_index(result.events, fallen_id) is not None, "precondition: the wand killed one skeleton" + (turned,) = turnings(result.events) + assert [kind.template_id for kind in turned.types] == ["skeleton"] + assert fallen_id not in turned.affected_ids + assert set(turned.affected_ids) <= set(standing.monster_ids) + + def test_a_group_that_fled_before_the_magic_phase_is_not_turned(self): + fled_first, still_here = [], [] + for seed in SCAN_SEEDS: + session = battle(seed, [("skeleton", 2), ("goblin", 2)], distance=40) + skeletons = session.encounter.groups[0] + skeletons.distance_feet = 100 + skeletons.fleeing = True + result = session.execute(ResolveBattleRound(declarations=declarations(session, TURN))) + assert result.accepted, [rejection.code for rejection in result.rejections] + assert session.mode is SessionMode.BATTLE, "precondition: the goblins keep the battle going" + (turned,) = turnings(result.events) + kinds = [kind.template_id for kind in turned.types] + if not party_acted_first(session, result): + assert skeletons.fled, "precondition: the skeletons left the battle before the party acted" + assert kinds == ["goblin"], f"seed {seed}" + assert turned.code == "magic.turning.failed" + assert turned.affected_ids == () + assert not any(has_condition(session.combatant(mid), Condition.TURNED) for mid in skeletons.monster_ids) + fled_first.append(seed) + else: + assert kinds == ["skeleton", "goblin"], f"seed {seed}" + still_here.append(seed) + assert fled_first, "no seed let the skeletons flee before the party acted" + assert still_here, "no seed let the party act before the skeletons fled" + + def test_a_turning_in_a_fight_with_no_undead_is_accepted_and_resolves(self): + session = battle(0, [("goblin", 2)], distance=40) + result = session.execute(ResolveBattleRound(declarations=declarations(session, TURN))) + assert result.accepted, [rejection.code for rejection in result.rejections] + assert not fizzles(result.events, CLERIC, "magic.turning.fizzled") + (turned,) = turnings(result.events) + assert turned.code == "magic.turning.failed" + assert [(kind.template_id, kind.outcome) for kind in turned.types] == [("goblin", "unaffected")] + + +# ---------------------------------------------------------------------- the staff of striking + + +def staff_battle(seed: int, *, order: tuple[str, ...] | None = None, ruleset: Ruleset | None = None): + """Two goblins at melee range, and a cleric carrying an unidentified staff of striking with 5 charges.""" + session = battle(seed, [("goblin", 2)], distance=MELEE_RANGE_FEET, order=order, ruleset=ruleset) + staff = MagicItemInstance(instance_id="magic-item-9003", template_id="staff_of_striking", charges_remaining=5) + session.member(CLERIC).inventory.items.append(staff) + return session, session.encounter.groups[0], staff + + +def strike(member_id: str, group, staff: MagicItemInstance) -> BattleDeclaration: + return BattleDeclaration( + character_id=member_id, action="use_item", item_id=staff.instance_id, target_group_id=group.id + ) + + +def activations(events, instance_id: str) -> list[Event]: + return [ + event + for event in events + if event.code == "items.device.activated" and getattr(event, "instance_id", None) == instance_id + ] + + +def strikes_by(events, member_id: str) -> list[AttackRolledEvent]: + return [event for event in events if isinstance(event, AttackRolledEvent) and event.attacker_id == member_id] + + +def paralyse_the_fighter_before_the_swap(monkeypatch) -> None: + """Paralyse the fighter at the start of the party's movement step, just before the swaps run. + + A paralysed member can't move, so a withdrawal the fighter declared doesn't happen, and the replacement it + named stays in the back rank. A ghoul's touch does this when the monsters act first. The test forces it so the + round doesn't depend on the ghoul's dice. + """ + original = battle_module._swap_ranks + + def paralysis_then_swaps(session, swaps): + fighter = session.member(FIGHTER) + if not has_condition(fighter, Condition.PARALYSED): + fighter.conditions = (*fighter.conditions, ActiveCondition(condition=Condition.PARALYSED, effect_id=None)) + return original(session, swaps) + + monkeypatch.setattr(battle_module, "_swap_ranks", paralysis_then_swaps) + + +class TestTheStaffOfStrikingIsAMeleeWeapon: + """The SRD's staff of striking is "Used as a magical melee weapon", so only the front rank strikes with it.""" + + @pytest.mark.parametrize("member_id", [CLERIC, MAGIC_USER]) + def test_a_member_behind_the_front_rank_is_refused(self, member_id): + session, group, staff = staff_battle(0) + if member_id != CLERIC: + session.member(CLERIC).inventory.items.remove(staff) + session.member(member_id).inventory.items.append(staff) + assert member_id not in front_rank(session), "precondition: the member stands behind the front rank" + assert group.distance_feet == MELEE_RANGE_FEET, "precondition: the group is in melee reach" + streams = stream_states(session) + + result = session.execute( + ResolveBattleRound(declarations=declarations(session, strike(member_id, group, staff))) + ) + + assert not result.accepted + assert [(rejection.code, dict(rejection.params)) for rejection in result.rejections] == [ + (NOT_IN_FRONT_RANK, {"character": member_id}) + ] + assert result.events == () + assert staff.charges_remaining == 5 + assert staff.identified is False + assert stream_states(session) == streams + assert session.battle.round == 0 + + def test_a_replacement_whose_swap_did_not_happen_fizzles_in_the_magic_phase(self, monkeypatch): + paralyse_the_fighter_before_the_swap(monkeypatch) + checked = [] + for seed in SCAN_SEEDS: + session, group, staff = staff_battle(seed) + assert CLERIC not in front_rank(session), "precondition: the cleric starts in the back rank" + withdraw = BattleDeclaration( + character_id=FIGHTER, action="move", move="fighting_withdrawal", replacement_id=CLERIC + ) + result = session.execute( + ResolveBattleRound(declarations=declarations(session, withdraw, strike(CLERIC, group, staff))) + ) + assert result.accepted, [rejection.code for rejection in result.rejections] + assert not any(isinstance(event, MemberWithdrewEvent) for event in result.events), "the swap happened" + party_ids = {member.id for member in session.party.members} + if any(isinstance(event, DeathEvent) and event.target_id in party_ids for event in result.events): + continue # a member's death moves the ranks up, so this round can't show the rank the staff met + assert CLERIC not in front_rank(session), "precondition: the cleric stayed in the back rank" + found = fizzles(result.events, CLERIC, "magic.device.fizzled") + assert len(found) == 1, [event.code for event in result.events] + assert found[0].reason == NOT_IN_FRONT_RANK + assert found[0].item_id == staff.instance_id + assert not activations(result.events, staff.instance_id) + assert not strikes_by(result.events, CLERIC) + assert staff.charges_remaining == 5 + assert staff.identified is False + checked.append(seed) + assert checked, "every seed killed a member, so no round kept the cleric behind" + + def test_a_front_rank_member_strikes(self): + session, group, staff = staff_battle(0, order=CLERIC_LEADS) + assert CLERIC in front_rank(session) + result = session.execute(ResolveBattleRound(declarations=declarations(session, strike(CLERIC, group, staff)))) + assert result.accepted, [rejection.code for rejection in result.rejections] + assert activations(result.events, staff.instance_id) + assert strikes_by(result.events, CLERIC) + assert staff.charges_remaining == 4 + + def test_without_the_width_limit_every_member_strikes(self): + session, group, staff = staff_battle(0, ruleset=Ruleset(formation_width_limit=False)) + assert session.party.members[-2].id == CLERIC, "precondition: the cleric marches third" + assert CLERIC in front_rank(session) + result = session.execute(ResolveBattleRound(declarations=declarations(session, strike(CLERIC, group, staff)))) + assert result.accepted, [rejection.code for rejection in result.rejections] + assert activations(result.events, staff.instance_id) + assert staff.charges_remaining == 4 + + def test_a_replacement_who_steps_up_strikes(self): + session, group, staff = staff_battle(0) + assert CLERIC not in front_rank(session), "precondition: the cleric starts in the back rank" + withdraw = BattleDeclaration( + character_id=FIGHTER, action="move", move="fighting_withdrawal", replacement_id=CLERIC + ) + result = session.execute( + ResolveBattleRound(declarations=declarations(session, withdraw, strike(CLERIC, group, staff))) + ) + assert result.accepted, [rejection.code for rejection in result.rejections] + assert any(isinstance(event, MemberWithdrewEvent) for event in result.events), "precondition: the swap" + assert activations(result.events, staff.instance_id) + assert strikes_by(result.events, CLERIC) + assert staff.charges_remaining == 4 + + def test_a_strike_that_misses_still_spends_its_charge(self): + for seed in SCAN_SEEDS: + session, group, staff = staff_battle(seed, order=CLERIC_LEADS) + result = session.execute( + ResolveBattleRound(declarations=declarations(session, strike(CLERIC, group, staff))) + ) + assert result.accepted + rolled = strikes_by(result.events, CLERIC) + if len(rolled) == 1 and rolled[0].code == "combat.attack.missed": + assert activations(result.events, staff.instance_id) + assert not [ + event + for event in result.events + if isinstance(event, DamageDealtEvent) and event.attacker_id == CLERIC + ] + assert staff.charges_remaining == 4 + return + pytest.fail("no seed made the cleric miss") + + +# ---------------------------------------------------------------------- energy drain + + +class FixedStream: + """A stand-in stream that returns the given values, so a test picks the hit point die exactly.""" + + def __init__(self, values): + self.values = list(values) + + def randbelow(self, n): + return self.values.pop(0) % n + + +def make_character(class_id: str, level: int, *, max_hp: int) -> Character: + """A character with every ability score 10, so no CON modifier touches the hit point die.""" + definition = load_classes().get(class_id) + return Character( + id="pc", + name="Hero", + class_id=class_id, + race=definition.race, + level=level, + xp=definition.row(level).xp, + scores={ability: 10 for ability in AbilityScore}, + alignment=Alignment.NEUTRAL, + max_hp=max_hp, + current_hp=max_hp, + ) + + +def spawn(template_id: str, monster_id: str): + return spawn_monster( + load_monsters().get(template_id), id=monster_id, stream=RngStreams(master_seed=9).get("monster_spawn") + ) + + +def drain_event(events) -> LevelDrainedEvent: + (event,) = [event for event in events if isinstance(event, LevelDrainedEvent)] + return event + + +class TestHpLostIsTheDropInMaximumHitPoints: + """`LevelDrainedEvent.hp_lost` is how far the victim's maximum hit points fell.""" + + def test_the_dwarf_from_the_playtest_reports_the_two_points_it_lost(self): + # The playtest's dwarf went from level 2 to 1 and from 3 maximum hit points to 1, and the event said 7. + wight = spawn("wight", "m-w") + dwarf = make_character("dwarf", 2, max_hp=3) + events = resolve_energy_drain(wight, dwarf, stream=FixedStream([6])) + assert dwarf.level == 1 and dwarf.max_hp == 1, "precondition: the floor of 1 stopped the loss" + assert drain_event(events).hp_lost == 2 + + def test_a_victim_already_at_one_maximum_hit_point_reports_none_lost(self): + fighter = make_character("fighter", 2, max_hp=1) + result = drain_levels(fighter, load_classes().get("fighter"), xp_policy="halfway", stream=FixedStream([4])) + assert fighter.level == 1 and fighter.max_hp == 1 + assert drain_event(result.events).hp_lost == 0 + + def test_a_two_level_drain_reports_the_whole_drop(self): + fighter = make_character("fighter", 3, max_hp=4) + result = drain_levels( + fighter, load_classes().get("fighter"), levels=2, xp_policy="level_minimum", stream=FixedStream([5, 5]) + ) + assert fighter.level == 1 and fighter.max_hp == 1 + assert result.hp_rolls == (6, 6), "precondition: both levels rolled more than the maximum had to give" + assert drain_event(result.events).hp_lost == 3 + + def test_a_drain_that_kills_reports_the_drop_before_the_killing_level(self): + spectre = spawn("spectre", "m-s") + fighter = make_character("fighter", 2, max_hp=3) + events = resolve_energy_drain(spectre, fighter, stream=FixedStream([6])) + drained = drain_event(events) + assert drained.code == "combat.drain.slain", "precondition: the second level killed the fighter" + assert fighter.max_hp == 1 + assert drained.hp_lost == 2 + + def test_a_monster_drain_stopped_at_one_reports_the_drop(self): + ogre = spawn("ogre", "m-o") + ogre.max_hp = ogre.current_hp = 3 + events = drain_monster_hd(ogre, stream=FixedStream([5])) + assert ogre.max_hp == 1, "precondition: the floor of 1 stopped the loss" + assert drain_event(events).hp_lost == 2 + + def test_a_drain_with_room_to_spare_reports_the_whole_roll(self): + fighter = make_character("fighter", 4, max_hp=24) + result = drain_levels(fighter, load_classes().get("fighter"), xp_policy="halfway", stream=FixedStream([5])) + assert fighter.max_hp == 18 + assert drain_event(result.events).hp_lost == 6 == result.hp_lost + + def test_a_monster_drain_with_room_to_spare_reports_the_whole_roll(self): + ogre = spawn("ogre", "m-o") + before = ogre.max_hp + assert before > 8, "precondition: room for any d8" + events = drain_monster_hd(ogre, stream=FixedStream([4])) + assert drain_event(events).hp_lost == 5 == before - ogre.max_hp + + +class TestTheSpawnConsequenceIsForADrainThatKills: + """`LevelDrainedEvent.spawn_consequence` says what becomes of a character the undead kills, so a drain the + character survives doesn't carry it.""" + + def test_a_drain_the_character_survives_carries_none(self): + fighter = make_character("fighter", 4, max_hp=24) + prose = "A person drained of all levels becomes a wight in 1d4 days." + result = drain_levels( + fighter, + load_classes().get("fighter"), + xp_policy="halfway", + stream=FixedStream([5]), + spawn_consequence=prose, + ) + drained = drain_event(result.events) + assert drained.code == "combat.drain.drained" + assert drained.spawn_consequence is None + + @pytest.mark.parametrize("template_id", ["wight", "wraith", "spectre"]) + def test_an_undead_touch_the_character_survives_carries_none(self, template_id): + fighter = make_character("fighter", 6, max_hp=36) + events = resolve_energy_drain(spawn(template_id, "m-u"), fighter, stream=FixedStream([5, 5])) + drained = drain_event(events) + assert drained.code == "combat.drain.drained" + assert drained.spawn_consequence is None + + def test_the_drain_in_the_wight_golden_battle_carries_none(self): + document = run_wight_battle() + drained = next(event for event in document["events"] if event["code"] == "combat.drain.drained") + assert document["fighter_level"] == 1, "precondition: the fighter survived the drain" + assert drained["spawn_consequence"] is None + + @pytest.mark.parametrize("template_id", ["wight", "wraith", "spectre"]) + def test_a_touch_that_kills_carries_the_monsters_text(self, template_id): + undead = spawn(template_id, "m-u") + fighter = make_character("fighter", 1, max_hp=6) + events = resolve_energy_drain(undead, fighter, stream=FixedStream([])) + drained = drain_event(events) + assert drained.code == "combat.drain.slain" + assert drained.spawn_consequence == undead.template.ability("energy_drain").prose + assert f"becomes a {template_id}" in drained.spawn_consequence + + def test_a_spectre_that_kills_a_level_two_character_carries_it(self): + spectre = spawn("spectre", "m-s") + fighter = make_character("fighter", 2, max_hp=12) + events = resolve_energy_drain(spectre, fighter, stream=FixedStream([3])) + drained = drain_event(events) + assert drained.code == "combat.drain.slain" and drained.levels_lost == 2 + assert drained.spawn_consequence == spectre.template.ability("energy_drain").prose + + @pytest.mark.parametrize("victim", ["troll", "goblin"]) + def test_a_monster_victim_carries_none(self, victim): + events = resolve_energy_drain(spawn("wight", "m-w"), spawn(victim, "m-v"), stream=FixedStream([4])) + assert drain_event(events).spawn_consequence is None + + +class TestATurningOutsideBattleSkipsTheDead: + """`TurnUndead` outside a battle reads only the living undead on the table, as a battle-round turning does.""" + + @pytest.mark.parametrize("seed", range(6)) + def test_a_zombie_killed_before_the_turning_is_not_among_its_types(self, seed): + session = GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=seed) + session.execute(GrantItem(character_id="character-0001", item_id="torch", quantity=6)) + session.execute(GrantItem(character_id="character-0001", item_id="tinder_box")) + session.execute(EnterDungeon(dungeon_id="delve")) + for _ in range(20): + lit = session.execute(LightSource(character_id="character-0001", item_id="torch")) + if any(event.code == "exploration.light.lit" for event in lit.events): + break + zombies = session.spawn("zombie", 1) + skeletons = session.spawn("skeleton", 2) + encounter_module.start_encounter( + session, + groups=[("zombie", zombies), ("skeleton", skeletons)], + kind="spawned", + distance_feet=40, + pinned_stance=ReactionResult.INDIFFERENT, + party_aware=True, + monsters_roll_surprise=False, + ) + zombie_id = session.encounter.groups[0].monster_ids[0] + session.combatant(zombie_id).current_hp = 1 + wand = MagicItemInstance(instance_id="magic-item-9001", template_id="wand_of_cold", charges_remaining=5) + session.member("character-0004").inventory.items.append(wand) + used = session.execute( + UseItem(character_id="character-0004", item_id=wand.instance_id, target_id=session.encounter.groups[0].id) + ) + assert used.accepted, "precondition: the wand use is accepted" + assert has_condition(session.combatant(zombie_id), Condition.DEAD), "precondition: the wand killed the zombie" + assert session.encounter is not None and session.battle is None, "precondition: the encounter goes on" + + result = session.execute(TurnUndead(character_id="character-0003")) + + assert result.accepted + turnings = [event for event in result.events if event.code.startswith("magic.turning.")] + assert turnings, "the turning reports an outcome" + for event in turnings: + assert [kind.template_id for kind in event.types] == ["skeleton"] diff --git a/tests/test_weapon_default_and_device_recheck.py b/tests/test_weapon_default_and_device_recheck.py index 31aa3c8..7598a4c 100644 --- a/tests/test_weapon_default_and_device_recheck.py +++ b/tests/test_weapon_default_and_device_recheck.py @@ -66,6 +66,7 @@ EquipItem, GrantItem, LightSource, + ReorderParty, ResolveBattleRound, SessionMode, SpawnMonsters, @@ -405,12 +406,15 @@ def test_a_member_wielding_only_a_splash_weapon_is_refused_beyond_reach(self): def staff_battle(seed: int, *, identified: bool = False, invisible: bool = False): """Goblins at melee range that are already fleeing, and a cleric with a staff of striking aimed at them. - The declaration passes at the top of the round. When the monsters win initiative, the group runs 60 feet - before the party's magic phase, out of the staff's melee reach. When the party wins, the staff resolves at - 5 feet. The group is still inside 120 feet either way, so it hasn't fled and the battle goes on. + The cleric leads the marching order, so they stand in the front rank, because the staff is "Used as a magical + melee weapon" and only the front rank can melee. The declaration passes at the top of the round. When the + monsters win initiative, the group runs 60 feet before the party's magic phase, out of the staff's melee reach. + When the party wins, the staff resolves at 5 feet. The group is still inside 120 feet either way, so it hasn't + fled and the battle goes on. """ session = GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=seed) _light_and_enter(session) + assert session.execute(ReorderParty(order=(CLERIC, FIGHTER, THIEF, MAGIC_USER))).accepted instances = session.spawn("goblin", 2) encounter_module.start_encounter( session, @@ -422,6 +426,7 @@ def staff_battle(seed: int, *, identified: bool = False, invisible: bool = False ) session.execute(EngageBattle()) assert session.mode is SessionMode.BATTLE + assert CLERIC in session.view(Visibility.PLAYER).encounter.front_rank group = session.encounter.groups[0] group.fleeing = True staff = MagicItemInstance(