diff --git a/CHANGELOG.md b/CHANGELOG.md index dccc77c..87dca95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,12 +32,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - 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. - 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 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. 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 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. +- *Death spell* cast at a group with `target_group_id` now takes only the creatures its 60' cube covers. Before, the cast took every living member of the group. The cast now uses the same footprint as any other area spell in [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration]: the group's living members in spawn order up to the footprint's capacity, then the party's front rank when `aoe_friendly_fire` applies. A cast with named `targets` still uses those targets. *Sleep* has no area in the OSE SRD, so it still takes every living member of the group. - When a wandering monster interrupts a rest, the full turns the party rested before the monsters came now count (#169). Before, a 48-turn night broken after four full turns cleared neither fatigue nor exhaustion. Now one full turn of rest clears fatigue. Each full turn also counts toward the three that clear exhaustion from a chase, and the next rest adds to that count. A rest interrupted during its first turn still clears nothing. An interrupted night still isn't sleep for preparing spells, and an interrupted day still heals nobody. The fix makes no new draws, and no golden file changed. - Trolls, thouls, and vampires regenerate in play (#167). Until this release a spawned one never healed, though its template had the ability. A monster whose template has the `regeneration` ability now starts to regenerate when its encounter opens. That's true whether the monster came from a [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters] command, a keyed area, or a wandering-monster check. The command that opened the encounter reports an [`EffectAttachedEvent`][osrlib.core.events.EffectAttachedEvent] for each such monster. A hurt troll gets back 3 hit points a round, starting 3 rounds after it last took damage. It never gets back damage from fire or acid. A troll slain while its encounter is still running gets back up 2d6 rounds later and fights again. It stays dead if fire and acid alone did as much damage as its maximum hit points. The session rolls the revival delay on the effects stream. A thoul or a vampire heals every round while it lives. The session releases the effect when the encounter ends, so a troll that is still dead then stays dead. A game recorded under 2.0.1 that met one of these monsters replays differently under this release. No golden file changed. - Undead that a cleric turns now flee and attack no one, even when the cleric turns only some of a group (#168). Before, the turned members of a partly turned group kept closing and attacking, the cleric who turned them included. Now they break away as a new group, which runs from the battle the way a wholly turned group does. The session reports the split with a [`MonsterFledEvent`][osrlib.crawl.events.MonsterFledEvent] whose `group_id` is the new group's id. The members who weren't turned stay in the original group and fight on, and a policy you registered for that group still chooses their actions. The group's shared treasure stays with the members who fight on, and each turned member takes only the treasure it carries. A turned monster that can't move, such as one caught in a *web*, stays where it is and does nothing. The session skips any action a policy returns for a turned monster. A wholly turned group behaves as before, and no golden file changed. diff --git a/docs/adaptations.md b/docs/adaptations.md index 3c7872c..f69fa0b 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 where a spell like *sleep* spends a Hit Dice budget or decides whether the monster is a 4+1 creature. 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`. +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`. ### The damage pipeline order @@ -280,7 +280,7 @@ The spell page's target is "an arcane spell caster (e.g. a magic-user or elf)": ### The turning column is the HD count, `2*` only at count 2, counts above 9 unturnable -A monster's column is its `hit_dice.count` as a string, except count 2 with a special ability (asterisked HD) maps to `2*` (the table's footnote), counts 7-9 share `7-9`, and counts above 9 have no column, so turning fails. The printed table is the rule, and the "referee may expand" footnote is left to game data. Modifiers never shift columns (the mummy's 5+1 turns on column 5) and the asterisk matters only at count 2 (the wight's `3*` is column 3). Locked by `test_turning.py::TestColumnMapping` and the verbatim table fidelity test. +A monster's column is its `hit_dice.count` as a string, except count 2 with a special ability (asterisked HD) maps to `2*` (the table's footnote), counts 7-9 share `7-9`, and counts above 9 have no column, so turning fails. The printed table is the rule, and the "referee may expand" footnote is left to game data. Modifiers never shift columns (the mummy's 5+1 turns on column 5) and the asterisk matters only at count 2 (the wight's `3*` is column 3). The column belongs to the kind of monster, because the SRD compares the roll "against the HD of the type of undead monsters targeted", so a drained monster turns on its template's column. A wight drained to 2 HD still turns on column 3. Locked by `test_turning.py::TestColumnMapping`, the verbatim table fidelity test, and `test_drain_and_area.py::TestTheTurningColumnStaysWithTheType`. ### The turning pool spends lowest-first, stops at the first unaffordable monster, and always affects one @@ -384,7 +384,7 @@ A declaration is judged twice: once when the round is accepted, and again in the An area effect targets one group, and its capacity in creatures is `ceil(span / 10) × width`, where span is the diameter (radius shapes), length (lines, cubes, clouds), or reach-limited length (cones: length minus the gap), and width is the formation width (unbounded with the flag off). Candidates fill in stable spawn order up to capacity. Under `aoe_friendly_fire`, an area landing on a group at melee range appends the engaged party front rank after the monsters, in marching order, consuming remaining capacity. Breath weapons against the party cover `ceil(reach-limited span / 10)` ranks from the front, and single-target breath (the hellhound) picks uniformly from the front rank on the monster-action stream. Locked by `test_battle.py::TestAreaFootprint`. -An HD-budget spell needs no footprint to aim at a group. A `cast` in an `hd_budget` mode with a `target_group_id` and no `targets` targets every living member of that group, in spawn order, and the targeting mode spends the budget on them weakest first. That's the same cast as naming each of those members in `targets`. The player view shows group ids and no monster ids, so a front end built on it casts *sleep* in battle this way. The SRD's *sleep* page gives the spell a range and a Hit Dice budget but no area, so the session doesn't cap the candidates with a footprint. *Death spell* is also `hd_budget`, and the session doesn't apply its "60' cube" when the cast aims at a group either (pinned). When both are set, the session uses the named `targets`, so a recorded command log replays as it did. The session refuses a declaration that sets neither with `magic.cast.target_count`. Locked by `test_battle_player_view.py::TestAnHdBudgetSpellAimedAtAGroup`. +An HD-budget spell can aim at a group too. A `cast` in an `hd_budget` mode with a `target_group_id` and no `targets` takes its candidates from that group, and the targeting mode spends the budget on them weakest first. The player view shows group ids and no monster ids, so a front end built on it casts *sleep* in battle this way. The SRD's *sleep* page gives the spell a range and a Hit Dice budget but no area, so a group-aimed *sleep* takes every living member of the group, in spawn order. That's the same cast as naming each of those members in `targets`. *Death spell*'s page gives it "a 60' cube area", so a group-aimed *death spell*, like any `hd_budget` mode with an area, takes the candidates its area would take under the footprint rule above: the group's living members in spawn order up to the area's capacity, then the engaged front rank under `aoe_friendly_fire`. When both are set, the session uses the named `targets`, with no footprint, so a recorded command log replays as it did. The session refuses a declaration that sets neither with `magic.cast.target_count`. Locked by `test_battle_player_view.py::TestAnHdBudgetSpellAimedAtAGroup` and `test_drain_and_area.py::TestAnHdBudgetSpellWithAnAreaAimedAtAGroup`. ### The default action policy and morale diff --git a/src/osrlib/core/combat.py b/src/osrlib/core/combat.py index b80d186..2eabace 100644 --- a/src/osrlib/core/combat.py +++ b/src/osrlib/core/combat.py @@ -1575,9 +1575,9 @@ def check_immunity( missile. An arrow or a thrown flask is blocked, and a hurled boulder or a magic arrow isn't. - Under the `hd5_counts_as_magical` ruleset flag, a monster attacker of 5 or more Hit - Dice, or one that only silver or magic can harm itself, can harm a monster that only - silver or magic can harm. + Under the `hd5_counts_as_magical` ruleset flag, a monster attacker with 5 or more Hit + Dice left after any energy drain, or one that only silver or magic can harm itself, can + harm a monster that only silver or magic can harm. Args: defender: The defending [`Creature`][osrlib.core.creature.Creature], which a @@ -1643,7 +1643,7 @@ def check_immunity( if ruleset.hd5_counts_as_magical and attacker is not None and gate_values <= {"silver", "magic"}: attacker_template = getattr(attacker, "template", None) if attacker_template is not None: - if attacker_template.hit_dice.count >= 5: + if getattr(attacker, "hit_dice_count", attacker_template.hit_dice.count) >= 5: return False attacker_gate = {key.value for key in attacker_template.defenses.harmed_only_by} if attacker_gate and attacker_gate <= {"silver", "magic"}: diff --git a/src/osrlib/core/ruleset.py b/src/osrlib/core/ruleset.py index 072997d..1791217 100644 --- a/src/osrlib/core/ruleset.py +++ b/src/osrlib/core/ruleset.py @@ -202,9 +202,10 @@ class Ruleset(BaseModel): hd5_counts_as_magical: bool = False """Let big monsters hurt creatures that only silver or magic weapons can harm. An optional rule, off by default. - The SRD prints this among its invulnerability rules. Turn it on and a monster of 5 or + The SRD prints this among its invulnerability rules. Turn it on and a monster with 5 or more Hit Dice gets past such a defense, and so does a monster that has the same kind - of defense itself. osrlib applies the rule only where the defense is limited to silver + of defense itself. A monster an energy drain has taken Hit Dice from counts only the + Hit Dice it has left. osrlib applies the rule only where the defense is limited to silver and magic, as the rule is worded, and interprets "another invulnerable monster" as a monster with such a defense. """ diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 96fdb29..d0342e8 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -1609,10 +1609,12 @@ def _cast_targets(session, declaration: BattleDeclaration, spell) -> tuple[list, A cast in an `area` mode aims at `target_group_id`, and its targets are the group's living members that fit the area footprint, followed by the party's front rank when `aoe_friendly_fire` applies. - A cast in an `hd_budget` mode with a `target_group_id` and no `targets` targets every living - member of the group, in spawn order, and the kernel spends the budget on them weakest first. - That's the same cast as naming those members one by one, so a front end with only the player - view's group ids can cast *sleep*. When both are set, the cast uses the named `targets`. For a + A cast in an `hd_budget` mode with a `target_group_id` and no `targets` takes the group's living + members in spawn order, and the kernel spends the budget on them weakest first. A mode with no + area, like *sleep*, takes every living member. That's the same cast as naming those members one by + one, so a front end with only the player view's group ids can cast *sleep*. A mode with an area, + like *death spell*'s 60' cube, takes only the candidates the area footprint takes, as an `area` + mode does. When both are set, the cast uses the named `targets`. For a group that isn't in the encounter, or that has fled, this returns a `battle.declaration.unknown_group` rejection. """ @@ -1633,7 +1635,7 @@ def _cast_targets(session, declaration: BattleDeclaration, spell) -> tuple[list, if group is None or group.fled: unknown = Rejection(code="battle.declaration.unknown_group", params={"character": declaration.character_id}) return [], None, [unknown] - if targeting.mode is TargetingMode.AREA: + if targeting.mode is TargetingMode.AREA or targeting.shape is not None: candidates = _area_candidates(session, group, targeting.shape, targeting.dimensions) else: candidates = _living_monsters(session, group) @@ -2315,7 +2317,8 @@ def _ward_bars_monster(session, monster) -> bool: A ward bars matching monsters up to its rolled per-HD-band count (the 1-3, 4-5, and 6+ HD bands), counted in spawn order among the encounter's matching - monsters. The scroll of protection from elementals bars every elemental it matches, with no count. + monsters. A monster's band follows the Hit Dice it has left, so a drained monster counts in a lower band. + The scroll of protection from elementals bars every elemental it matches, with no count. """ wards = _party_wards(session) if not wards: @@ -2330,7 +2333,7 @@ def _ward_bars_monster(session, monster) -> bool: continue if params.get("all_affected"): return True - hd = template.hit_dice.count + hd = monster.hit_dice_count band_key = "affected_1_3" if hd <= 3 else ("affected_4_5" if hd <= 5 else "affected_6_plus") count = int(params.get(band_key, 0)) if count <= 0: @@ -2353,7 +2356,7 @@ def _ward_matches(monster, params) -> bool: def _hd_band(monster) -> str: - hd = monster.template.hit_dice.count + hd = monster.hit_dice_count return "affected_1_3" if hd <= 3 else ("affected_4_5" if hd <= 5 else "affected_6_plus") @@ -3067,6 +3070,7 @@ def _group_morale(session, group, fire_damaged) -> list[Event]: def _confused_overrides(session, group, actions) -> 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. @@ -3082,10 +3086,12 @@ def _confused_overrides(session, group, actions) -> list[Event]: continue effect = confusion_effects[0] params = effect.definition.params - hd = monster.template.hit_dice + hd_count = monster.hit_dice_count resave_min = int(params.get("resave_hd_min_count", 3)) - qualifies = hd.count >= resave_min or ( - bool(params.get("resave_at_hd_count_2_with_bonus")) and hd.count == 2 and hd.modifier > 0 + qualifies = hd_count >= resave_min or ( + bool(params.get("resave_at_hd_count_2_with_bonus")) + and hd_count == 2 + and monster.template.hit_dice.modifier > 0 ) if qualifies: save = saving_throw( diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index b047d41..3012dd2 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2442,7 +2442,9 @@ class BattleDeclaration(BaseModel): so do a `close` move, a `cast` of a spell that covers an area, a thrown item, and a wand, staff, or rod that strikes or covers an area. A `cast` in an `hd_budget` mode, like *sleep*, can use it in place of `targets`. The session then spends the spell's Hit Dice budget on the - group's living members, weakest first. The session refuses a group that isn't in the + group's living members, weakest first. A mode with an area, like *death spell*'s 60' cube, + takes only the members its area would take, as a `cast` of a spell that covers an area does. + The session refuses a group that isn't in the encounter, or one whose [`EncounterGroupView.fled`][osrlib.crawl.views.EncounterGroupView.fled] is true, with `battle.declaration.unknown_group`.""" @@ -2493,8 +2495,9 @@ class BattleDeclaration(BaseModel): [`CastSpell.targets`][osrlib.crawl.commands.CastSpell.targets]: member ids, monster ids, or a `cell:` reference. Leave it empty for a spell that covers an area, which takes `target_group_id` instead. For an `hd_budget` mode, you can also leave it empty and set - `target_group_id`. That's the same cast as naming every living member of the group in spawn - order. When both are set, the session uses `targets`. The session refuses an invisible target + `target_group_id`. For a mode with no area, like *sleep*'s, that's the same cast as naming every + living member of the group in spawn order. A mode with an area, like *death spell*'s, takes the + members its area would take. When both are set, the session uses `targets`. The session refuses an invisible target with `battle.declaration.invisible_target`.""" move: Literal["close", "fighting_withdrawal", "retreat"] | None = None """Which movement a `move` declaration makes. diff --git a/tests/test_drain_and_area.py b/tests/test_drain_and_area.py new file mode 100644 index 0000000..b6c77b7 --- /dev/null +++ b/tests/test_drain_and_area.py @@ -0,0 +1,496 @@ +"""Two rules gaps: rules that still read a drained monster's full Hit Dice, and *death spell*'s 60' cube. + +**Hit Dice after a drain.** The OSE SRD's wight page says a target of energy drain "permanently loses one experience +level (or Hit Die)" and with it "all other benefits due to the drained level (e.g. spells, saving throws, etc.)". +osrlib already judges a drained monster by the Hit Dice it has left for its THAC0, its saves, and *sleep*. Three more +rules measure a creature's power by its Hit Dice, and each one should read the Hit Dice the monster has left: + +- *Confusion*'s re-save. The spell page reads "Subjects of 2+1 HD or greater: May save versus spells each round" and + "Subjects of 2 HD or lower: May not make a saving throw." A 3 HD boar drained to 2 HD is a subject of 2 HD. +- The protection scrolls' Hit Dice bands. The scroll page reads "A number of undead monsters are barred from entering + the circle of protection. The number depends on their Hit Dice: 1–3 HD: 2d12 undead affected, 4–5 HD: 2d6 undead + affected, 6+ HD: 1d6 undead affected." A 4 HD wraith drained to 3 HD counts in the 1–3 band. +- The invulnerabilities optional rule. The combat page lets a monster that only magic or silver can harm also be + harmed by "A monster with 5 HD or greater." A 5 HD grizzly drained to 4 HD no longer qualifies. + +One read stays with the template, and the guards below pin it. The cleric's turning table compares the roll "against +the HD of the type of undead monsters targeted", and its header reads "Hit Dice of Monster Type". The column belongs to +the kind of monster, and osrlib rolls once per kind, so a drained wight still turns on the wight's column. + +**A group-aimed *death spell*.** A `cast` in an `hd_budget` mode aimed at a group with `target_group_id` takes the +group's living members as candidates. *Sleep*'s page gives the spell a range and a Hit Dice budget but no area, so it +takes every living member. *Death spell*'s page says "Up to 4d8 Hit Dice of creatures within a 60' cube area", so a +group-aimed *death spell* takes exactly the candidates its area would take, under the area footprint rule: the +group's living members in spawn order up to the footprint's capacity, then the engaged front rank under +`aoe_friendly_fire`. Named `targets` still win when both are set. +""" + +from crawl_fixtures import build_adventure +from osrlib.core.abilities import AbilityScore +from osrlib.core.character import ADVANCEMENT_STREAM, Character +from osrlib.core.clock import GameClock, TimeUnit +from osrlib.core.combat import AttackContext, check_immunity, damage_source_for, drain_monster_hd +from osrlib.core.effects import Condition, EffectDefinition, EffectsLedger, has_condition +from osrlib.core.events import Visibility +from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, MonsterInstance, spawn_monster +from osrlib.core.rng import RngStreams +from osrlib.core.ruleset import Ruleset +from osrlib.core.spells import MAGIC_STREAM, MemorizedSpell, turn_undead +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, ScriptedPolicy +from osrlib.crawl.commands import ( + BattleDeclaration, + CommandResult, + EngageBattle, + EnterDungeon, + GrantItem, + LightSource, + ResolveBattleRound, + SessionMode, +) +from osrlib.crawl.party import Party +from osrlib.crawl.session import GameSession +from osrlib.data import load_classes, load_monsters, load_spells + +FIGHTER = "character-0001" +SECOND_FIGHTER = "character-0002" +MAGE = "character-0003" + +# ---------------------------------------------------------------------- shared helpers + + +def adventurer(name: str, class_id: str, level: int, hp: int, **fields) -> Character: + definition = load_classes().get(class_id) + return Character( + name=name, + class_id=class_id, + race=definition.race, + level=level, + xp=definition.row(level).xp, + scores={ability: 11 for ability in AbilityScore}, + alignment="lawful", + max_hp=hp, + current_hp=hp, + **fields, + ) + + +def drain(monster: MonsterInstance, levels: int = 1) -> MonsterInstance: + """Drain Hit Dice from a monster as a wight's touch does, and check that it worked. + + The drain draws from a stream of its own, so the session's streams are untouched. + """ + before = monster.hit_dice_count + events = drain_monster_hd(monster, levels=levels, stream=RngStreams(master_seed=99).get(ADVANCEMENT_STREAM)) + assert events[0].code == "combat.drain.drained" + assert monster.drained_hd == levels and monster.hit_dice_count == before - levels + assert not has_condition(monster, Condition.DEAD) + return monster + + +def battle( + template_id: str, + count: int, + *, + distance: int, + seed: int = 5, + ruleset: Ruleset | None = None, +) -> GameSession: + """A battle in the test delve's entrance corridor against one group. + + The party is two 3rd-level fighters, who make up the front rank, and a 12th-level magic-user behind them with + *sleep* and *death spell* memorized. The corridor is one cell wide, which holds two abreast. + """ + party = Party( + members=[ + adventurer("Brakk", "fighter", 3, 20), + adventurer("Hild", "fighter", 3, 20), + adventurer( + "Elara", + "magic_user", + 12, + 30, + spell_book=("sleep", "death_spell"), + memorized_spells=(MemorizedSpell(spell_id="sleep"), MemorizedSpell(spell_id="death_spell")), + ), + ] + ) + session = GameSession.new(party, build_adventure(wandering_chance=0), seed=seed, ruleset=ruleset) + assert [member.id for member in session.party.members] == [FIGHTER, SECOND_FIGHTER, MAGE] + assert session.execute(GrantItem(character_id=FIGHTER, item_id="torch", quantity=6)).accepted + assert session.execute(GrantItem(character_id=FIGHTER, item_id="tinder_box")).accepted + 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): + break + assert session.party_light()[0], "precondition: the party carries a lit torch" + 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, + ) + if session.mode is SessionMode.ENCOUNTER: + assert session.execute(EngageBattle()).accepted + assert session.mode is SessionMode.BATTLE, "precondition: the encounter became a battle" + assert len(session.encounter.groups) == 1 and len(session.encounter.groups[0].monster_ids) == count + return session + + +def round_with(session: GameSession, *declarations: BattleDeclaration) -> CommandResult: + """Resolve one battle round: the given declarations, and a `hold` for every other member who can declare.""" + from osrlib.core.combat import incapacitated + + covered = {declaration.character_id for declaration in declarations} + holds = [ + BattleDeclaration(character_id=member.id, action="hold") + for member in session.party.living_members() + if member.id not in covered and not incapacitated(member) + ] + return session.execute(ResolveBattleRound(declarations=(*declarations, *holds))) + + +def living_ids(session: GameSession) -> tuple[str, ...]: + group = session.encounter.groups[0] + return tuple( + monster_id + for monster_id in group.monster_ids + if not has_condition(session.combatant(monster_id), Condition.DEAD) + ) + + +# ---------------------------------------------------------------------- confusion's re-save + + +def confuse(session: GameSession, monster: MonsterInstance) -> None: + """Put *confusion* on a monster with the spell's own params, as a casting would.""" + params = load_spells().get("confusion").modes[0].effect.params + assert params["resave_hd_min_count"] == 3 and params["resave_at_hd_count_2_with_bonus"] is True + confusion = EffectDefinition( + kind="confusion", + condition=Condition.CONFUSED, + duration_unit=TimeUnit.ROUND, + duration_amount=12, + params=dict(params), + ) + effect, _ = session.ledger.attach( + confusion, monster.id, clock=session.clock, allocator=session.allocator, registry=session.registry() + ) + assert effect is not None and has_condition(monster, Condition.CONFUSED) + + +def confusion_resaves(template_id: str, drained: int) -> list: + """Confuse one monster, drained of `drained` Hit Dice, and return its re-saves from one battle round.""" + session = battle(template_id, 1, distance=60, seed=9) + monster = session.monsters[session.encounter.groups[0].monster_ids[0]] + if drained: + drain(monster, drained) + confuse(session, monster) + result = round_with(session) + assert result.accepted, [rejection.code for rejection in result.rejections] + return [ + event + for event in result.events + if getattr(event, "category", None) == "spells" and getattr(event, "target_id", None) == monster.id + ] + + +class TestConfusionResavesByTheHitDiceLeft: + """*Confusion*: "Subjects of 2+1 HD or greater: May save versus spells each round". "Subjects of 2 HD or lower: May + not make a saving throw." A drained subject is judged by the Hit Dice it has left, with its template's modifier.""" + + def test_an_undrained_3_hd_boar_resaves(self): + boar = load_monsters().get("boar").hit_dice + assert (boar.count, boar.modifier) == (3, 0) + assert len(confusion_resaves("boar", 0)) == 1 + + def test_a_boar_drained_to_2_hd_makes_no_resave(self): + assert confusion_resaves("boar", 1) == [] + + def test_an_undrained_2_plus_1_lizard_man_resaves(self): + lizard = load_monsters().get("lizard_man").hit_dice + assert (lizard.count, lizard.modifier) == (2, 1) + assert len(confusion_resaves("lizard_man", 0)) == 1 + + def test_a_lizard_man_drained_to_1_plus_1_makes_no_resave(self): + assert confusion_resaves("lizard_man", 1) == [] + + def test_an_ogre_drained_to_2_plus_1_still_resaves(self): + # The template's +1 stays after a drain, so the ogre is a 2+1 subject. + ogre = load_monsters().get("ogre").hit_dice + assert (ogre.count, ogre.modifier) == (4, 1) + assert len(confusion_resaves("ogre", 2)) == 1 + + +# ---------------------------------------------------------------------- the protection scrolls' bands + + +def ward_scene(*, affected_1_3: int, affected_4_5: int, drained_first: bool) -> tuple[GameSession, list]: + """Two wraiths at melee range and a *protection from undead* ward on the party with fixed band counts. + + Returns: + The session and the two wraiths in spawn order. The first is drained to 3 HD when `drained_first` is set. + """ + session = battle("wraith", 2, distance=MELEE_RANGE_FEET, seed=5) + group = session.encounter.groups[0] + assert group.distance_feet <= MELEE_RANGE_FEET + wraiths = [session.monsters[monster_id] for monster_id in group.monster_ids] + assert all(wraith.hit_dice_count == 4 for wraith in wraiths) + assert all("undead" in wraith.template.categories for wraith in wraiths) + if drained_first: + drain(wraiths[0]) + assert wraiths[0].hit_dice_count == 3 + ward = EffectDefinition( + kind="protection_ward", + duration_unit=TimeUnit.TURN, + duration_amount=6, + params={ + "party_wide": True, + "bars_categories": ("undead",), + "targets": "undead", + "affected_1_3": affected_1_3, + "affected_4_5": affected_4_5, + "affected_6_plus": 0, + }, + ) + effect, _ = session.ledger.attach( + ward, FIGHTER, clock=session.clock, allocator=session.allocator, registry=session.registry() + ) + assert effect is not None + return session, wraiths + + +def chosen_kinds(session: GameSession) -> dict[str, str]: + """What the default monster policy has each wraith do this round. A barred wraith holds, the other melees.""" + actions = ScriptedPolicy().choose(session, session.encounter.groups[0], RngStreams(master_seed=1).get("probe")) + return {action.monster_id: action.kind for action in actions} + + +class TestProtectionWardBandsByTheHitDiceLeft: + """The scroll page: "The number depends on their Hit Dice: 1–3 HD: 2d12 undead affected, 4–5 HD: 2d6 undead + affected, 6+ HD: 1d6 undead affected." A drained monster counts in the band of the Hit Dice it has left.""" + + def test_an_undrained_pair_shares_the_4_to_5_band(self): + session, (first, second) = ward_scene(affected_1_3=0, affected_4_5=1, drained_first=False) + assert chosen_kinds(session) == {first.id: "hold", second.id: "melee"} + + def test_a_wraith_drained_to_3_hd_counts_in_the_1_to_3_band(self): + session, (drained, whole) = ward_scene(affected_1_3=1, affected_4_5=0, drained_first=True) + assert chosen_kinds(session) == {drained.id: "hold", whole.id: "melee"} + + def test_a_wraith_drained_to_3_hd_leaves_the_4_to_5_band(self): + # With one wraith barred in the 4-5 band, the barred one is the whole wraith, not the first in spawn order. + session, (drained, whole) = ward_scene(affected_1_3=0, affected_4_5=1, drained_first=True) + assert chosen_kinds(session) == {drained.id: "melee", whole.id: "hold"} + + +# ---------------------------------------------------------------------- 5 HD or greater counts as magical + + +def kernel_monster(template_id: str, *, drained: int = 0) -> MonsterInstance: + instance = spawn_monster( + load_monsters().get(template_id), + id=f"{template_id}-1", + stream=RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM), + ) + return drain(instance, drained) if drained else instance + + +def harms_the_wight(attacker: MonsterInstance) -> bool: + """Whether the attacker's first attack gets past a wight's silver-or-magic gate under the optional rule.""" + wight = kernel_monster("wight") + assert set(wight.template.defenses.harmed_only_by) == {"silver", "magic"} + attack = attacker.template.attacks[0].attacks[0] + source = damage_source_for(attacker, attack, AttackContext()) + assert check_immunity(wight, source, ruleset=Ruleset(), attacker=attacker), "precondition: the gate holds" + return not check_immunity(wight, source, ruleset=Ruleset(hd5_counts_as_magical=True), attacker=attacker) + + +class TestFiveHitDiceCountsByTheHitDiceLeft: + """The invulnerabilities optional rule: such a monster may "also be harmed by attacks from ... A monster with 5 HD + or greater." A drained monster qualifies by the Hit Dice it has left.""" + + def test_an_undrained_5_hd_grizzly_harms_a_wight(self): + grizzly = kernel_monster("grizzly_bear") + assert grizzly.hit_dice_count == 5 and not grizzly.template.defenses.harmed_only_by + assert harms_the_wight(grizzly) + + def test_a_grizzly_drained_to_4_hd_cannot_harm_a_wight(self): + grizzly = kernel_monster("grizzly_bear", drained=1) + assert grizzly.hit_dice_count == 4 + assert not harms_the_wight(grizzly) + + def test_a_troll_drained_to_5_hd_still_harms_a_wight(self): + troll = kernel_monster("troll", drained=1) + assert troll.hit_dice_count == 5 + assert harms_the_wight(troll) + + def test_a_drained_wraith_still_harms_a_wight_as_another_invulnerable_monster(self): + # The rule's other bullet, "Another invulnerable monster", reads the monster's defenses, not its Hit Dice. + wraith = kernel_monster("wraith", drained=1) + assert wraith.hit_dice_count == 3 + assert harms_the_wight(wraith) + + +# ---------------------------------------------------------------------- the turning column stays with the type + + +class TestTheTurningColumnStaysWithTheType: + """The cleric page compares the turning roll "against the HD of the type of undead monsters targeted", in a table + headed "Hit Dice of Monster Type". The column belongs to the kind of monster, so a drained wight keeps its own.""" + + def turn(self, wight: MonsterInstance): + cleric = adventurer("Wynn", "cleric", 3, 14) + streams = RngStreams(master_seed=12) + return turn_undead( + cleric, + load_classes().get("cleric"), + [wight], + ledger=EffectsLedger(), + clock=GameClock(), + allocator=IdAllocator(), + registry={wight.id: wight}, + stream=streams.get(MAGIC_STREAM), + ) + + def test_an_undrained_wight_turns_on_column_3(self): + wight = kernel_monster("wight") + assert (wight.template.hit_dice.count, wight.template.hit_dice.asterisks) == (3, 1) + assert [outcome.column for outcome in self.turn(wight).outcomes] == ["3"] + + def test_a_wight_drained_to_2_hd_still_turns_on_column_3(self): + wight = kernel_monster("wight", drained=1) + assert wight.hit_dice_count == 2 # the Hit Dice left would read as column 2* + assert [outcome.column for outcome in self.turn(wight).outcomes] == ["3"] + + +# ---------------------------------------------------------------------- an hd_budget spell with an area + + +CORRIDOR_WIDTH = 2 +"""The test delve's entrance corridor is one cell wide, and the formation width rule fits two abreast in it.""" + +CUBE_CAPACITY = 12 +"""*Death spell*'s 60' cube in that corridor holds ceil(60 / 10) × 2 = 12 creatures, under the area footprint rule.""" + +GOBLINS = 16 +"""More goblins than the cube holds.""" + +DEATH_SEED = 2 +"""A master seed whose *death spell* budget, cast at the sixteen goblins, pays for more than the cube holds.""" + +SLEEP_SEED = 4 +"""A master seed whose *sleep* budget, cast at the sixteen goblins, pays for more than a 60' cube would hold.""" + + +def death_spell(*, group_id: str | None = None, targets: tuple[str, ...] = ()) -> BattleDeclaration: + return BattleDeclaration( + character_id=MAGE, + action="cast", + spell_id="death_spell", + spell_mode="kill", + target_group_id=group_id, + targets=targets, + ) + + +def sleep(*, group_id: str | None = None, targets: tuple[str, ...] = ()) -> BattleDeclaration: + return BattleDeclaration( + character_id=MAGE, + action="cast", + spell_id="sleep", + spell_mode="hd_budget", + target_group_id=group_id, + targets=targets, + ) + + +def selected(result: CommandResult) -> tuple[str, ...]: + assert result.accepted, [(rejection.code, dict(rejection.params)) for rejection in result.rejections] + selections = [ + event for event in result.events if event.code == "combat.targeting.selected" and event.mode == "hd_budget" + ] + assert len(selections) == 1, "the cast selected its targets once, by Hit Dice budget" + return selections[0].target_ids + + +def fingerprint(session: GameSession, result: CommandResult) -> dict: + """What a resolved round did and left behind, for comparing two runs.""" + return { + "accepted": result.accepted, + "events": [event.model_dump(mode="json") for event in result.events], + "streams": session.streams.export_states(), + "view": session.view(Visibility.PLAYER).model_dump(mode="json"), + } + + +def goblins_in_the_corridor(seed: int, distance: int = 60, ruleset: Ruleset | None = None) -> GameSession: + session = battle("goblin", GOBLINS, distance=distance, seed=seed, ruleset=ruleset) + assert battle_module._formation_width(session) == CORRIDOR_WIDTH, "precondition: the party is in the corridor" + return session + + +class TestAnHdBudgetSpellWithAnAreaAimedAtAGroup: + """*Death spell*: "Up to 4d8 Hit Dice of creatures within a 60' cube area". Aimed at a group, it takes the + candidates its area would take. *Sleep* has no area and still takes every living member.""" + + def test_named_targets_pay_for_more_than_the_cube_holds(self): + # The calibration for the next test: the seed's budget reaches past the twelfth goblin. + session = goblins_in_the_corridor(DEATH_SEED) + assert len(selected(round_with(session, death_spell(targets=living_ids(session))))) > CUBE_CAPACITY + + def test_a_death_spell_aimed_at_a_group_takes_only_what_its_cube_holds(self): + by_group = goblins_in_the_corridor(DEATH_SEED) + cube = living_ids(by_group)[:CUBE_CAPACITY] + result = round_with(by_group, death_spell(group_id=by_group.encounter.groups[0].id)) + assert set(selected(result)) <= set(cube) + + def test_aiming_a_death_spell_at_a_group_is_naming_what_its_cube_holds(self): + by_group = goblins_in_the_corridor(DEATH_SEED) + by_name = goblins_in_the_corridor(DEATH_SEED) + cube = living_ids(by_name)[:CUBE_CAPACITY] + aimed = round_with(by_group, death_spell(group_id=by_group.encounter.groups[0].id)) + named = round_with(by_name, death_spell(targets=cube)) + assert selected(named) == cube, "precondition: the budget pays for the whole cube" + assert fingerprint(by_group, aimed) == fingerprint(by_name, named) + + def test_a_death_spell_aimed_at_a_group_in_melee_catches_the_front_rank(self): + # Under `aoe_friendly_fire`, an area that lands on a group at melee range takes the engaged front rank after + # the monsters, as a *fire ball* does. + by_group = battle("goblin", 2, distance=MELEE_RANGE_FEET, seed=1) + by_name = battle("goblin", 2, distance=MELEE_RANGE_FEET, seed=1) + assert by_group.ruleset.aoe_friendly_fire + area = (*living_ids(by_name), FIGHTER, SECOND_FIGHTER) + aimed = round_with(by_group, death_spell(group_id=by_group.encounter.groups[0].id)) + named = round_with(by_name, death_spell(targets=area)) + assert set(selected(named)) == set(area), "precondition: the budget pays for the goblins and the front rank" + assert fingerprint(by_group, aimed) == fingerprint(by_name, named) + + def test_without_friendly_fire_a_death_spell_in_melee_takes_only_the_group(self): + session = battle("goblin", 2, distance=MELEE_RANGE_FEET, seed=1, ruleset=Ruleset(aoe_friendly_fire=False)) + goblins = living_ids(session) + result = round_with(session, death_spell(group_id=session.encounter.groups[0].id)) + assert set(selected(result)) <= set(goblins) + + def test_named_targets_beside_a_group_id_are_not_capped(self): + session = goblins_in_the_corridor(DEATH_SEED) + result = round_with(session, death_spell(group_id=session.encounter.groups[0].id, targets=living_ids(session))) + assert len(selected(result)) > CUBE_CAPACITY + + def test_a_sleep_aimed_at_a_group_takes_more_than_a_cube_would_hold(self): + session = goblins_in_the_corridor(SLEEP_SEED) + assert load_spells().get("sleep").mode("hd_budget").targeting.dimensions == {} + assert len(selected(round_with(session, sleep(group_id=session.encounter.groups[0].id)))) > CUBE_CAPACITY + + def test_aiming_a_sleep_at_a_group_is_still_naming_every_living_member(self): + by_group = goblins_in_the_corridor(SLEEP_SEED) + by_name = goblins_in_the_corridor(SLEEP_SEED) + aimed = round_with(by_group, sleep(group_id=by_group.encounter.groups[0].id)) + named = round_with(by_name, sleep(targets=living_ids(by_name))) + assert fingerprint(by_group, aimed) == fingerprint(by_name, named)