From 0d9079c5d4543841193f74376e9c337201d01846 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 15:33:19 -0700 Subject: [PATCH 1/7] Pin the monster-tactics contract and its acceptance tests Four playtest findings, each pinned by docstrings, the adaptations register, and tests/test_monster_tactics.py. The tests are marked xfail(reason="chunk: monster-tactics", strict=True), and the ones that pass today are unmarked guards. No behavior changes in this commit. - A spitting cobra's spit hit makes the target save versus death, the category for poison, or be blinded for good. The cobra's ability takes the effect's tag, blindness, and isn't manual. - A monster whose paralysis ability has the param attacks_others, the ghoul and the thoul, passes over a member it has paralysed while another target is in reach. - No monster picks a member turned to stone while another target is in reach. A paralysed member comes before a statue. When every member in reach is stone, the monster attacks a statue, as before. - Every member of a party a basilisk surprises meets its gaze once as the encounter opens, whatever the stance, and a later battle doesn't repeat it. The guard test_the_cobras_spit_applies_no_save_in_battle is removed from tests/test_attack_routines.py, because the spit now blinds. APPLIED_KINDS in tests/test_monster_attack_effects.py counts blindness. No golden file changes. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- docs/adaptations.md | 17 +- src/osrlib/core/effects.py | 3 +- src/osrlib/core/monsters.py | 10 +- src/osrlib/crawl/battle.py | 19 +- src/osrlib/crawl/commands.py | 6 +- src/osrlib/crawl/encounter.py | 9 +- tests/test_attack_routines.py | 16 - tests/test_avert_eyes.py | 11 +- tests/test_monster_attack_effects.py | 7 +- tests/test_monster_tactics.py | 751 +++++++++++++++++++++++++++ 10 files changed, 805 insertions(+), 44 deletions(-) create mode 100644 tests/test_monster_tactics.py diff --git a/docs/adaptations.md b/docs/adaptations.md index 6e4b671..407e1e0 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -396,19 +396,25 @@ An HD-budget spell can aim at a group too. A `cast` in an `hd_budget` mode with The policy draws only from the monster-action stream. Monsters with a scripted pattern in their data follow it: an `uses_per_day` breath weapon opens with breath, then breath or melee with equal chance while daily uses remain (the dragons, RAW), and the engine rolls a `per_round_chance_in_six` gate each round (the hellhound). Otherwise a group beyond 5' closes and at 5' melees. A monster that melees picks its target, then one of its attack routines, with equal chance among the routines whose `use` is `primary` or `alternative`: the thoul claws or swings a weapon, the mule kicks or bites, and the spitting cobra spits or bites. A monster with one such routine draws nothing for the pick. The policy never picks a `situational` routine, which the SRD ties to a situation osrlib doesn't model: a range, a charge or a trample chance, water, a ship, a form, or a loadout. A game's own policy names a situational routine in `MonsterAction.routine` when the game decides the situation applies, and battle rolls it as melee. So the default policy never rolls the 3-in-4 trample chance of the elephant and its kin or the cave locust's 50% jump (pinned). A `special` routine, which is a breath, a gaze, or a spell, rolls nothing even when a policy names it, and battle never rolls an entry named `gaze` or `breath` as an attack. A confused monster that attacks uses its first routine (pinned). Monster missile attacks lack structured range data and stay close-then-melee, so the spitting cobra spits from 5' (pinned). A thrown missile in a later routine, like a giant's boulder, is a situational routine. Groups never cast (casting tags are `manual` data). Morale is auto-invoked at the start of each monster block through a per-battle tracker. The triggers are first death and half incapacitated. A monster's alternate morale score applies when the round meets its condition (fear of fire when the round's damage included fire), and the spell morale modifier applies per the cumulative-effects rule. ML 2 groups rout when battle starts, or as they join a battle already running (none exist in the compiled data, so both the battle-start branch and the join branch are test-forced). A failed check means retreat exposure that round, then flight at full speed, leaving the battle past 120' (the pursuit sight figure, symmetric) as routed. A fled group stays in the player view's `groups`, in order, with `fled` set. The session refuses every declaration aimed at it with `battle.declaration.unknown_group`: an attack, a `close` move, a thrown item, a device, or a spell. The party can still declare against a group that is running but still inside 120', because it can chase the group or shoot at it. A cast aimed at a group that flees earlier in the same round fizzles at the magic phase's re-check (see [The battle round](#the-battle-round)). Chasing routed monsters is left to the game (an evasion procedure with roles swapped, deferred). A group whose living members are all turned or afraid retreats as a whole, and individually turned members of a mixed group don't act (pinned). Locked by `test_battle.py::TestDefaultPolicy` and `TestMoraleAndEnds`, for the routine pick by `test_attack_routines.py`, for a group that joins by `test_spawn_into_encounter.py::TestAGroupThatWillNotFight`, and for a fled group by `test_battle_player_view.py::TestAFledGroup`. -### Battle applies five kinds of monster attack effect and the petrifying gaze, and leaves the rest to the referee +### Monsters pass over statues, and ghouls and thouls turn from the members they paralyse -When a monster's attack hits in battle, the session applies each effect the attack lists if the effect is `paralysis`, `poison`, `disease`, `energy_drain`, or `petrification` and the monster's ability for it isn't `manual`. The effects apply once for each hit, so a target hit by two paralysing claws saves twice. The target saves versus paralysis against paralysis and petrification, and versus death, with the ability's modifier, against poison. A failed save against poison kills at once, or when the poison's onset runs out. Mummy rot and energy drain allow no save. An elf, whose class has `ghoul_paralysis_immunity`, rolls no save against the ghoul's or the thoul's paralysis and isn't paralysed. osrlib has no model of size, so the same paralysis affects a creature larger than an ogre, which the SRD exempts. A dead or petrified target takes none of these effects. +A monster that melees picks its target with equal chance among the members of the party's front rank it can reach, but it passes over some of them while another target is in reach. A member turned to stone is a statue and out of the fight, so no monster picks one while a target that isn't stone is in reach. The SRD doesn't say this, so it's a judgement (pinned). The ghoul's entry says "After paralysing a target, ghouls will attack others", and the thoul's says the same of thouls. Their `paralysis` abilities have the param `attacks_others`, and a monster whose `paralysis` ability has it also passes over a paralysed member while a target that is neither paralysed nor stone is in reach. The policy reads the param, not the monster's id. The carcass crawler's and the gelatinous cube's entries have no such clause, so they keep attacking the members they paralyse. A paralysed member comes before a statue, for a ghoul too. + +When every member in reach is stone, the monster attacks a statue, as it did before this rule (pinned). It doesn't hold, because a party that is all stone declares nothing and could never end the battle. It doesn't reach past the statues to the ranks behind, because melee reaches the front rank alone, in both directions. A dead member leaves the ranks at once, so a front rank is never made of the dead. The policy picks a target once a round, so a ghoul whose claw paralyses its target spends the rest of that round's attacks on the same target (pinned). A monster with no paralysed or petrified member among its candidates draws exactly as before, and so does one whose candidates are all stone. Locked by `test_monster_tactics.py`. + +### Battle applies six kinds of monster attack effect and the petrifying gaze, and leaves the rest to the referee + +When a monster's attack hits in battle, the session applies each effect the attack lists if the effect is `paralysis`, `poison`, `disease`, `energy_drain`, `petrification`, or `blindness` and the monster's ability for it isn't `manual`. The effects apply once for each hit, so a target hit by two paralysing claws saves twice. The target saves versus paralysis against paralysis and petrification, and versus death, with the ability's modifier, against poison. A failed save against poison kills at once, or when the poison's onset runs out. The spitting cobra's entry says "Blinding spit: Range: 6'. A hit causes permanent blindness (save vs poison)." A spit hit makes the target save versus death, the category for poison, or be blinded for good. The effect is permanent and isn't dispellable, and no spell osrlib runs ends it, so a cure is the referee's call. The SRD's combat rules say "A blind character is unable to attack", so the session refuses an attack from a blind member with `combat.attack.attacker_blind`. Mummy rot and energy drain allow no save. An elf, whose class has `ghoul_paralysis_immunity`, rolls no save against the ghoul's or the thoul's paralysis and isn't paralysed. osrlib has no model of size, so the same paralysis affects a creature larger than an ogre, which the SRD exempts. A dead or petrified target takes none of these effects. The session runs the basilisk's and the medusa's petrifying gaze through `resolve_gaze` once a round while the gazer's group is at 5', before the group acts. The gaze reaches the party's front rank and takes no attack roll. The session never rolls the basilisk's `gaze` attack entry as an attack. The SRD's basilisk says "Unless averting eyes or using a mirror, characters in melee are affected each round", so a member's `BattleDeclaration` takes a `gaze_defense` of `avert_eyes` or `mirror` for one round, with any action. A member who averts their eyes has the `averted_eyes` condition from the moment the round is accepted until it ends, and `resolve_gaze` spares them. The basilisk's and the medusa's entries give the cost: "-4 penalty to-hit; the basilisk gains a +2 bonus to attack". The −4 applies to every attack roll the member makes that round, whatever it's aimed at, because the SRD gives it without naming a target. The +2 applies only to the attacks a gazer makes on the member, because the SRD names the gazer for it (pinned). A member who watches a mirror is spared the gaze too, and every attack roll they make that round takes the −1 the basilisk's entry gives for "Fighting by looking in a mirror". No monster gains a bonus against them. The SRD's equipment list has the mirror, "Useful for looking around corners or for reflecting a gaze attack", so the member has to carry one, and the session refuses a `mirror` stance from a member who doesn't with `battle.declaration.no_mirror`. Neither stance draws anything or emits an event of its own, so a round without one draws exactly as before. A back-rank member is out of the gaze's reach and needs no stance, and a member who can't act, and so doesn't declare, is already spared by `resolve_gaze`. -The basilisk's entry also says "Characters surprised by a basilisk meet its gaze". In the monsters' free round after they surprise the party, each basilisk gazes once at every living member of the party, back rank included, at whatever distance the encounter opened, before its group acts. That gaze takes the place of the front-rank gaze, so a member at 5' meets it once that round. The party declares nothing in that round, so no stance can spare it. The medusa's entry has no such clause, so a medusa that surprises the party gazes in its free round only if it's already at 5', and then only at the front rank (pinned). +The basilisk's entry also says "Characters surprised by a basilisk meet its gaze". When a basilisk surprises the party, every living member of the party, back rank included, meets its gaze once as the encounter opens, at whatever distance the encounter opened. The gaze comes whatever the stance, so it comes in an encounter that never becomes a battle. It comes when the basilisk is surprised too, because the clause is about the characters and a gaze takes no action (pinned). A battle that opens from the encounter brings no second surprise gaze. When the battle opens at once with the monsters' free round, the basilisk doesn't gaze again in that round, so a member at 5' meets its gaze once. A battle that opens later gazes at the front rank in each round the basilisk is in melee, as any battle does. The party declares nothing before the encounter opens, so no stance can spare the surprise gaze. The medusa's entry has no such clause, so a medusa that surprises the party gazes in its free round only if it's already at 5', and then only at the front rank (pinned). -The SRD's rule that a basilisk or a medusa that sees its own reflection saves or turns to stone is left to the referee. The basilisk's entry gives that a 2-in-6 chance and the medusa's gives no chance at all, so both monsters' `mirrors` abilities stay `manual`. The basilisk's `surprise` ability and both monsters' `averting_eyes` abilities aren't `manual`, because the session applies them. The medusa's disguise is left to the referee too. Locked by `test_avert_eyes.py`. +The SRD's rule that a basilisk or a medusa that sees its own reflection saves or turns to stone is left to the referee. The basilisk's entry gives that a 2-in-6 chance and the medusa's gives no chance at all, so both monsters' `mirrors` abilities stay `manual`. The basilisk's `surprise` ability and both monsters' `averting_eyes` abilities aren't `manual`, because the session applies them. The medusa's disguise is left to the referee too. Locked by `test_avert_eyes.py`, and the surprise gaze outside a battle by `test_monster_tactics.py`. -An effect in a later attack routine applies when battle resolves that routine. The spitting cobra's poison bite is an alternative routine, so under the default policy the cobra bites in about half the rounds it fights. The vampire's charming gaze is a special routine, which battle never rolls, so the referee applies it. +An effect in a later attack routine applies when battle resolves that routine. The spitting cobra's poison bite is an alternative routine, so under the default policy the cobra bites in about half the rounds it fights and spits in the rest. The vampire's charming gaze is a special routine, which battle never rolls, so the referee applies it. The session doesn't apply these attack effects and gazes, so the referee applies them from the monster's description: @@ -416,7 +422,6 @@ The session doesn't apply these attack effects and gazes, so the referee applies - `1d10_hull_damage`: lesser sea serpent, giant squid. - `2_hull_damage`: giant squid. - `6d6_hull_damage`: sperm whale. -- `blindness`: spitting cobra. - `blistering`: oil beetle. - `blood_drain`: giant leech. - `blood_sucking`: stirge, giant weasel. diff --git a/src/osrlib/core/effects.py b/src/osrlib/core/effects.py index e3e7ba7..f24313a 100644 --- a/src/osrlib/core/effects.py +++ b/src/osrlib/core/effects.py @@ -197,7 +197,8 @@ class Condition(StrEnum): BLIND = "blind" """Unable to see. [`validate_attack`][osrlib.core.combat.validate_attack] rejects the creature's attacks with - `combat.attack.attacker_blind`.""" + `combat.attack.attacker_blind`. A battle in a [`GameSession`][osrlib.crawl.session.GameSession] gives it for good + to a creature a spitting cobra's spit hits, when the creature fails its save versus death.""" CHARMED = "charmed" """Under a charm. The monsters that can't be charmed, the undead and the golems among them, have it in diff --git a/src/osrlib/core/monsters.py b/src/osrlib/core/monsters.py index 4e47037..2a0ce95 100644 --- a/src/osrlib/core/monsters.py +++ b/src/osrlib/core/monsters.py @@ -693,10 +693,12 @@ class MonsterAbility(BaseModel): """The ability's identifier, a lowercase name like `"regeneration"`. The combat functions read four tags: `"regeneration"`, `"energy_drain"`, `"breath_weapon"`, and - `"uses_fire"`. Battle also reads `"paralysis"`, `"poison"`, `"disease"`, and `"petrification"` - when a monster's attack hits. It reads `"averting_eyes"` for the bonus a gazer gains against a - creature that averts its eyes, and a `"surprise"` ability whose `params` have `meets_gaze` for a - gaze, like the basilisk's, that reaches every character the monster surprises. + `"uses_fire"`. Battle also reads `"paralysis"`, `"poison"`, `"disease"`, `"petrification"`, and + `"blindness"` when a monster's attack hits. It reads `"averting_eyes"` for the bonus a gazer gains + against a creature that averts its eyes, and a `"surprise"` ability whose `params` have + `meets_gaze` for a gaze, like the basilisk's, that reaches every character the monster surprises. + The default action policy reads `attacks_others` in a `"paralysis"` ability's `params`, which the + ghoul's and the thoul's have, and turns the monster from the members it has paralysed. """ name: str = Field(min_length=1) diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 9868814..9dd2f32 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -72,7 +72,8 @@ between initiative and their turn to act. - Each monster group checks morale when the rules call for it. - When a monster hits, the session applies the effects its attack lists, each with the save the - SRD gives: paralysis, poison, mummy rot, energy drain, and petrification. The + SRD gives: paralysis, poison, mummy rot, energy drain, petrification, and the spitting cobra's + blindness. The [adaptations register](https://mmacy.github.io/osrlib-python/adaptations/) lists the effects the session leaves to the referee. - A petrifying gaze, like the basilisk's or the medusa's, reaches each member of the party's @@ -80,9 +81,9 @@ [`resolve_gaze`][osrlib.core.combat.resolve_gaze] skips a member who is averting their eyes. A member averts them, or watches a mirror instead, through [`BattleDeclaration.gaze_defense`][osrlib.crawl.commands.BattleDeclaration.gaze_defense], and - the session applies that stance's penalties to the round's attack rolls. In the monsters' - free round after they surprise the party, a basilisk's gaze reaches every member of the - party, at any distance. + the session applies that stance's penalties to the round's attack rolls. Every member of a + party a basilisk surprises meets its gaze once, at any distance, as the encounter opens, so + a battle that opens from that encounter doesn't repeat it. - Turned undead attack no one. When a cleric turns only some of a group, the turned members break away as a new group, which runs, and the rest fight on. - A member's invisibility ends when the member attacks, throws or uses an item other than a @@ -558,6 +559,16 @@ class ScriptedPolicy: picks a target at random from the party's front rank, leaving out invisible members and members whose *protection from evil* bars that monster. When no target is left, the monster holds. + Among the targets left, the monster passes over a member turned to stone while a target that + isn't stone is in reach, because a statue is out of the fight. A monster whose `paralysis` + ability has the param `attacks_others`, like the ghoul and the thoul, also passes over a + paralysed member while a target that is neither paralysed nor stone is in reach, as the OSE + SRD's "After paralysing a target, ghouls will attack others" says. A paralysed member comes + before a statue. When every target in reach is stone, the monster attacks a statue. The policy + picks a target once a round, so a paralysing hit doesn't move the rest of that round's attacks + to another target. A monster with no paralysed or petrified member among its targets draws + exactly as it would without these rules. + The monster then picks which of its attack routines to use, with equal chance among the routines whose [`use`][osrlib.core.monsters.AttackRoutine.use] is `PRIMARY` or `ALTERNATIVE`, and names it in [`MonsterAction.routine`][osrlib.crawl.battle.MonsterAction.routine]. A thoul claws or diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index d915060..6bf0ee8 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2592,9 +2592,9 @@ class BattleDeclaration(BaseModel): round until the round ends. It draws nothing and emits no event of its own. The command log keeps it with the declaration. A member in the back rank, or one who can't act and so doesn't declare, is out of the gaze's reach or already spared by it, so neither needs a - stance. A party the monsters surprise declares nothing in the monsters' free round, and in - that round a basilisk's gaze reaches every member of the party, as the SRD's "Characters - surprised by a basilisk meet its gaze" says. + stance. A party a basilisk surprises meets its gaze as the encounter opens, every member once, + before the party can declare anything, as the SRD's "Characters surprised by a basilisk meet + its gaze" says. No stance spares that gaze. [The adaptations register](https://mmacy.github.io/osrlib-python/adaptations/) describes the gaze in full.""" diff --git a/src/osrlib/crawl/encounter.py b/src/osrlib/crawl/encounter.py index f6006af..4cedeb4 100644 --- a/src/osrlib/crawl/encounter.py +++ b/src/osrlib/crawl/encounter.py @@ -364,6 +364,11 @@ def start_encounter( leaves open. The [adaptations register](https://mmacy.github.io/osrlib-python/adaptations/) lists it as the blind-party adaptation. + When the party is surprised and a monster's `surprise` ability has `meets_gaze`, as the + basilisk's does, every living member meets that monster's petrifying gaze once, here, at any + distance and whatever the stance. Each saves versus paralysis or turns to stone. That holds when + the monsters are surprised too. A battle that opens from this encounter doesn't repeat the gaze. + A monster whose template has the `regeneration` ability, like a troll or a thoul, starts to regenerate when this function opens its encounter. Before the function rolls anything, it attaches the effect that [`regeneration_definition`][osrlib.core.effects.regeneration_definition] @@ -405,8 +410,8 @@ def start_encounter( [`EncounterStartedEvent`][osrlib.crawl.events.EncounterStartedEvent], the [`ReactionRolledEvent`][osrlib.core.events.ReactionRolledEvent] unless you passed `pinned_stance`, and the [`StanceChangedEvent`][osrlib.crawl.events.StanceChangedEvent]. - Then come the events of anything the stance or surprise starts, which can include a - battle and its first round. + The saving throws of a basilisk's surprise gaze come among them. Then come the events of + anything the stance or surprise starts, which can include a battle and its first round. Examples: ```python diff --git a/tests/test_attack_routines.py b/tests/test_attack_routines.py index 534c9fd..eed71fe 100644 --- a/tests/test_attack_routines.py +++ b/tests/test_attack_routines.py @@ -623,22 +623,6 @@ def test_a_passed_save_leaves_the_bite_damage(self): assert damage and all(event.amount >= 1 for event in damage) assert not has_condition(fighter, Condition.POISONED) - def test_the_cobras_spit_applies_no_save_in_battle(self): - """Blindness stays with the referee, as the register says, whichever routine policy is in force.""" - game, cobra = _battle("spitting_cobra") - fighter = game.party.members[0] - _with_policy(game, None) - - hit = False - for _ in range(20): - events = _round(game) - rolled = _rolls(events, cobra.id) - assert rolled and {event.attack_name for event in rolled} == {"spit"} - hit = hit or bool(_hits(events, cobra.id, "spit")) - assert not _saves(events, fighter.id) - assert hit, "precondition: a spit hit" - assert not has_condition(fighter, Condition.BLIND) - # ---------------------------------------------------------------------- a game's own policy diff --git a/tests/test_avert_eyes.py b/tests/test_avert_eyes.py index 0fe88bd..d99b2b1 100644 --- a/tests/test_avert_eyes.py +++ b/tests/test_avert_eyes.py @@ -20,10 +20,11 @@ their attack rolls that round take −1, and no monster gains a bonus. A member who carries no mirror is refused with `battle.declaration.no_mirror`. A gazer that sees its own reflection is left to the referee. -**Surprise.** "Characters surprised by a basilisk meet its gaze." In the monsters' free round after they surprise -the party, each basilisk gazes once at every living member, back rank included, at whatever distance the encounter -opened. That gaze takes the place of the front-rank gaze, so no member meets the gaze twice in the round. The -medusa's entry has no such clause, so a medusa's free round is unchanged. +**Surprise.** "Characters surprised by a basilisk meet its gaze." When a basilisk surprises the party and the +battle opens at once, every living member, back rank included, meets its gaze once in the opening events, at +whatever distance the encounter opened. No member meets the gaze twice in the monsters' free round. The medusa's +entry has no such clause, so a medusa's free round is unchanged. `test_monster_tactics.py` pins the surprise gaze +in an encounter that doesn't open a battle. Saving throws are forced where a test needs a particular outcome: a `save_bonus` of +20 makes every save pass and one of −20 makes every save fail. The penalty tests compare two sessions on the same seed and setup, one with the @@ -499,7 +500,7 @@ def _surprised( class TestTheSurpriseGaze: - """Characters a basilisk surprises meet its gaze in its free round, at any distance and in any rank.""" + """Characters a basilisk surprises meet its gaze once as a battle opens, at any distance and in any rank.""" @pytest.mark.parametrize("distance_feet", [60, 5]) def test_a_basilisk_that_surprises_the_party_gazes_once_at_every_member(self, distance_feet): diff --git a/tests/test_monster_attack_effects.py b/tests/test_monster_attack_effects.py index 1c415ee..04ec765 100644 --- a/tests/test_monster_attack_effects.py +++ b/tests/test_monster_attack_effects.py @@ -54,9 +54,10 @@ ALWAYS_FAIL = -20 """A save penalty no d20 can pass with: a roll of 20 minus 20 is below every saving throw value.""" -APPLIED_KINDS = frozenset({"paralysis", "poison", "disease", "energy_drain", "petrification"}) -"""The effect kinds this file shows battle applying. A monster's attack effect of one of these -kinds counts as applied when the monster's ability with that tag isn't `manual`.""" +APPLIED_KINDS = frozenset({"paralysis", "poison", "disease", "energy_drain", "petrification", "blindness"}) +"""The effect kinds this file shows battle applying, and `blindness`, which `test_monster_tactics.py` +shows. A monster's attack effect of one of these kinds counts as applied when the monster's ability +with that tag isn't `manual`.""" # ---------------------------------------------------------------------- setup diff --git a/tests/test_monster_tactics.py b/tests/test_monster_tactics.py new file mode 100644 index 0000000..230270b --- /dev/null +++ b/tests/test_monster_tactics.py @@ -0,0 +1,751 @@ +"""Acceptance tests for four monster behaviours a playtest found wrong: a cobra's spit, ghouls, statues, and surprise. + +**The spitting cobra's spit.** The OSE SRD's snake page says "Blinding spit: Range: 6'. A hit causes permanent +blindness (save vs poison)." The spit's attack lists the `blindness` effect, and the cobra's ability for it was +`manual`, so a spit hit did nothing. The cobra's ability is now tagged `blindness` and isn't `manual`, and battle +applies it as #180 applies the other attack effects: a spit hit makes the target save versus death, the category for +poison, or be blinded for good. The SRD's combat rules say "A blind character is unable to attack." + +**Ghouls and thouls attack others.** The ghoul's and the thoul's entries say "After paralysing a target, ghouls will +attack others", and the same of thouls. Their `paralysis` ability carries the param `attacks_others`, and the default +policy, `ScriptedPolicy`, reads that param, not the monster's id. A monster with it picks a target that isn't +paralysed while one is in reach, and picks a paralysed member only when no other target is left. The policy picks a +target once a round, so the attacks left in the round's routine after a paralysing hit stay on that target. + +**Statues.** A member turned to stone is out of the fight. This is a judgement, not SRD text, and the adaptations +register records it. Under `ScriptedPolicy`, no monster picks a petrified member while another target is in reach. A +paralysed member comes before a statue, even for a ghoul. When every member in reach is stone, the monster attacks a +statue, exactly as it did before. It doesn't hold, because a party that is all stone declares nothing and could never +end the battle. It doesn't reach past the statues, because melee reaches only the front rank, in both directions. + +**The surprise gaze.** The basilisk's entry says "Characters surprised by a basilisk meet its gaze." #196 ran that +gaze only in the monsters' free round, which comes only when the encounter goes straight to battle. Now every living +member of a party a basilisk surprises meets its gaze once, as the encounter opens, whatever the stance and whatever +the encounter does next. That holds when the basilisk is surprised too, because the clause is about the characters +and the gaze takes no action. A battle that opens later, in a free round or not, brings no second surprise gaze. + +**Determinism.** A monster whose candidates include no paralysed or petrified member draws exactly as before, and so +does one whose candidates are all statues, so no golden moves. Saving throws are forced where a test needs a +particular outcome: a `save_bonus` of +20 makes every save pass and one of -20 makes every save fail. Each test +asserts its own preconditions, so a test that stops exercising its case fails loudly. +""" + +import pytest + +from osrlib.core.abilities import AbilityScore +from osrlib.core.character import Character, party_to_document +from osrlib.core.combat import incapacitated +from osrlib.core.effects import Condition, EffectDefinition, ModifierSpec, has_condition +from osrlib.core.events import AttackRolledEvent, SavingThrowRolledEvent +from osrlib.core.rng import RngStream +from osrlib.core.ruleset import Ruleset +from osrlib.core.tables import ReactionResult +from osrlib.crawl import encounter as encounter_module +from osrlib.crawl.adventure import Adventure, TownSpec +from osrlib.crawl.battle import MonsterAction, ScriptedPolicy +from osrlib.crawl.commands import ( + BattleDeclaration, + EngageBattle, + EnterDungeon, + ResolveBattleRound, + SessionMode, + SpawnMonsters, +) +from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec +from osrlib.crawl.party import Party +from osrlib.crawl.session import MONSTER_ACTION_STREAM, GameSession +from osrlib.data import load_classes, load_monsters +from osrlib.persistence import replay_game, session_state + +CHUNK = pytest.mark.xfail(reason="chunk: monster-tactics", strict=True) + +HIT_CODES = frozenset({"combat.attack.hit", "combat.attack.auto_hit"}) + +ALWAYS_PASS = 20 +"""A save bonus no d20 can fail with.""" + +ALWAYS_FAIL = -20 +"""A save penalty no d20 can pass with.""" + +SEEDS = range(1, 80) +"""Seeds the helpers scan for a setup they need, like an encounter neither side is surprised in.""" + +POLICY_CALLS = 40 +"""Policy calls in a run: with two candidates, one of them goes unpicked by chance once in about 10^12 runs.""" + + +# ---------------------------------------------------------------------- setup + + +def _character(name: str, class_id: str = "fighter", *, level: int = 3, hp: int = 400) -> Character: + """A character with average scores and enough hit points to outlast a long fight.""" + 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: 10 for ability in AbilityScore}, + alignment="lawful", + max_hp=hp, + current_hp=hp, + ) + + +def _members(*names: str) -> list[Character]: + return [_character(name) for name in names] + + +def _adventure() -> Adventure: + """One level: a 10' corridor two cells long, so the party's front rank is two abreast.""" + level = LevelSpec(number=1, width=2, height=1, entrance=(0, 0), edges={"1,0:west": Edge(kind=EdgeKind.OPEN)}) + crypt = DungeonSpec(id="crypt", name="Crypt", levels=(level,)) + return Adventure(name="A", town=TownSpec(name="Threshold", travel_turns={"crypt": 1}), dungeons=(crypt,)) + + +def _session(seed: int, members: list[Character]) -> GameSession: + game = GameSession.new(Party(members=members), _adventure(), seed=seed) + assert game.execute(EnterDungeon(dungeon_id="crypt")).accepted + return game + + +def _attach(game: GameSession, member: Character, definition: EffectDefinition) -> None: + effect, _ = game.ledger.attach( + definition, member.id, clock=game.clock, allocator=game.allocator, registry=game.registry() + ) + assert effect is not None + + +def _force_saves(game: GameSession, member: Character, bonus: int) -> None: + """Make every save the member rolls pass (+20) or fail (-20).""" + _attach( + game, member, EffectDefinition(kind="test_save_ward", modifiers=(ModifierSpec(kind="save_bonus", value=bonus),)) + ) + + +def _paralyse(game: GameSession, member: Character) -> None: + _attach(game, member, EffectDefinition(kind="test_paralysis", condition=Condition.PARALYSED, permanent=True)) + assert has_condition(member, Condition.PARALYSED) + + +def _petrify(game: GameSession, member: Character) -> None: + _attach(game, member, EffectDefinition(kind="test_petrification", condition=Condition.PETRIFIED, permanent=True)) + assert has_condition(member, Condition.PETRIFIED) + + +def _make_invisible(game: GameSession, member: Character) -> None: + _attach(game, member, EffectDefinition(kind="test_invisibility", condition=Condition.INVISIBLE, permanent=True)) + assert has_condition(member, Condition.INVISIBLE) + + +def _battle(template_id: str, names: tuple[str, ...] = ("Hild", "Brakk"), *, distance_feet: int = 5) -> GameSession: + """A battle against one monster that neither side was surprised in, built by command, before its first round.""" + for seed in SEEDS: + game = _session(seed, _members(*names)) + result = game.execute(SpawnMonsters(template_id=template_id, count_fixed=1, distance_feet=distance_feet)) + assert result.accepted + surprise = [event for event in result.events if event.code == "encounter.surprise.rolled"] + if not surprise or any(event.roll is not None and event.roll <= event.threshold for event in surprise): + continue + if game.mode is SessionMode.ENCOUNTER: + assert game.execute(EngageBattle()).accepted + if game.mode is not SessionMode.BATTLE or game.battle.round != 0: + continue + assert game.encounter.groups[0].distance_feet == distance_feet + assert game.monsters[_monster_id(game)].template.id == template_id + return game + raise AssertionError(f"precondition: no seed opened a {template_id} battle with neither side surprised") + + +def _monster_id(game: GameSession) -> str: + return game.encounter.groups[0].monster_ids[0] + + +def _group_id(game: GameSession) -> str: + return game.encounter.groups[0].id + + +def _front_rank_ids(game: GameSession) -> list[str]: + return [member.id for member in game.party.ranks(2)[0]] + + +def _choose(game: GameSession) -> list[MonsterAction]: + """Call the default policy for the battle's only group, as the session does.""" + return ScriptedPolicy().choose(game, game.encounter.groups[0], game.streams.get(MONSTER_ACTION_STREAM)) + + +def _targets(game: GameSession, calls: int = POLICY_CALLS) -> list[str]: + """The target of each melee action the default policy chooses over `calls` calls.""" + targets = [] + for _ in range(calls): + actions = _choose(game) + assert [action.kind for action in actions] == ["melee"], "precondition: the monster attacks in melee" + targets.append(actions[0].target_id) + return targets + + +def _play(game: GameSession, *declared: BattleDeclaration) -> list: + """Resolve one round: the members in `declared` as given, every other able member holding.""" + assert game.mode is SessionMode.BATTLE, "precondition: the battle is still running" + by_id = {declaration.character_id: declaration for declaration in declared} + able = [member for member in game.party.living_members() if not incapacitated(member)] + declarations = tuple( + by_id.get(member.id, BattleDeclaration(character_id=member.id, action="hold")) for member in able + ) + result = game.execute(ResolveBattleRound(declarations=declarations)) + assert result.accepted, [rejection.code for rejection in result.rejections] + return list(result.events) + + +def _rolls(events, attacker_id: str) -> list[AttackRolledEvent]: + return [event for event in events if isinstance(event, AttackRolledEvent) and event.attacker_id == attacker_id] + + +def _saves(events, target_id: str, category: str | None = None) -> list[SavingThrowRolledEvent]: + return [ + event + for event in events + if isinstance(event, SavingThrowRolledEvent) + and event.target_id == target_id + and (category is None or event.category == category) + ] + + +def _state_without_logs(game: GameSession) -> dict: + """Every state block, the clock and the random number streams among them, without the two logs.""" + state = session_state(game) + state.pop("command_log") + state.pop("event_log") + return state + + +def _with_paralysis_param(monster, attacks_others: bool | None) -> None: + """Give a spawned monster a copy of its template whose `paralysis` ability has `attacks_others` set or removed.""" + template = monster.template + abilities = [] + for ability in template.abilities: + if ability.tag == "paralysis": + params = {key: value for key, value in ability.params.items() if key != "attacks_others"} + if attacks_others is not None: + params["attacks_others"] = attacks_others + ability = ability.model_copy(update={"params": params}) + abilities.append(ability) + assert any(ability.tag == "paralysis" for ability in abilities), "precondition: the monster paralyses" + monster.template = template.model_copy(update={"abilities": tuple(abilities)}) + + +# ---------------------------------------------------------------------- the spitting cobra + + +class _SpitEveryRound: + """The default policy with the cobra's first routine, the spit, named every round.""" + + def choose(self, session, group, stream): + actions = ScriptedPolicy().choose(session, group, stream) + return [action.model_copy(update={"routine": 0}) if action.kind == "melee" else action for action in actions] + + +def _cobra_battle(bonus: int | None) -> tuple[GameSession, Character, str]: + """A one-member battle against a spitting cobra that spits every round.""" + game = _battle("spitting_cobra", ("Hild",)) + hild = game.party.members[0] + if bonus is not None: + _force_saves(game, hild, bonus) + game.action_policies[_group_id(game)] = _SpitEveryRound() + return game, hild, _monster_id(game) + + +def _until_spit_hits(game: GameSession, cobra_id: str, *, max_rounds: int = 30) -> list: + for _ in range(max_rounds): + events = _play(game) + rolled = _rolls(events, cobra_id) + assert [event.attack_name for event in rolled] == ["spit"], "precondition: the cobra spat this round" + if rolled[0].code in HIT_CODES: + return events + pytest.fail(f"precondition: the cobra's spit hit within {max_rounds} rounds") + + +class TestTheSpittingCobrasSpit: + """A spit hit makes the target save versus poison or be blinded for good.""" + + @CHUNK + def test_the_catalog_gives_the_spit_an_ability_battle_applies(self): + cobra = load_monsters().get("spitting_cobra") + spit = next(attack for routine in cobra.attacks for attack in routine.attacks if attack.name == "spit") + assert list(spit.effects) == ["blindness"], "precondition: the spit's attack lists the effect" + + blindness = cobra.ability("blindness") + assert blindness is not None, "the spit's effect has an ability of the same tag, as #180's effects do" + assert not blindness.manual + assert "A hit causes permanent blindness (save vs poison)." in blindness.prose + assert cobra.ability("blinding_spit") is None, "one ability holds the spit, under the effect's tag" + + @CHUNK + def test_a_failed_save_blinds_for_good(self): + game, hild, cobra = _cobra_battle(ALWAYS_FAIL) + + events = _until_spit_hits(game, cobra) + + saves = _saves(events, hild.id) + assert [(save.category, save.code) for save in saves] == [("death", "combat.save.failed")], ( + "save versus poison, which is the death category" + ) + assert has_condition(hild, Condition.BLIND) + blinding = [ + effect for effect in game.ledger.active_on(hild.id) if effect.definition.condition is Condition.BLIND + ] + assert len(blinding) == 1 + assert blinding[0].expires_round is None, "the blindness is permanent" + assert not blinding[0].definition.dispellable, "what a monster inflicts isn't dispellable" + + # The blindness outlasts the battle's rounds. + for _ in range(3): + _play(game) + assert has_condition(hild, Condition.BLIND) + + @CHUNK + def test_a_passed_save_leaves_the_target_seeing(self): + game, hild, cobra = _cobra_battle(ALWAYS_PASS) + + events = _until_spit_hits(game, cobra) + + saves = _saves(events, hild.id) + assert [(save.category, save.code) for save in saves] == [("death", "combat.save.passed")] + assert not has_condition(hild, Condition.BLIND) + + def test_a_spit_that_misses_applies_nothing(self): + """A guard: a miss rolls no save and blinds no one, before and after the chunk.""" + game, hild, cobra = _cobra_battle(ALWAYS_FAIL) + + for _ in range(30): + events = _play(game) + rolled = _rolls(events, cobra) + assert [event.attack_name for event in rolled] == ["spit"], "precondition: the cobra spat this round" + if rolled[0].code not in HIT_CODES: + assert not _saves(events, hild.id) + assert not has_condition(hild, Condition.BLIND) + return + pytest.fail("precondition: the cobra's spit missed within 30 rounds") + + @CHUNK + def test_a_member_the_spit_blinded_cannot_attack(self): + """The SRD's combat rules: "A blind character is unable to attack." The refused round costs nothing.""" + game, hild, cobra = _cobra_battle(ALWAYS_FAIL) + _until_spit_hits(game, cobra) + assert has_condition(hild, Condition.BLIND), "precondition: the spit blinded Hild" + before = _state_without_logs(game) + + result = game.execute( + ResolveBattleRound( + declarations=( + BattleDeclaration(character_id=hild.id, action="attack", target_group_id=_group_id(game)), + ) + ) + ) + + assert not result.accepted + assert "combat.attack.attacker_blind" in [rejection.code for rejection in result.rejections] + assert not result.events + assert _state_without_logs(game) == before + + +# ---------------------------------------------------------------------- ghouls and thouls + +PARALYSERS_THAT_ATTACK_OTHERS = ("ghoul", "thoul") + +OTHER_PARALYSERS = ("carcass_crawler", "gelatinous_cube") + + +class TestGhoulsAttackOthers: + """A ghoul or a thoul passes over a member it has paralysed while another target is in reach.""" + + @CHUNK + def test_the_catalog_marks_the_ghouls_and_the_thouls_paralysis(self): + catalog = load_monsters() + for template_id in PARALYSERS_THAT_ATTACK_OTHERS: + paralysis = catalog.get(template_id).ability("paralysis") + assert paralysis is not None and not paralysis.manual + assert "will attack others" in paralysis.prose, "precondition: the SRD's clause is in the prose" + assert paralysis.params.get("attacks_others") is True, f"{template_id}'s paralysis has attacks_others" + + def test_the_other_paralysers_have_no_such_clause(self): + """A guard: the carcass crawler's and the gelatinous cube's entries don't say they attack others.""" + catalog = load_monsters() + for template_id in OTHER_PARALYSERS: + paralysis = catalog.get(template_id).ability("paralysis") + assert paralysis is not None and not paralysis.manual + assert "attack others" not in paralysis.prose + assert "attacks_others" not in paralysis.params + + @CHUNK + @pytest.mark.parametrize("template_id", PARALYSERS_THAT_ATTACK_OTHERS) + def test_it_picks_a_member_who_isnt_paralysed(self, template_id): + game = _battle(template_id) + hild, brakk = game.party.members + _paralyse(game, hild) + assert _front_rank_ids(game) == [hild.id, brakk.id], "precondition: both stand in the front rank" + + assert set(_targets(game)) == {brakk.id} + + @CHUNK + @pytest.mark.parametrize("template_id", PARALYSERS_THAT_ATTACK_OTHERS) + def test_it_picks_a_paralysed_member_before_a_statue(self, template_id): + game = _battle(template_id) + hild, brakk = game.party.members + _paralyse(game, hild) + _petrify(game, brakk) + + assert set(_targets(game)) == {hild.id} + + @pytest.mark.parametrize("template_id", PARALYSERS_THAT_ATTACK_OTHERS) + def test_it_attacks_a_paralysed_member_when_no_other_target_is_in_reach(self, template_id): + """A guard: an invisible member is out of reach, so the paralysed one is the only target left.""" + game = _battle(template_id) + hild, brakk = game.party.members + _paralyse(game, hild) + _make_invisible(game, brakk) + + assert set(_targets(game)) == {hild.id} + + @pytest.mark.parametrize("template_id", PARALYSERS_THAT_ATTACK_OTHERS) + def test_it_attacks_a_lone_paralysed_member(self, template_id): + """A guard: with no one else in the party, the paralysed member is the target.""" + game = _battle(template_id, ("Hild",)) + hild = game.party.members[0] + _paralyse(game, hild) + + assert set(_targets(game)) == {hild.id} + + @pytest.mark.parametrize("template_id", OTHER_PARALYSERS) + def test_a_paralyser_without_the_clause_still_attacks_a_paralysed_member(self, template_id): + """A guard: only the ghoul's and the thoul's entries say they turn to others.""" + game = _battle(template_id) + hild, brakk = game.party.members + _paralyse(game, hild) + + assert set(_targets(game)) == {hild.id, brakk.id} + + @CHUNK + def test_the_policy_reads_the_param_and_not_the_monster(self): + """A carcass crawler given `attacks_others` turns to others, so the rule is data, not a list of ids.""" + game = _battle("carcass_crawler") + hild, brakk = game.party.members + _with_paralysis_param(game.monsters[_monster_id(game)], True) + _paralyse(game, hild) + + assert set(_targets(game)) == {brakk.id} + + def test_a_ghoul_without_the_param_attacks_a_paralysed_member(self): + """A guard: a ghoul whose paralysis lacks `attacks_others` picks among both members, as before.""" + game = _battle("ghoul") + hild, brakk = game.party.members + _with_paralysis_param(game.monsters[_monster_id(game)], None) + _paralyse(game, hild) + + assert set(_targets(game)) == {hild.id, brakk.id} + + @CHUNK + @pytest.mark.parametrize("template_id", PARALYSERS_THAT_ATTACK_OTHERS) + def test_in_battle_it_turns_from_the_member_it_paralysed(self, template_id): + """The playtest's case: once a member is paralysed, the monster's attacks go to the member still standing.""" + game = _battle(template_id) + hild, brakk = game.party.members + _force_saves(game, hild, ALWAYS_FAIL) + _force_saves(game, brakk, ALWAYS_PASS) + monster_id = _monster_id(game) + + after = [] + for _ in range(30): + paralysed_before_the_round = has_condition(hild, Condition.PARALYSED) + events = _play(game) + if paralysed_before_the_round: + after += _rolls(events, monster_id) + + assert has_condition(hild, Condition.PARALYSED), "precondition: the monster paralysed Hild" + assert not has_condition(brakk, Condition.PARALYSED), "precondition: Brakk is still standing" + assert len(after) >= 5, "precondition: the monster attacked in several rounds after Hild was paralysed" + assert {event.defender_id for event in after} == {brakk.id} + + @pytest.mark.parametrize("template_id", PARALYSERS_THAT_ATTACK_OTHERS) + def test_its_routine_stays_on_one_target_within_a_round(self, template_id): + """A guard: the policy picks a target once a round, so a paralysing hit doesn't move the rest of the routine.""" + game = _battle(template_id) + for member in game.party.members: + _force_saves(game, member, ALWAYS_FAIL) + monster_id = _monster_id(game) + + multi_attack_rounds = 0 + for _ in range(12): + if all(incapacitated(member) for member in game.party.members): + break + rolled = _rolls(_play(game), monster_id) + assert len({event.defender_id for event in rolled}) <= 1 + multi_attack_rounds += len(rolled) > 1 + assert multi_attack_rounds, "precondition: the monster made more than one attack in some round" + + +# ---------------------------------------------------------------------- statues + + +EVERY_KIND = ("goblin", "ogre", "basilisk", "ghoul", "thoul", "carcass_crawler") +"""Monsters of several kinds: plain fighters, a petrifier, and paralysers with and without the clause.""" + + +class TestStatues: + """A member turned to stone is out of the fight, so no monster picks one while another target is in reach.""" + + @CHUNK + @pytest.mark.parametrize("template_id", EVERY_KIND) + def test_no_monster_picks_a_statue_while_another_target_is_in_reach(self, template_id): + game = _battle(template_id) + hild, brakk = game.party.members + _petrify(game, hild) + assert _front_rank_ids(game) == [hild.id, brakk.id], "precondition: the statue still stands in the front rank" + + assert set(_targets(game)) == {brakk.id} + + @CHUNK + def test_a_paralysed_member_comes_before_a_statue_for_any_monster(self): + game = _battle("goblin") + hild, brakk = game.party.members + _petrify(game, hild) + _paralyse(game, brakk) + + assert set(_targets(game)) == {brakk.id} + + @pytest.mark.parametrize("template_id", ["goblin", "basilisk", "ghoul"]) + def test_a_front_rank_of_statues_is_attacked_as_before(self, template_id): + """A guard: with only statues in reach, the monster picks one of them, with the same draw as before. + + It neither holds nor reaches past them to the member behind, who stands out of melee. + """ + game = _battle(template_id, ("Hild", "Brakk", "Sable")) + hild, brakk, sable = game.party.members + _petrify(game, hild) + _petrify(game, brakk) + front = _front_rank_ids(game) + assert front == [hild.id, brakk.id], "precondition: Sable stands behind the statues" + assert not incapacitated(sable) + stream = game.streams.get(MONSTER_ACTION_STREAM) + + for _ in range(POLICY_CALLS): + replica = RngStream.restore(stream.export_state()) + actions = _choose(game) + assert [action.kind for action in actions] == ["melee"] + assert actions[0].target_id == front[replica.randbelow(len(front))] + assert stream.export_state() == replica.export_state() + + @CHUNK + def test_in_battle_a_basilisk_bites_the_member_still_standing(self): + """The playtest's case: a basilisk kept biting members it had already turned to stone.""" + game = _battle("basilisk") + hild, brakk = game.party.members + _petrify(game, hild) + _force_saves(game, brakk, ALWAYS_PASS) + basilisk = _monster_id(game) + + rolled = [] + for _ in range(10): + rolled += _rolls(_play(game), basilisk) + + assert len(rolled) >= 5, "precondition: the basilisk bit in several rounds" + assert not has_condition(brakk, Condition.PETRIFIED), "precondition: Brakk is still flesh" + assert {event.defender_id for event in rolled} == {brakk.id} + + +# ---------------------------------------------------------------------- draws + + +class TestDrawsAsBefore: + """A monster with no paralysed or petrified candidate draws exactly as before, so no golden moves.""" + + @pytest.mark.parametrize(("template_id", "routines"), [("goblin", 1), ("basilisk", 1), ("ghoul", 1), ("thoul", 2)]) + def test_two_members_standing_draw_the_target_then_the_routine(self, template_id, routines): + """A guard: the target draw is `randbelow` over the front rank, then the routine draw when there's a choice.""" + game = _battle(template_id) + front = _front_rank_ids(game) + assert len(front) == 2 + stream = game.streams.get(MONSTER_ACTION_STREAM) + + for _ in range(POLICY_CALLS): + replica = RngStream.restore(stream.export_state()) + actions = _choose(game) + assert [action.kind for action in actions] == ["melee"] + assert actions[0].target_id == front[replica.randbelow(len(front))] + if routines > 1: + replica.randbelow(routines) + assert stream.export_state() == replica.export_state(), "the policy drew differently than before" + + @pytest.mark.parametrize("template_id", ["goblin", "carcass_crawler"]) + def test_a_paralysed_member_changes_nothing_for_a_monster_without_the_clause(self, template_id): + """A guard: only `attacks_others` and stone change the pool, so these draw as before.""" + game = _battle(template_id) + _paralyse(game, game.party.members[0]) + front = _front_rank_ids(game) + stream = game.streams.get(MONSTER_ACTION_STREAM) + + for _ in range(POLICY_CALLS): + replica = RngStream.restore(stream.export_state()) + actions = _choose(game) + assert actions[0].target_id == front[replica.randbelow(len(front))] + assert stream.export_state() == replica.export_state() + + +# ---------------------------------------------------------------------- the surprise gaze + + +def _opening( + template_id: str, + stance: ReactionResult, + *, + distance_feet: int = 60, + battle: bool | None = False, + both_surprised: bool = False, + save_bonus: int = ALWAYS_PASS, + names: tuple[str, ...] = ("Hild", "Brakk", "Sable"), +) -> tuple[GameSession, list]: + """A party surprised by one monster, and the opening events of the encounter. + + `battle` says whether the opening must end in a battle (True), in encounter mode (False), or either (None). + With `both_surprised`, the monster rolls for surprise too and loses. Otherwise it's aware and can't be surprised. + The party's front rank is Hild and Brakk, and Sable stands in the back rank. + """ + for seed in SEEDS: + game = _session(seed, _members(*names)) + for member in game.party.members: + _force_saves(game, member, save_bonus) + instances = game.spawn(template_id, 1) + events = encounter_module.start_encounter( + game, + groups=[(template_id, instances)], + kind="spawned", + distance_feet=distance_feet, + pinned_stance=stance, + monsters_aware=not both_surprised, + ) + started = next(event for event in events if event.code == "encounter.started") + if not started.party_surprised or started.monsters_surprised != both_surprised: + continue + opened_battle = "battle.started" in [event.code for event in events] + if battle is not None and opened_battle != battle: + continue + return game, events + raise AssertionError(f"precondition: no seed let a {template_id} surprise the party as the test needs") + + +ENCOUNTER_STANCES = (ReactionResult.FRIENDLY, ReactionResult.INDIFFERENT, ReactionResult.UNCERTAIN) +"""Stances that leave a surprised party in encounter mode after the round it loses.""" + + +class TestTheSurpriseGaze: + """Every member of a party a basilisk surprises meets its gaze once, as the encounter opens.""" + + @CHUNK + @pytest.mark.parametrize("stance", ENCOUNTER_STANCES) + def test_it_comes_whatever_the_stance(self, stance): + game, events = _opening("basilisk", stance) + + assert game.mode is SessionMode.ENCOUNTER and game.battle is None, "precondition: no battle opened" + for member in game.party.members: + assert len(_saves(events, member.id, "paralysis")) == 1, f"{member.id} meets the basilisk's gaze once" + + @CHUNK + def test_it_reaches_the_back_rank_in_melee_range_too(self): + game, events = _opening("basilisk", ReactionResult.INDIFFERENT, distance_feet=5) + + assert game.mode is SessionMode.ENCOUNTER, "precondition: no battle opened" + for member in game.party.members: + assert len(_saves(events, member.id, "paralysis")) == 1 + + @CHUNK + def test_a_failed_save_turns_a_member_to_stone_outside_battle(self): + game, events = _opening("basilisk", ReactionResult.INDIFFERENT, save_bonus=ALWAYS_FAIL) + + assert game.mode is SessionMode.ENCOUNTER, "precondition: no battle opened" + for member in game.party.members: + assert [save.code for save in _saves(events, member.id)] == ["combat.save.failed"] + assert has_condition(member, Condition.PETRIFIED) + + @CHUNK + def test_engaging_later_brings_no_second_gaze(self): + game, opening = _opening("basilisk", ReactionResult.INDIFFERENT) + assert game.mode is SessionMode.ENCOUNTER, "precondition: no battle opened" + + engage = game.execute(EngageBattle()) + assert engage.accepted and game.mode is SessionMode.BATTLE + first_round = _play(game) + + assert game.encounter.groups[0].distance_feet > 5, "precondition: the basilisk is still out of melee" + for member in game.party.members: + assert len(_saves(opening, member.id)) == 1, "the member meets the gaze as the encounter opens" + assert not _saves(engage.events, member.id) + assert not _saves(first_round, member.id) + + @CHUNK + def test_it_comes_when_the_basilisk_is_surprised_too(self): + """The clause is about the characters, and a gaze is no action, so the basilisk's surprise changes nothing.""" + game, events = _opening("basilisk", ReactionResult.INDIFFERENT, both_surprised=True) + + for member in game.party.members: + assert len(_saves(events, member.id, "paralysis")) == 1 + + @pytest.mark.parametrize("stance", [ReactionResult.ATTACKS, ReactionResult.HOSTILE, ReactionResult.UNCERTAIN]) + def test_a_battle_that_opens_with_the_free_round_has_it_once(self, stance): + """A guard: when the battle opens in the round the party loses, each member still meets the gaze once.""" + game, events = _opening("basilisk", stance, battle=True) + + assert game.battle is not None and game.battle.round == 1, "precondition: the monsters' free round ran" + for member in game.party.members: + assert len(_saves(events, member.id, "paralysis")) == 1 + + def test_a_party_that_isnt_surprised_doesnt_meet_it_at_the_opening(self): + """A guard: the clause needs the party surprised, and the basilisk is out of melee.""" + game = _session(SEEDS.start, _members("Hild", "Brakk")) + instances = game.spawn("basilisk", 1) + events = encounter_module.start_encounter( + game, + groups=[("basilisk", instances)], + kind="spawned", + distance_feet=60, + pinned_stance=ReactionResult.INDIFFERENT, + monsters_aware=True, + party_aware=True, + ) + + started = next(event for event in events if event.code == "encounter.started") + assert not started.party_surprised, "precondition: a party that heard the monsters isn't surprised" + assert not [event for event in events if isinstance(event, SavingThrowRolledEvent)] + + def test_a_medusa_that_surprises_the_party_doesnt_gaze_at_the_opening(self): + """A guard: the medusa's entry has no surprise clause.""" + game, events = _opening("medusa", ReactionResult.INDIFFERENT) + + for member in game.party.members: + assert not _saves(events, member.id) + + @CHUNK + def test_the_playtests_spawn_meets_it_and_replays(self): + """The playtest's case, by command: a referee spawn the basilisk surprises, which stays an encounter.""" + for seed in SEEDS: + members = _members("Hild", "Brakk", "Sable") + party_document = party_to_document(members) + game = _session(seed, members) + result = game.execute(SpawnMonsters(template_id="basilisk", count_fixed=1, distance_feet=60)) + assert result.accepted + started = next(event for event in result.events if event.code == "encounter.started") + if not started.party_surprised or started.monsters_surprised or game.mode is not SessionMode.ENCOUNTER: + continue + for member in game.party.members: + assert len(_saves(result.events, member.id, "paralysis")) == 1, f"{member.id} meets the gaze once" + engage = game.execute(EngageBattle()) + assert engage.accepted + assert not [event for event in engage.events if isinstance(event, SavingThrowRolledEvent)] + + replayed = replay_game(game.master_seed, party_document, game.adventure, Ruleset(), game.command_log) + assert [event.model_dump(mode="json") for event in replayed.event_log] == [ + event.model_dump(mode="json") for event in game.event_log + ] + assert session_state(replayed) == session_state(game) + return + raise AssertionError("precondition: no seed let a spawned basilisk surprise the party without a battle") From 60d7229e484d9d2eea1effd400017da8c3e65d39 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 15:48:05 -0700 Subject: [PATCH 2/7] Blind with the cobra's spit, turn ghouls from the paralysed, spare statues, and gaze on surprise Four playtest findings, made to pass tests/test_monster_tactics.py. - The spitting cobra's spit ability compiles under the tag blindness and isn't manual. Battle applies a blindness hit effect: the target saves versus death, the category for poison, or gains a permanent, undispellable blind condition. A second blinding attaches nothing. - The ghoul's and the thoul's paralysis abilities carry attacks_others. ScriptedPolicy narrows its reachable targets before the draw: it drops statues while anyone else is in reach, and a monster with attacks_others also drops paralysed members while someone neither paralysed nor stone is in reach. An all-stone front rank comes back whole. A list with no paralysed or petrified member is unchanged, so the monster-action draw is the same as before. - start_encounter runs the basilisk's surprise gaze on every living member as the encounter opens, whenever the party is surprised. The monsters' free round skips the gaze of such a gazer when the encounter's party_surprised is set, so no member meets it twice. The existing party_surprised field is the record, so no serialized model changes. Data regenerated with python -m tools.srd_compile. No golden file changed. CHANGELOG adds a Fixed bullet for the cobra, the ghouls, and the statues, rewrites the unreleased surprise-gaze bullet to describe the final behaviour, and extends the 2.0.1 replay list. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 10 +++- src/osrlib/crawl/battle.py | 104 ++++++++++++++++++++++++++-------- src/osrlib/crawl/encounter.py | 11 +++- src/osrlib/data/monsters.json | 6 +- tools/srd_compile/monsters.py | 9 ++- 5 files changed, 106 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01328c8..53d6e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,8 +45,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - A *death spell* cast at a group. - 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, including a basilisk that surprises the party. + - A basilisk's or a medusa's gaze, including a basilisk that surprises the party, whether or not a battle follows. - A melee attack on a petrified creature. + - A spitting cobra's spit that hits. + - A ghoul or a thoul that attacks while one member in its reach is paralysed and another isn't. + - A monster that attacks while one member in its reach is turned to stone and another isn't. - 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. - A wand, staff, rod, or turning that now fizzles. @@ -63,8 +66,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - 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. - When a monster's attack hits in battle, the session applies the effects the attack lists (#167). A ghoul's or a thoul's claw paralyses for 2d4 turns unless the target saves versus paralysis. An elf isn't affected. A carcass crawler's tentacle and a gelatinous cube's touch paralyse the same way, and they affect elves too. A poisonous bite or sting kills unless the target saves versus death. The target dies at once, or when the poison's onset runs out if the monster's poison has one. The session records the death as a poison death, so *neutralize poison* can revive the character. A mummy's touch gives mummy rot, with no save. A wight's, a wraith's, a spectre's, or a vampire's touch drains levels, also with no save. A basilisk's bite and a cockatrice's beak turn the target to stone unless it saves versus petrification. The basilisk's and the medusa's petrifying gaze reaches each character in the party's front rank once a round while the gazer is in melee. The gaze takes no attack roll, and the session no longer rolls the basilisk's gaze as an attack. A character with the `averted_eyes` condition doesn't meet the gaze, and a member who sets the new [`BattleDeclaration.gaze_defense`][osrlib.crawl.commands.BattleDeclaration.gaze_defense] field doesn't meet it either. The adaptations register lists the attack effects that the session leaves to the referee. In a battle against one of these monsters, the session now makes more draws from the combat, effects, and advancement streams. So the same seed and commands give a different battle than they gave under 2.0.1. No golden file changed. -- A basilisk that surprises the party now gazes at every member in its free round, as the OSE SRD's basilisk says: "Characters surprised by a basilisk meet its gaze." The gaze reaches the back rank too, at whatever distance the encounter opened, and each member makes a saving throw versus paralysis, which in B/X also covers petrification, or turns to stone. The gaze takes the place of the round's front-rank gaze, so no member meets it twice. Before, the basilisk gazed in its free round only when it was already in melee, and then only at the front rank. A medusa's free round is unchanged, because her entry has no such rule. In the monster catalog, the basilisk's `surprise` ability is no longer `manual`, and its `params` include `meets_gaze: true`. You don't need to set either. +- Every member of a party a basilisk surprises now meets its gaze as the encounter opens, as the OSE SRD's basilisk says: "Characters surprised by a basilisk meet its gaze." Each living member, back rank included, makes one saving throw versus paralysis, which in B/X also covers petrification, or turns to stone. The gaze comes at whatever distance the encounter opened and whatever the monsters' reaction, so it comes in an encounter that never becomes a battle. It comes when the basilisk is surprised too, because a gaze takes no action. The saving throws come among the opening events of [`start_encounter`][osrlib.crawl.encounter.start_encounter], so the command that opened the encounter reports them, whether the encounter came from a [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters] command, a keyed area, or a wandering-monster check. A battle that opens from the encounter doesn't repeat the gaze. When the basilisk attacks at once, it doesn't gaze again in its free round, so a member in melee meets its gaze once. Before, a basilisk that surprised the party gazed only once it was in melee, and then only at the front rank. A medusa doesn't do this, because her entry has no such rule. In the monster catalog, the basilisk's `surprise` ability is no longer `manual`, and its `params` include `meets_gaze: true`. You don't need to set either. - Battle now uses a monster's other attack routines, not only the first. Before, it used only the first routine in the monster's stat block, so a thoul never used a weapon, a mule never bit, and an elephant never trampled, and an effect in a later routine never applied. Now the default policy, [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy], picks a routine for each melee attack, with equal chance among the first routine and the alternatives the OSE SRD offers. Each round, the spitting cobra either spits or bites, and its bite poisons. The thoul either claws or swings a weapon, the mule kicks or bites, the minotaur gores and bites or swings a weapon, and the wererat bites or uses a weapon. The policy never picks a situational routine, like an elephant's trample. A policy of your own can name one, and battle then rolls it as a melee attack. Battle never rolls a breath, a gaze, or a spell as an attack, even when a policy names one. The policy draws the pick from the monster-action stream, and only for a monster that has a choice. Every other monster draws as before. +- A spitting cobra's spit now blinds, as the OSE SRD's snake entry says: "A hit causes permanent blindness (save vs poison)." Before, a spit that hit did nothing. Now the target makes a saving throw versus death, which in B/X also covers poison, or is blind for good. The SRD's combat rules say "A blind character is unable to attack", so [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] refuses an attack from a blind member with `combat.attack.attacker_blind`. The blindness is permanent and *dispel magic* doesn't end it. No spell osrlib runs cures it, so a cure is the referee's call. In the monster catalog, the cobra's spit ability has the tag `blindness` where it had `blinding_spit`, and it's no longer `manual`. A game that looks the ability up with [`MonsterTemplate.ability`][osrlib.core.monsters.MonsterTemplate.ability] passes `"blindness"` now. The save draws from the combat stream, so a battle in which a spit hits plays out differently than before. No golden file changed. +- A ghoul or a thoul now turns from a member it has paralysed and attacks someone else, as the OSE SRD's ghoul entry says: "After paralysing a target, ghouls will attack others." The thoul's entry says the same of thouls. Before, the default policy, [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy], picked a paralysed member as often as any other. Now the policy passes over a paralysed member while someone who is neither paralysed nor turned to stone is in reach. It attacks a paralysed member only when no one else is in reach, and a paralysed member still comes before a statue. The policy reads the new param `attacks_others` in the monster's `paralysis` ability, not the monster's id, so a monster you define can have it too. In the monster catalog, the ghoul's and the thoul's `paralysis` abilities have `attacks_others: true`. A carcass crawler and a gelatinous cube still attack the members they paralyse, because their entries don't say otherwise. The policy picks a target once a round, so the rest of a ghoul's attacks that round stay on a member its claw has just paralysed. When a ghoul or a thoul passes over a paralysed member, its pick from the monster-action stream changes, so that fight plays out differently than before. No golden file changed. +- A monster no longer attacks a member who was turned to stone while someone else is in reach. Before, the default policy, [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy], picked a statue as often as any other member of the front rank, so a basilisk kept biting the members it had already turned to stone. A statue is out of the fight, so now the policy passes over one while a member who isn't stone is in reach. A paralysed member comes before a statue. When every member the monster can reach is stone, it attacks a statue, as before. The SRD doesn't say this, so the adaptations register records it as a judgement. A monster with no paralysed or petrified member in reach draws from the monster-action stream exactly as before, and so does a monster with only statues in reach. No golden file changed. - A trap's saving throw now adds the `modifier` of the trap's [`SaveSpec`][osrlib.core.spells.SaveSpec], as a spell's save does. Before, the session left the modifier out, so a trap you wrote with a bonus or a penalty on its save played as if it had none. The modifier changes whether the save passes, and the dice the session rolls are the same as before (#167). - Lighting the last oil flask in a pile on the floor with [`LightSource`][osrlib.crawl.commands.LightSource] now leaves the pile when gems, jewellery, or magic items are still in it. Before, the session removed the pile once it had no ordinary items and no coins, which deleted any gems, jewellery, or magic items still in it (#167). - When a breath weapon or *disintegrate* kills a party member, the magic items that make their saving throw now go to a drop pile on the party's cell in the same command. The party picks them up with [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] and the feature id `"pile"`. Before, the items stayed in the dead member's inventory, and the commands that hand over or drop an item refuse a dead member, so the party couldn't recover them. [`EquipmentDestroyedEvent`][osrlib.core.events.EquipmentDestroyedEvent]`.saved_items` still lists them, and the draws are unchanged (#167). diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 9dd2f32..44da4a8 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -199,7 +199,7 @@ """ import math -from collections.abc import Collection, Mapping, Sequence +from collections.abc import Collection, Iterable, Mapping, Sequence from typing import Any, NamedTuple, Protocol from pydantic import BaseModel, ConfigDict @@ -699,7 +699,7 @@ def choose(self, session, group, stream) -> list[MonsterAction]: if group.distance_feet > MELEE_RANGE_FEET: actions.append(MonsterAction(monster_id=monster.id, kind="close")) continue - targets = _reachable_targets(session, monster, pool) + targets = _preferred_targets(monster, _reachable_targets(session, monster, pool)) if not targets: actions.append(MonsterAction(monster_id=monster.id, kind="hold")) continue @@ -923,6 +923,33 @@ def _npc_wielded(npc, *, missile: bool, distance_feet: int = MELEE_RANGE_FEET): return None +def _preferred_targets(monster: MonsterInstance, targets: list) -> list: + """Narrow the targets a monster can reach to the ones `ScriptedPolicy` lets it pick. + + A member turned to stone is out of the fight, so the monster passes over one while a target that + isn't stone is in reach. A monster whose `paralysis` ability has the param `attacks_others`, like + the ghoul and the thoul, also passes over a paralysed member while a target that is neither + paralysed nor stone is in reach. When every target is stone, all of them come back, so the + monster attacks a statue rather than holding. The targets keep their order, and a list with no + paralysed or petrified member comes back unchanged, so the policy's draw over it is the draw it + made before these rules. + """ + standing = [target for target in targets if not has_condition(target, Condition.PETRIFIED)] + if not standing: + return targets + if _attacks_others(monster): + active = [target for target in standing if not has_condition(target, Condition.PARALYSED)] + if active: + return active + return standing + + +def _attacks_others(monster: MonsterInstance) -> bool: + """Whether the monster turns from the members it paralyses, as the ghoul's and the thoul's entries say.""" + ability = monster.template.ability("paralysis") + return ability is not None and not ability.manual and bool(ability.params.get("attacks_others")) + + _PICKED_ROUTINE_USES = frozenset({RoutineUse.PRIMARY, RoutineUse.ALTERNATIVE}) """The routines `ScriptedPolicy` picks among. A situational or special routine is left to a game's own policy.""" @@ -3449,7 +3476,7 @@ def _resolve_monster_melee(session, monster, target, *, retreating: bool, routin return events -_HIT_EFFECT_KINDS = frozenset({"paralysis", "poison", "disease", "energy_drain", "petrification"}) +_HIT_EFFECT_KINDS = frozenset({"paralysis", "poison", "disease", "energy_drain", "petrification", "blindness"}) """The attack effects that battle applies when a monster hits. The adaptations register lists the rest.""" @@ -3497,6 +3524,9 @@ def _apply_hit_effect(session, monster, target, kind: str, params: Mapping[str, to stone. - `poison` makes the target save versus death, with the ability's `save_modifier`, or die. The target dies at once, or when the onset in the ability's params runs out. + - `blindness`, the spitting cobra's spit, makes the target save versus death, the category for + poison, or be blinded for good. The effect is permanent and isn't dispellable, and a target + that's already blind saves again but gains nothing more. A monster that's immune to the condition the effect brings rolls no save and takes nothing. Saves draw from the combat stream, rolled durations and onsets from the effects stream, and @@ -3529,6 +3559,10 @@ def _apply_hit_effect(session, monster, target, kind: str, params: Mapping[str, elif kind == "petrification": condition = Condition.PETRIFIED definition = EffectDefinition(kind="petrification", condition=condition, permanent=True) + elif kind == "blindness": + category = SaveCategory.DEATH + condition = Condition.BLIND + definition = EffectDefinition(kind="blindness", condition=condition, permanent=True, stacking="ignore") else: category = SaveCategory.DEATH modifier = _int_param(params, "save_modifier") @@ -3617,37 +3651,57 @@ def _resolve_gazes(session, group, *, free_round: bool, mirrored: Collection[str A gaze takes no attack roll and no action. Every living gazer that isn't incapacitated uses it before its group acts, whatever the policy then chooses for it. While the gazer's group is in melee, the gaze reaches the party's front rank, less the members in `mirrored`, who fight by a - mirror this round. In the monsters' free round after they surprise the party (`free_round`), a - gazer with a `surprise` ability that says so, the basilisk, gazes instead at every living member - at any distance, so no member meets its gaze twice in that round. `resolve_gaze` skips a - character who is averting their eyes or is incapacitated, and rolls a save versus paralysis on - the combat stream for each of the others. + mirror this round. `resolve_gaze` skips a character who is averting their eyes or is + incapacitated, and rolls a save versus paralysis on the combat stream for each of the others. + + In the monsters' free round (`free_round`) of an encounter that opened with the party surprised, + a gazer whose `surprise` ability says so, the basilisk, already turned its gaze on every member + in `_surprise_gazes` as the encounter opened, so it doesn't gaze again in that round. """ - in_melee = group.distance_feet <= MELEE_RANGE_FEET + if group.distance_feet > MELEE_RANGE_FEET: + return [] events: list[Event] = [] for monster in _living_monsters(session, group): if incapacitated(monster) or _petrifying_gaze(monster) is None: continue - if free_round and _surprise_gaze(monster): - engaged = session.party.living_members() - elif in_melee: - engaged = [member for member in _party_front_rank(session) if member.id not in mirrored] - else: + if free_round and session.encounter.party_surprised and _surprise_gaze(monster): continue - events.extend( - resolve_gaze( - monster, - engaged, - stream=session.streams.get(COMBAT_STREAM), - ledger=session.ledger, - clock=session.clock, - allocator=session.allocator, - registry=session.registry(), - ) - ) + engaged = [member for member in _party_front_rank(session) if member.id not in mirrored] + events.extend(_gaze(session, monster, engaged)) + return events + + +def _surprise_gazes(session, monsters: Iterable) -> list[Event]: + """Turn each surprise gazer's petrifying gaze on every living member of the party it surprised. + + `start_encounter` calls this as the encounter opens, when the party is surprised, as the OSE + SRD's "Characters surprised by a basilisk meet its gaze" says. A monster gazes when its + `surprise` ability has `meets_gaze`, it has a petrifying gaze, and it isn't incapacitated. The + gaze reaches every living member once, at any distance and in any rank, and no stance spares + it, because the party hasn't declared anything yet. `resolve_gaze` skips a member who is + already incapacitated, and rolls a save versus paralysis on the combat stream for each of the + others. + """ + events: list[Event] = [] + for monster in monsters: + if incapacitated(monster) or _petrifying_gaze(monster) is None or not _surprise_gaze(monster): + continue + events.extend(_gaze(session, monster, session.party.living_members())) return events +def _gaze(session, monster, engaged: list) -> list[Event]: + return resolve_gaze( + monster, + engaged, + stream=session.streams.get(COMBAT_STREAM), + ledger=session.ledger, + clock=session.clock, + allocator=session.allocator, + registry=session.registry(), + ) + + _AVERT_EYES_PENALTY = -4 """The attack roll penalty for fighting with averted eyes, the basilisk's and the medusa's "-4 penalty to-hit".""" diff --git a/src/osrlib/crawl/encounter.py b/src/osrlib/crawl/encounter.py index 4cedeb4..daeda12 100644 --- a/src/osrlib/crawl/encounter.py +++ b/src/osrlib/crawl/encounter.py @@ -469,11 +469,13 @@ def start_encounter( assert session.encounter.groups[0].monster_ids == ["monster-0001", "monster-0002"] ``` """ + from osrlib.crawl import battle as battle_module from osrlib.crawl import exploration from osrlib.crawl.session import ENCOUNTER_STREAM stream = session.streams.get(ENCOUNTER_STREAM) - events: list[Event] = _attach_regeneration(session, [instance for _, instances in groups for instance in instances]) + opened = [instance for _, instances in groups for instance in instances] + events: list[Event] = _attach_regeneration(session, opened) lit, _ = session.party_light() monsters_surprised = False @@ -546,6 +548,11 @@ def start_encounter( monsters_surprised=monsters_surprised, ) ) + if party_surprised: + # "Characters surprised by a basilisk meet its gaze": every member meets it once, here, whether + # or not the monsters are surprised too and whatever the stance turns out to be. The monsters' + # free round, when they get one, doesn't repeat it. + events.extend(battle_module._surprise_gazes(session, opened)) if pinned_stance is not None: stance = pinned_stance @@ -557,8 +564,6 @@ def start_encounter( events.append(StanceChangedEvent(stance=stance.value)) if stance is ReactionResult.ATTACKS: - from osrlib.crawl import battle as battle_module - # A surprise advantage becomes a free battle round for either side: the # round the surprised monsters would have sat out is the party's free # round, and a surprised party gives the monsters theirs. diff --git a/src/osrlib/data/monsters.json b/src/osrlib/data/monsters.json index 12b4e2f..4dc412c 100644 --- a/src/osrlib/data/monsters.json +++ b/src/osrlib/data/monsters.json @@ -9024,6 +9024,7 @@ "manual": false, "name": "Paralysis", "params": { + "attacks_others": true, "duration": "2d4", "unaffected": [ "elf", @@ -24373,11 +24374,11 @@ { "abilities": [ { - "manual": true, + "manual": false, "name": "Blinding spit", "params": {}, "prose": "Range: 6’. A hit causes permanent blindness (save vs poison).", - "tag": "blinding_spit" + "tag": "blindness" }, { "manual": false, @@ -25567,6 +25568,7 @@ "manual": false, "name": "Paralysis", "params": { + "attacks_others": true, "duration": "2d4", "unaffected": [ "elf", diff --git a/tools/srd_compile/monsters.py b/tools/srd_compile/monsters.py index 848289e..4b58c57 100644 --- a/tools/srd_compile/monsters.py +++ b/tools/srd_compile/monsters.py @@ -110,14 +110,16 @@ ("Wraith.md", None, "Energy drain"): ("energy_drain", {"levels": 1, "xp_policy": "level_minimum"}, False), ("Spectre.md", None, "Energy drain"): ("energy_drain", {"levels": 2, "xp_policy": "level_minimum"}, False), ("Vampire.md", None, "Energy drain"): ("energy_drain", {"levels": 2, "xp_policy": "level_minimum"}, False), + # "After paralysing a target, ghouls will attack others", and the same of thouls: the default + # action policy reads `attacks_others` and turns the monster from the members it has paralysed. ("Ghoul.md", None, "Paralysis"): ( "paralysis", - {"duration": "2d4", "unit": "turn", "unaffected": ["elf", "larger_than_ogre"]}, + {"duration": "2d4", "unit": "turn", "unaffected": ["elf", "larger_than_ogre"], "attacks_others": True}, False, ), ("Thoul.md", None, "Paralysis"): ( "paralysis", - {"duration": "2d4", "unit": "turn", "unaffected": ["elf", "larger_than_ogre"]}, + {"duration": "2d4", "unit": "turn", "unaffected": ["elf", "larger_than_ogre"], "attacks_others": True}, False, ), ("Carcass_Crawler.md", None, "Paralysis"): ("paralysis", {"duration": "2d4", "unit": "turn"}, False), @@ -150,6 +152,9 @@ {"outcome": "death", "onset_dice": "1d10", "onset_unit": "turn"}, False, ), + # "A hit causes permanent blindness (save vs poison)." The spit's attack lists the `blindness` + # effect, and battle applies it under this tag. + ("Snake.md", "Spitting Cobra", "Blinding spit"): ("blindness", {}, False), # The sea snake's slow-acting onset interacts with *neutralize poison* (25% failure # after onset) — referee territory; the tarantella's dance and the giant # centipede's sickness are not death outcomes. All three stay manual. From cd328ddb3d40fe3fcf0bf69caeb6617f29d4eb3c Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 15:51:08 -0700 Subject: [PATCH 3/7] Pin that NPC adventurers pass over statues too NpcPartyPolicy melees "the same as a monster", so an NPC adventurer shouldn't pick a member turned to stone while another target is in reach. One acceptance test pins that, marked xfail(reason="chunk: monster-tactics", strict=True). Two guards pin that a front rank of statues is still attacked with the same draw as before, and that an NPC with no statue in reach draws as before, with a paralysed member still a candidate. The ghoul's rule about paralysed members belongs to the ghoul's and the thoul's abilities, so it doesn't apply to NPCs. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- tests/test_monster_tactics.py | 94 ++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/tests/test_monster_tactics.py b/tests/test_monster_tactics.py index 230270b..17b7cab 100644 --- a/tests/test_monster_tactics.py +++ b/tests/test_monster_tactics.py @@ -42,7 +42,7 @@ from osrlib.core.tables import ReactionResult from osrlib.crawl import encounter as encounter_module from osrlib.crawl.adventure import Adventure, TownSpec -from osrlib.crawl.battle import MonsterAction, ScriptedPolicy +from osrlib.crawl.battle import MonsterAction, NpcPartyPolicy, ScriptedPolicy from osrlib.crawl.commands import ( BattleDeclaration, EngageBattle, @@ -50,6 +50,7 @@ ResolveBattleRound, SessionMode, SpawnMonsters, + SpawnNpcParty, ) from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec from osrlib.crawl.party import Party @@ -554,6 +555,97 @@ def test_in_battle_a_basilisk_bites_the_member_still_standing(self): assert {event.defender_id for event in rolled} == {brakk.id} +# ---------------------------------------------------------------------- NPC adventurers + + +def _npc_battle(names: tuple[str, ...] = ("Hild", "Brakk")) -> GameSession: + """A battle at 5' against a band of one NPC adventurer who melees, with neither side surprised.""" + for seed in SEEDS: + game = _session(seed, _members(*names)) + result = game.execute(SpawnNpcParty(party_kind="basic", count_fixed=1, distance_feet=5)) + assert result.accepted + surprise = [event for event in result.events if event.code == "encounter.surprise.rolled"] + if not surprise or any(event.roll is not None and event.roll <= event.threshold for event in surprise): + continue + if game.mode is SessionMode.ENCOUNTER: + assert game.execute(EngageBattle()).accepted + if game.mode is not SessionMode.BATTLE or game.battle.round != 0: + continue + assert game.encounter.groups[0].distance_feet == 5 + npc = game.combatant(_monster_id(game)) + assert getattr(npc, "definition", None) is not None, "precondition: the band is NPC adventurers" + if [action.kind for action in _choose_npc(game)] != ["melee"]: + continue # a caster or a drinker: the test needs one who melees + return game + raise AssertionError("precondition: no seed opened a battle with an NPC adventurer who melees") + + +def _choose_npc(game: GameSession) -> list[MonsterAction]: + """Call the NPC party policy for the battle's only group, as the session does.""" + return NpcPartyPolicy().choose(game, game.encounter.groups[0], game.streams.get(MONSTER_ACTION_STREAM)) + + +def _npc_targets(game: GameSession, calls: int = POLICY_CALLS) -> list[str]: + targets = [] + for _ in range(calls): + actions = _choose_npc(game) + assert [action.kind for action in actions] == ["melee"], "precondition: the NPC attacks in melee" + targets.append(actions[0].target_id) + return targets + + +class TestNpcAdventurersAndStatues: + """`NpcPartyPolicy` melees "the same as a monster", so an NPC adventurer passes over a statue too. + + The ghoul's rule about paralysed members belongs to the ghoul's and the thoul's abilities, so it doesn't apply + to NPC adventurers. + """ + + @CHUNK + def test_an_npc_never_picks_a_statue_while_another_target_is_in_reach(self): + game = _npc_battle() + hild, brakk = game.party.members + _petrify(game, hild) + assert _front_rank_ids(game) == [hild.id, brakk.id], "precondition: the statue still stands in the front rank" + + assert set(_npc_targets(game)) == {brakk.id} + + def test_a_front_rank_of_statues_is_attacked_as_before(self): + """A guard: with only statues in reach, the NPC picks one of them, with the same draw as before.""" + game = _npc_battle(("Hild", "Brakk", "Sable")) + hild, brakk, sable = game.party.members + _petrify(game, hild) + _petrify(game, brakk) + front = _front_rank_ids(game) + assert front == [hild.id, brakk.id], "precondition: Sable stands behind the statues" + assert not incapacitated(sable) + stream = game.streams.get(MONSTER_ACTION_STREAM) + + for _ in range(POLICY_CALLS): + replica = RngStream.restore(stream.export_state()) + actions = _choose_npc(game) + assert [action.kind for action in actions] == ["melee"] + assert actions[0].target_id == front[replica.randbelow(len(front))] + assert stream.export_state() == replica.export_state() + + @pytest.mark.parametrize("paralysed", [False, True]) + def test_an_npc_with_no_statue_in_reach_draws_as_before(self, paralysed): + """A guard: the target draw is `randbelow` over the front rank, and a paralysed member stays a candidate.""" + game = _npc_battle() + if paralysed: + _paralyse(game, game.party.members[0]) + front = _front_rank_ids(game) + assert len(front) == 2 + stream = game.streams.get(MONSTER_ACTION_STREAM) + + for _ in range(POLICY_CALLS): + replica = RngStream.restore(stream.export_state()) + actions = _choose_npc(game) + assert [action.kind for action in actions] == ["melee"] + assert actions[0].target_id == front[replica.randbelow(len(front))] + assert stream.export_state() == replica.export_state(), "the policy drew differently than before" + + # ---------------------------------------------------------------------- draws From 5f4347b01547c8c3767495df55da7624e15e7890 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 15:53:22 -0700 Subject: [PATCH 4/7] Let NPC adventurers pass over statues too NpcPartyPolicy's melee target choice now goes through the statue half of the monster rule: it leaves out members turned to stone while a target that isn't stone is in reach, and keeps the whole list when every target is stone. A list with no statue is unchanged, so the monster-action draw is the same as before. The ghoul's and the thoul's rule about paralysed members stays with ScriptedPolicy, because it belongs to their abilities. _preferred_targets now builds on the shared _passing_over_statues. The NpcPartyPolicy docstring says how an NPC picks its melee target instead of "the same as a monster". The adaptations register and the statue changelog bullet and replay item name the NPC case. No golden file changed. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 4 ++-- docs/adaptations.md | 2 +- src/osrlib/crawl/battle.py | 44 ++++++++++++++++++++++++++------------ 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53d6e47..6381abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - A melee attack on a petrified creature. - A spitting cobra's spit that hits. - A ghoul or a thoul that attacks while one member in its reach is paralysed and another isn't. - - A monster that attacks while one member in its reach is turned to stone and another isn't. + - A monster or an NPC adventurer that attacks while one member in its reach is turned to stone and another isn't. - 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. - A wand, staff, rod, or turning that now fizzles. @@ -70,7 +70,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Battle now uses a monster's other attack routines, not only the first. Before, it used only the first routine in the monster's stat block, so a thoul never used a weapon, a mule never bit, and an elephant never trampled, and an effect in a later routine never applied. Now the default policy, [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy], picks a routine for each melee attack, with equal chance among the first routine and the alternatives the OSE SRD offers. Each round, the spitting cobra either spits or bites, and its bite poisons. The thoul either claws or swings a weapon, the mule kicks or bites, the minotaur gores and bites or swings a weapon, and the wererat bites or uses a weapon. The policy never picks a situational routine, like an elephant's trample. A policy of your own can name one, and battle then rolls it as a melee attack. Battle never rolls a breath, a gaze, or a spell as an attack, even when a policy names one. The policy draws the pick from the monster-action stream, and only for a monster that has a choice. Every other monster draws as before. - A spitting cobra's spit now blinds, as the OSE SRD's snake entry says: "A hit causes permanent blindness (save vs poison)." Before, a spit that hit did nothing. Now the target makes a saving throw versus death, which in B/X also covers poison, or is blind for good. The SRD's combat rules say "A blind character is unable to attack", so [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] refuses an attack from a blind member with `combat.attack.attacker_blind`. The blindness is permanent and *dispel magic* doesn't end it. No spell osrlib runs cures it, so a cure is the referee's call. In the monster catalog, the cobra's spit ability has the tag `blindness` where it had `blinding_spit`, and it's no longer `manual`. A game that looks the ability up with [`MonsterTemplate.ability`][osrlib.core.monsters.MonsterTemplate.ability] passes `"blindness"` now. The save draws from the combat stream, so a battle in which a spit hits plays out differently than before. No golden file changed. - A ghoul or a thoul now turns from a member it has paralysed and attacks someone else, as the OSE SRD's ghoul entry says: "After paralysing a target, ghouls will attack others." The thoul's entry says the same of thouls. Before, the default policy, [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy], picked a paralysed member as often as any other. Now the policy passes over a paralysed member while someone who is neither paralysed nor turned to stone is in reach. It attacks a paralysed member only when no one else is in reach, and a paralysed member still comes before a statue. The policy reads the new param `attacks_others` in the monster's `paralysis` ability, not the monster's id, so a monster you define can have it too. In the monster catalog, the ghoul's and the thoul's `paralysis` abilities have `attacks_others: true`. A carcass crawler and a gelatinous cube still attack the members they paralyse, because their entries don't say otherwise. The policy picks a target once a round, so the rest of a ghoul's attacks that round stay on a member its claw has just paralysed. When a ghoul or a thoul passes over a paralysed member, its pick from the monster-action stream changes, so that fight plays out differently than before. No golden file changed. -- A monster no longer attacks a member who was turned to stone while someone else is in reach. Before, the default policy, [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy], picked a statue as often as any other member of the front rank, so a basilisk kept biting the members it had already turned to stone. A statue is out of the fight, so now the policy passes over one while a member who isn't stone is in reach. A paralysed member comes before a statue. When every member the monster can reach is stone, it attacks a statue, as before. The SRD doesn't say this, so the adaptations register records it as a judgement. A monster with no paralysed or petrified member in reach draws from the monster-action stream exactly as before, and so does a monster with only statues in reach. No golden file changed. +- A monster or an NPC adventurer no longer attacks a member who was turned to stone while someone else is in reach. Before, the default policies, [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy] for monsters and [`NpcPartyPolicy`][osrlib.crawl.battle.NpcPartyPolicy] for NPC adventurers, picked a statue as often as any other member of the front rank, so a basilisk kept biting the members it had already turned to stone. A statue is out of the fight, so now both policies pass over one while a member who isn't stone is in reach. A paralysed member comes before a statue. When every member in reach is stone, the attacker picks a statue, as before. The SRD doesn't say this, so the adaptations register records it as a judgement. A monster with no paralysed or petrified member in reach, an NPC adventurer with no statue in reach, and either one with only statues in reach draw from the monster-action stream exactly as before. No golden file changed. - A trap's saving throw now adds the `modifier` of the trap's [`SaveSpec`][osrlib.core.spells.SaveSpec], as a spell's save does. Before, the session left the modifier out, so a trap you wrote with a bonus or a penalty on its save played as if it had none. The modifier changes whether the save passes, and the dice the session rolls are the same as before (#167). - Lighting the last oil flask in a pile on the floor with [`LightSource`][osrlib.crawl.commands.LightSource] now leaves the pile when gems, jewellery, or magic items are still in it. Before, the session removed the pile once it had no ordinary items and no coins, which deleted any gems, jewellery, or magic items still in it (#167). - When a breath weapon or *disintegrate* kills a party member, the magic items that make their saving throw now go to a drop pile on the party's cell in the same command. The party picks them up with [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] and the feature id `"pile"`. Before, the items stayed in the dead member's inventory, and the commands that hand over or drop an item refuse a dead member, so the party couldn't recover them. [`EquipmentDestroyedEvent`][osrlib.core.events.EquipmentDestroyedEvent]`.saved_items` still lists them, and the draws are unchanged (#167). diff --git a/docs/adaptations.md b/docs/adaptations.md index 407e1e0..893fe3f 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -398,7 +398,7 @@ The policy draws only from the monster-action stream. Monsters with a scripted p ### Monsters pass over statues, and ghouls and thouls turn from the members they paralyse -A monster that melees picks its target with equal chance among the members of the party's front rank it can reach, but it passes over some of them while another target is in reach. A member turned to stone is a statue and out of the fight, so no monster picks one while a target that isn't stone is in reach. The SRD doesn't say this, so it's a judgement (pinned). The ghoul's entry says "After paralysing a target, ghouls will attack others", and the thoul's says the same of thouls. Their `paralysis` abilities have the param `attacks_others`, and a monster whose `paralysis` ability has it also passes over a paralysed member while a target that is neither paralysed nor stone is in reach. The policy reads the param, not the monster's id. The carcass crawler's and the gelatinous cube's entries have no such clause, so they keep attacking the members they paralyse. A paralysed member comes before a statue, for a ghoul too. +A monster that melees picks its target with equal chance among the members of the party's front rank it can reach, but it passes over some of them while another target is in reach. A member turned to stone is a statue and out of the fight, so no monster picks one while a target that isn't stone is in reach. The SRD doesn't say this, so it's a judgement (pinned). The ghoul's entry says "After paralysing a target, ghouls will attack others", and the thoul's says the same of thouls. Their `paralysis` abilities have the param `attacks_others`, and a monster whose `paralysis` ability has it also passes over a paralysed member while a target that is neither paralysed nor stone is in reach. The policy reads the param, not the monster's id. The carcass crawler's and the gelatinous cube's entries have no such clause, so they keep attacking the members they paralyse. A paralysed member comes before a statue, for a ghoul too. An NPC adventurer under `NpcPartyPolicy` passes over statues the same way, and still picks a paralysed member, because the ghoul's clause belongs to the ghoul's and the thoul's abilities (pinned). When every member in reach is stone, the monster attacks a statue, as it did before this rule (pinned). It doesn't hold, because a party that is all stone declares nothing and could never end the battle. It doesn't reach past the statues to the ranks behind, because melee reaches the front rank alone, in both directions. A dead member leaves the ranks at once, so a front rank is never made of the dead. The policy picks a target once a round, so a ghoul whose claw paralyses its target spends the rest of that round's attacks on the same target (pinned). A monster with no paralysed or petrified member among its candidates draws exactly as before, and so does one whose candidates are all stone. Locked by `test_monster_tactics.py`. diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 44da4a8..8abf0d8 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -732,7 +732,11 @@ class NpcPartyPolicy: leaving out invisible members. 4. A member with a missile weapon shoots while the group is farther away than [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET]. - 5. Otherwise, the member closes and attacks in melee, the same as a monster. + 5. Otherwise, the member closes and attacks in melee. Once in range, it picks its target at + random from the party's front rank, leaving out invisible members, and passes over a member + turned to stone while a target that isn't stone is in reach, as a monster does. The ghoul's + and the thoul's rule about paralysed members belongs to their abilities, so an NPC still + picks a paralysed member. NPCs declare their spells at the start of the round. An attack that hits or a failed saving throw can disrupt an NPC's spell, as it can the party's, because the SRD's rule applies to both @@ -859,7 +863,7 @@ def choose(self, session, group, stream) -> list[MonsterAction]: else: actions.append(MonsterAction(monster_id=npc.id, kind="close")) continue - targets = _reachable_targets(session, npc, pool) + targets = _passing_over_statues(_reachable_targets(session, npc, pool)) if not targets: actions.append(MonsterAction(monster_id=npc.id, kind="hold")) continue @@ -926,22 +930,34 @@ def _npc_wielded(npc, *, missile: bool, distance_feet: int = MELEE_RANGE_FEET): def _preferred_targets(monster: MonsterInstance, targets: list) -> list: """Narrow the targets a monster can reach to the ones `ScriptedPolicy` lets it pick. - A member turned to stone is out of the fight, so the monster passes over one while a target that - isn't stone is in reach. A monster whose `paralysis` ability has the param `attacks_others`, like - the ghoul and the thoul, also passes over a paralysed member while a target that is neither - paralysed nor stone is in reach. When every target is stone, all of them come back, so the - monster attacks a statue rather than holding. The targets keep their order, and a list with no - paralysed or petrified member comes back unchanged, so the policy's draw over it is the draw it - made before these rules. + The monster passes over statues, as `_passing_over_statues` says. A monster whose `paralysis` + ability has the param `attacks_others`, like the ghoul and the thoul, also passes over a + paralysed member while a target that is neither paralysed nor stone is in reach. The targets + keep their order, and a list with no paralysed or petrified member comes back unchanged, so the + policy's draw over it is the draw it made before these rules. """ - standing = [target for target in targets if not has_condition(target, Condition.PETRIFIED)] - if not standing: - return targets + targets = _passing_over_statues(targets) if _attacks_others(monster): - active = [target for target in standing if not has_condition(target, Condition.PARALYSED)] + active = [ + target + for target in targets + if not has_condition(target, Condition.PARALYSED) and not has_condition(target, Condition.PETRIFIED) + ] if active: return active - return standing + return targets + + +def _passing_over_statues(targets: list) -> list: + """Leave out the members turned to stone while a target that isn't stone is in reach. + + A statue is out of the fight, so neither a monster under `ScriptedPolicy` nor an NPC adventurer + under `NpcPartyPolicy` picks one while it has another choice. When every target is stone, all of + them come back, so the attacker picks a statue rather than holding. The targets keep their + order, and a list with no statue comes back unchanged. + """ + standing = [target for target in targets if not has_condition(target, Condition.PETRIFIED)] + return standing or targets def _attacks_others(monster: MonsterInstance) -> bool: From e25177ff70a3c53aa378174dd89d00fc38133ba6 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 15:58:43 -0700 Subject: [PATCH 5/7] Pin that a blind member meets no gaze and a joining basilisk doesn't gaze Someone who can't see can't meet a gaze. Three acceptance tests, marked xfail(reason="chunk: monster-tactics", strict=True), pin that a blind front-rank member is spared the basilisk's and the medusa's gaze in a battle round, and that a blind member is spared the surprise gaze when the encounter opens. Each asserts the blindness as a precondition. A guard pins that a basilisk spawned into an encounter already open doesn't gaze at a party that was surprised when the encounter opened, because the basilisk surprised no one. It holds today. The adaptations register says both. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- docs/adaptations.md | 4 +- tests/test_monster_tactics.py | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/adaptations.md b/docs/adaptations.md index 5f76459..44e8ea6 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -406,11 +406,11 @@ When every member in reach is stone, the monster attacks a statue, as it did bef When a monster's attack hits in battle, the session applies each effect the attack lists if the effect is `paralysis`, `poison`, `disease`, `energy_drain`, `petrification`, or `blindness` and the monster's ability for it isn't `manual`. The effects apply once for each hit, so a target hit by two paralysing claws saves twice. The target saves versus paralysis against paralysis and petrification, and versus death, with the ability's modifier, against poison. A failed save against poison kills at once, or when the poison's onset runs out. The spitting cobra's entry says "Blinding spit: Range: 6'. A hit causes permanent blindness (save vs poison)." A spit hit makes the target save versus death, the category for poison, or be blinded for good. The effect is permanent and isn't dispellable, and no spell osrlib runs ends it, so a cure is the referee's call. The SRD's combat rules say "A blind character is unable to attack", so the session refuses an attack from a blind member with `combat.attack.attacker_blind`. Mummy rot and energy drain allow no save. An elf, whose class has `ghoul_paralysis_immunity`, rolls no save against the ghoul's or the thoul's paralysis and isn't paralysed. osrlib has no model of size, so the same paralysis affects a creature larger than an ogre, which the SRD exempts. A dead or petrified target takes none of these effects. -The session runs the basilisk's and the medusa's petrifying gaze through `resolve_gaze` once a round while the gazer's group is at 5', before the group acts. The gaze reaches the party's front rank and takes no attack roll. The session never rolls the basilisk's `gaze` attack entry as an attack. +The session runs the basilisk's and the medusa's petrifying gaze through `resolve_gaze` once a round while the gazer's group is at 5', before the group acts. The gaze reaches the party's front rank and takes no attack roll. The session never rolls the basilisk's `gaze` attack entry as an attack. A blind member can't meet a gaze, so no gaze reaches one, the surprise gaze included (pinned). The SRD's basilisk says "Unless averting eyes or using a mirror, characters in melee are affected each round", so a member's `BattleDeclaration` takes a `gaze_defense` of `avert_eyes` or `mirror` for one round, with any action. A member who averts their eyes has the `averted_eyes` condition from the moment the round is accepted until it ends, and `resolve_gaze` spares them. The basilisk's and the medusa's entries give the cost: "-4 penalty to-hit; the basilisk gains a +2 bonus to attack". The −4 applies to every attack roll the member makes that round, whatever it's aimed at, because the SRD gives it without naming a target. The +2 applies only to the attacks a gazer makes on the member, because the SRD names the gazer for it (pinned). A member who watches a mirror is spared the gaze too, and every attack roll they make that round takes the −1 the basilisk's entry gives for "Fighting by looking in a mirror". No monster gains a bonus against them. The SRD's equipment list has the mirror, "Useful for looking around corners or for reflecting a gaze attack", so the member has to carry one, and the session refuses a `mirror` stance from a member who doesn't with `battle.declaration.no_mirror`. Neither stance draws anything or emits an event of its own, so a round without one draws exactly as before. A back-rank member is out of the gaze's reach and needs no stance, and a member who can't act, and so doesn't declare, is already spared by `resolve_gaze`. -The basilisk's entry also says "Characters surprised by a basilisk meet its gaze". When a basilisk surprises the party, every living member of the party, back rank included, meets its gaze once as the encounter opens, at whatever distance the encounter opened. The gaze comes whatever the stance, so it comes in an encounter that never becomes a battle. It comes when the basilisk is surprised too, because the clause is about the characters and a gaze takes no action (pinned). A battle that opens from the encounter brings no second surprise gaze. When the battle opens at once with the monsters' free round, the basilisk doesn't gaze again in that round, so a member at 5' meets its gaze once. A battle that opens later gazes at the front rank in each round the basilisk is in melee, as any battle does. The party declares nothing before the encounter opens, so no stance can spare the surprise gaze. The medusa's entry has no such clause, so a medusa that surprises the party gazes in its free round only if it's already at 5', and then only at the front rank (pinned). +The basilisk's entry also says "Characters surprised by a basilisk meet its gaze". When a basilisk surprises the party, every living member of the party, back rank included, meets its gaze once as the encounter opens, at whatever distance the encounter opened. The gaze comes whatever the stance, so it comes in an encounter that never becomes a battle. It comes when the basilisk is surprised too, because the clause is about the characters and a gaze takes no action (pinned). A battle that opens from the encounter brings no second surprise gaze. When the battle opens at once with the monsters' free round, the basilisk doesn't gaze again in that round, so a member at 5' meets its gaze once. A battle that opens later gazes at the front rank in each round the basilisk is in melee, as any battle does. The party declares nothing before the encounter opens, so no stance can spare the surprise gaze. A basilisk that joins an encounter already open, as one a referee spawns into it does, surprised no one, so it doesn't gaze at the party as it joins, even when the party was surprised as the encounter opened. The medusa's entry has no such clause, so a medusa that surprises the party gazes in its free round only if it's already at 5', and then only at the front rank (pinned). The SRD's rule that a basilisk or a medusa that sees its own reflection saves or turns to stone is left to the referee. The basilisk's entry gives that a 2-in-6 chance and the medusa's gives no chance at all, so both monsters' `mirrors` abilities stay `manual`. The basilisk's `surprise` ability and both monsters' `averting_eyes` abilities aren't `manual`, because the session applies them. The medusa's disguise is left to the referee too. Locked by `test_avert_eyes.py`, and the surprise gaze outside a battle by `test_monster_tactics.py`. diff --git a/tests/test_monster_tactics.py b/tests/test_monster_tactics.py index 17b7cab..5e597a8 100644 --- a/tests/test_monster_tactics.py +++ b/tests/test_monster_tactics.py @@ -135,6 +135,11 @@ def _petrify(game: GameSession, member: Character) -> None: assert has_condition(member, Condition.PETRIFIED) +def _blind(game: GameSession, member: Character) -> None: + _attach(game, member, EffectDefinition(kind="test_blindness", condition=Condition.BLIND, permanent=True)) + assert has_condition(member, Condition.BLIND) + + def _make_invisible(game: GameSession, member: Character) -> None: _attach(game, member, EffectDefinition(kind="test_invisibility", condition=Condition.INVISIBLE, permanent=True)) assert has_condition(member, Condition.INVISIBLE) @@ -213,6 +218,16 @@ def _saves(events, target_id: str, category: str | None = None) -> list[SavingTh ] +def _gaze_saves(events, monster_id: str, member_id: str) -> int: + """The member's saves versus paralysis, less those the monster's petrifying bites caused: the gazes they met.""" + bites = sum( + 1 + for event in _rolls(events, monster_id) + if event.defender_id == member_id and event.attack_name == "bite" and event.code in HIT_CODES + ) + return len(_saves(events, member_id, "paralysis")) - bites + + def _state_without_logs(game: GameSession) -> dict: """Every state block, the clock and the random number streams among them, without the two logs.""" state = session_state(game) @@ -684,6 +699,28 @@ def test_a_paralysed_member_changes_nothing_for_a_monster_without_the_clause(sel assert stream.export_state() == replica.export_state() +class TestABlindMemberMeetsNoGaze: + """Someone who can't see can't meet a gaze, so a blind front-rank member is spared it in battle.""" + + @CHUNK + @pytest.mark.parametrize("template_id", ["basilisk", "medusa"]) + def test_a_blind_front_rank_member_is_spared_the_gaze(self, template_id): + game = _battle(template_id) + hild, brakk = game.party.members + for member in (hild, brakk): + _force_saves(game, member, ALWAYS_PASS) + _blind(game, hild) + assert _front_rank_ids(game) == [hild.id, brakk.id], "precondition: Hild stands in the front rank" + monster_id = _monster_id(game) + + for _ in range(3): + assert game.encounter.groups[0].distance_feet == 5, "precondition: the gazer is in melee" + events = _play(game) + assert has_condition(hild, Condition.BLIND), "precondition: Hild is still blind" + assert _gaze_saves(events, monster_id, brakk.id) == 1, "precondition: the gaze reached Brakk" + assert _gaze_saves(events, monster_id, hild.id) == 0, "a blind member meets no gaze" + + # ---------------------------------------------------------------------- the surprise gaze @@ -696,17 +733,21 @@ def _opening( both_surprised: bool = False, save_bonus: int = ALWAYS_PASS, names: tuple[str, ...] = ("Hild", "Brakk", "Sable"), + blind: tuple[int, ...] = (), ) -> tuple[GameSession, list]: """A party surprised by one monster, and the opening events of the encounter. `battle` says whether the opening must end in a battle (True), in encounter mode (False), or either (None). With `both_surprised`, the monster rolls for surprise too and loses. Otherwise it's aware and can't be surprised. + The members at the indexes in `blind` are blinded before the encounter opens. The party's front rank is Hild and Brakk, and Sable stands in the back rank. """ for seed in SEEDS: game = _session(seed, _members(*names)) for member in game.party.members: _force_saves(game, member, save_bonus) + for index in blind: + _blind(game, game.party.members[index]) instances = game.spawn(template_id, 1) events = encounter_module.start_encounter( game, @@ -816,6 +857,35 @@ def test_a_medusa_that_surprises_the_party_doesnt_gaze_at_the_opening(self): for member in game.party.members: assert not _saves(events, member.id) + @CHUNK + def test_a_blind_member_is_spared_the_surprise_gaze(self): + """Someone who can't see can't meet a gaze.""" + game, events = _opening("basilisk", ReactionResult.INDIFFERENT, blind=(0,)) + hild, brakk, sable = game.party.members + + assert has_condition(hild, Condition.BLIND), "precondition: Hild is blind as the encounter opens" + assert len(_saves(events, brakk.id, "paralysis")) == 1, "precondition: the surprise gaze ran" + assert len(_saves(events, sable.id, "paralysis")) == 1 + assert not _saves(events, hild.id), "a blind member meets no gaze" + + def test_a_basilisk_that_joins_an_open_encounter_doesnt_gaze(self): + """A guard: a basilisk spawned into an encounter already open surprised no one, so it meets no one's eyes.""" + game, opening = _opening("goblin", ReactionResult.INDIFFERENT) + assert game.mode is SessionMode.ENCOUNTER and game.encounter.party_surprised, ( + "precondition: surprised, no battle" + ) + assert not [event for event in opening if isinstance(event, SavingThrowRolledEvent)] + + joined = game.execute(SpawnMonsters(template_id="basilisk", count_fixed=1, distance_feet=60)) + + assert joined.accepted + assert "encounter.group.joined" in [event.code for event in joined.events], "precondition: it joined" + assert len(game.encounter.groups) == 2 + assert not [event for event in joined.events if isinstance(event, SavingThrowRolledEvent)] + engage = game.execute(EngageBattle()) + assert engage.accepted + assert not [event for event in engage.events if isinstance(event, SavingThrowRolledEvent)] + @CHUNK def test_the_playtests_spawn_meets_it_and_replays(self): """The playtest's case, by command: a referee spawn the basilisk surprises, which stays an encounter.""" From e3384cd4dae2f517e2c3ecd7d191e2c8135d850a Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 16:00:44 -0700 Subject: [PATCH 6/7] Spare a blind creature from every petrifying gaze Someone who can't see can't meet a gaze. resolve_gaze now skips a creature with the blind condition, as it skips one averting its eyes, so neither the battle round's front-rank gaze nor the basilisk's surprise gaze reaches a blind member. A blind creature rolls no save, so the combat stream draws one fewer for each blind creature a gaze would have reached. Nothing else draws differently. The docstrings of resolve_gaze, Condition.BLIND, the battle module, _resolve_gazes, and _surprise_gazes say so, and so do the gaze changelog bullets. No golden file changed. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 4 ++-- src/osrlib/core/combat.py | 11 ++++++++--- src/osrlib/core/effects.py | 5 +++-- src/osrlib/crawl/battle.py | 11 ++++++----- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1290064..f7dda69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,8 +68,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - 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. -- When a monster's attack hits in battle, the session applies the effects the attack lists (#167). A ghoul's or a thoul's claw paralyses for 2d4 turns unless the target saves versus paralysis. An elf isn't affected. A carcass crawler's tentacle and a gelatinous cube's touch paralyse the same way, and they affect elves too. A poisonous bite or sting kills unless the target saves versus death. The target dies at once, or when the poison's onset runs out if the monster's poison has one. The session records the death as a poison death, so *neutralize poison* can revive the character. A mummy's touch gives mummy rot, with no save. A wight's, a wraith's, a spectre's, or a vampire's touch drains levels, also with no save. A basilisk's bite and a cockatrice's beak turn the target to stone unless it saves versus petrification. The basilisk's and the medusa's petrifying gaze reaches each character in the party's front rank once a round while the gazer is in melee. The gaze takes no attack roll, and the session no longer rolls the basilisk's gaze as an attack. A character with the `averted_eyes` condition doesn't meet the gaze, and a member who sets the new [`BattleDeclaration.gaze_defense`][osrlib.crawl.commands.BattleDeclaration.gaze_defense] field doesn't meet it either. The adaptations register lists the attack effects that the session leaves to the referee. In a battle against one of these monsters, the session now makes more draws from the combat, effects, and advancement streams. So the same seed and commands give a different battle than they gave under 2.0.1. No golden file changed. -- Every member of a party a basilisk surprises now meets its gaze as the encounter opens, as the OSE SRD's basilisk says: "Characters surprised by a basilisk meet its gaze." Each living member, back rank included, makes one saving throw versus paralysis, which in B/X also covers petrification, or turns to stone. The gaze comes at whatever distance the encounter opened and whatever the monsters' reaction, so it comes in an encounter that never becomes a battle. It comes when the basilisk is surprised too, because a gaze takes no action. The saving throws come among the opening events of [`start_encounter`][osrlib.crawl.encounter.start_encounter], so the command that opened the encounter reports them, whether the encounter came from a [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters] command, a keyed area, or a wandering-monster check. A battle that opens from the encounter doesn't repeat the gaze. When the basilisk attacks at once, it doesn't gaze again in its free round, so a member in melee meets its gaze once. Before, a basilisk that surprised the party gazed only once it was in melee, and then only at the front rank. A medusa doesn't do this, because her entry has no such rule. In the monster catalog, the basilisk's `surprise` ability is no longer `manual`, and its `params` include `meets_gaze: true`. You don't need to set either. +- When a monster's attack hits in battle, the session applies the effects the attack lists (#167). A ghoul's or a thoul's claw paralyses for 2d4 turns unless the target saves versus paralysis. An elf isn't affected. A carcass crawler's tentacle and a gelatinous cube's touch paralyse the same way, and they affect elves too. A poisonous bite or sting kills unless the target saves versus death. The target dies at once, or when the poison's onset runs out if the monster's poison has one. The session records the death as a poison death, so *neutralize poison* can revive the character. A mummy's touch gives mummy rot, with no save. A wight's, a wraith's, a spectre's, or a vampire's touch drains levels, also with no save. A basilisk's bite and a cockatrice's beak turn the target to stone unless it saves versus petrification. The basilisk's and the medusa's petrifying gaze reaches each character in the party's front rank once a round while the gazer is in melee. The gaze takes no attack roll, and the session no longer rolls the basilisk's gaze as an attack. A character with the `averted_eyes` condition doesn't meet the gaze, and a member who sets the new [`BattleDeclaration.gaze_defense`][osrlib.crawl.commands.BattleDeclaration.gaze_defense] field doesn't meet it either. A blind character can't meet a gaze, so [`resolve_gaze`][osrlib.core.combat.resolve_gaze] spares one too. The adaptations register lists the attack effects that the session leaves to the referee. In a battle against one of these monsters, the session now makes more draws from the combat, effects, and advancement streams. So the same seed and commands give a different battle than they gave under 2.0.1. No golden file changed. +- Every member of a party a basilisk surprises now meets its gaze as the encounter opens, as the OSE SRD's basilisk says: "Characters surprised by a basilisk meet its gaze." Each living member, back rank included, makes one saving throw versus paralysis, which in B/X also covers petrification, or turns to stone. A blind member can't meet the gaze and makes no save. The gaze comes at whatever distance the encounter opened and whatever the monsters' reaction, so it comes in an encounter that never becomes a battle. It comes when the basilisk is surprised too, because a gaze takes no action. The saving throws come among the opening events of [`start_encounter`][osrlib.crawl.encounter.start_encounter], so the command that opened the encounter reports them, whether the encounter came from a [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters] command, a keyed area, or a wandering-monster check. A battle that opens from the encounter doesn't repeat the gaze. When the basilisk attacks at once, it doesn't gaze again in its free round, so a member in melee meets its gaze once. Before, a basilisk that surprised the party gazed only once it was in melee, and then only at the front rank. A medusa doesn't do this, because her entry has no such rule. In the monster catalog, the basilisk's `surprise` ability is no longer `manual`, and its `params` include `meets_gaze: true`. You don't need to set either. - Battle now uses a monster's other attack routines, not only the first. Before, it used only the first routine in the monster's stat block, so a thoul never used a weapon, a mule never bit, and an elephant never trampled, and an effect in a later routine never applied. Now the default policy, [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy], picks a routine for each melee attack, with equal chance among the first routine and the alternatives the OSE SRD offers. Each round, the spitting cobra either spits or bites, and its bite poisons. The thoul either claws or swings a weapon, the mule kicks or bites, the minotaur gores and bites or swings a weapon, and the wererat bites or uses a weapon. The policy never picks a situational routine, like an elephant's trample. A policy of your own can name one, and battle then rolls it as a melee attack. Battle never rolls a breath, a gaze, or a spell as an attack, even when a policy names one. The policy draws the pick from the monster-action stream, and only for a monster that has a choice. Every other monster draws as before. - A spitting cobra's spit now blinds, as the OSE SRD's snake entry says: "A hit causes permanent blindness (save vs poison)." Before, a spit that hit did nothing. Now the target makes a saving throw versus death, which in B/X also covers poison, or is blind for good. The SRD's combat rules say "A blind character is unable to attack", so [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] refuses an attack from a blind member with `combat.attack.attacker_blind`. The blindness is permanent and *dispel magic* doesn't end it. No spell osrlib runs cures it, so a cure is the referee's call. In the monster catalog, the cobra's spit ability has the tag `blindness` where it had `blinding_spit`, and it's no longer `manual`. A game that looks the ability up with [`MonsterTemplate.ability`][osrlib.core.monsters.MonsterTemplate.ability] passes `"blindness"` now. The save draws from the combat stream, so a battle in which a spit hits plays out differently than before. No golden file changed. - A ghoul or a thoul now turns from a member it has paralysed and attacks someone else, as the OSE SRD's ghoul entry says: "After paralysing a target, ghouls will attack others." The thoul's entry says the same of thouls. Before, the default policy, [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy], picked a paralysed member as often as any other. Now the policy passes over a paralysed member while someone who is neither paralysed nor turned to stone is in reach. It attacks a paralysed member only when no one else is in reach, and a paralysed member still comes before a statue. The policy reads the new param `attacks_others` in the monster's `paralysis` ability, not the monster's id, so a monster you define can have it too. In the monster catalog, the ghoul's and the thoul's `paralysis` abilities have `attacks_others: true`. A carcass crawler and a gelatinous cube still attack the members they paralyse, because their entries don't say otherwise. The policy picks a target once a round, so the rest of a ghoul's attacks that round stay on a member its claw has just paralysed. When a ghoul or a thoul passes over a paralysed member, its pick from the monster-action stream changes, so that fight plays out differently than before. No golden file changed. diff --git a/src/osrlib/core/combat.py b/src/osrlib/core/combat.py index d16a496..263784f 100644 --- a/src/osrlib/core/combat.py +++ b/src/osrlib/core/combat.py @@ -3630,8 +3630,9 @@ def resolve_gaze( creatures in melee with it, because `osrlib.core` has no record of where creatures stand. - Each of those creatures saves versus paralysis, unless it's averting its eyes or is - already [`incapacitated`][osrlib.core.combat.incapacitated]. On a failed save, the + Each of those creatures saves versus paralysis, unless it's averting its eyes, is blind, + or is already [`incapacitated`][osrlib.core.combat.incapacitated]. A creature that can't + see can't meet a gaze, so the function skips one with the `blind` condition. On a failed save, the function attaches a permanent petrification effect, which gives the `petrified` condition. A petrified creature isn't dead, and the effect can be ended. A creature that fights with its eyes @@ -3695,7 +3696,11 @@ def resolve_gaze( """ events: list[Event] = [] for target in engaged: - if has_condition(target, Condition.AVERTED_EYES) or incapacitated(target): + if ( + has_condition(target, Condition.AVERTED_EYES) + or has_condition(target, Condition.BLIND) + or incapacitated(target) + ): continue save = saving_throw(target, SaveCategory.PARALYSIS, stream=stream) events.extend(save.events) diff --git a/src/osrlib/core/effects.py b/src/osrlib/core/effects.py index f24313a..6aed222 100644 --- a/src/osrlib/core/effects.py +++ b/src/osrlib/core/effects.py @@ -197,8 +197,9 @@ class Condition(StrEnum): BLIND = "blind" """Unable to see. [`validate_attack`][osrlib.core.combat.validate_attack] rejects the creature's attacks with - `combat.attack.attacker_blind`. A battle in a [`GameSession`][osrlib.crawl.session.GameSession] gives it for good - to a creature a spitting cobra's spit hits, when the creature fails its save versus death.""" + `combat.attack.attacker_blind`, and [`resolve_gaze`][osrlib.core.combat.resolve_gaze] skips the creature, because + it can't meet a gaze. A battle in a [`GameSession`][osrlib.crawl.session.GameSession] gives it for good to a + creature a spitting cobra's spit hits, when the creature fails its save versus death.""" CHARMED = "charmed" """Under a charm. The monsters that can't be charmed, the undead and the golems among them, have it in diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index f1f379c..e5d5a78 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -78,7 +78,8 @@ the session leaves to the referee. - A petrifying gaze, like the basilisk's or the medusa's, reaches each member of the party's front rank once a round while the gazer's group is in melee. It takes no attack roll, and - [`resolve_gaze`][osrlib.core.combat.resolve_gaze] skips a member who is averting their eyes. + [`resolve_gaze`][osrlib.core.combat.resolve_gaze] skips a member who is averting their eyes + or is blind. A member averts them, or watches a mirror instead, through [`BattleDeclaration.gaze_defense`][osrlib.crawl.commands.BattleDeclaration.gaze_defense], and the session applies that stance's penalties to the round's attack rolls. Every member of a @@ -3716,7 +3717,7 @@ def _resolve_gazes(session, group, *, free_round: bool, mirrored: Collection[str A gaze takes no attack roll and no action. Every living gazer that isn't incapacitated uses it before its group acts, whatever the policy then chooses for it. While the gazer's group is in melee, the gaze reaches the party's front rank, less the members in `mirrored`, who fight by a - mirror this round. `resolve_gaze` skips a character who is averting their eyes or is + mirror this round. `resolve_gaze` skips a character who is averting their eyes, is blind, or is incapacitated, and rolls a save versus paralysis on the combat stream for each of the others. In the monsters' free round (`free_round`) of an encounter that opened with the party surprised, @@ -3743,9 +3744,9 @@ def _surprise_gazes(session, monsters: Iterable) -> list[Event]: SRD's "Characters surprised by a basilisk meet its gaze" says. A monster gazes when its `surprise` ability has `meets_gaze`, it has a petrifying gaze, and it isn't incapacitated. The gaze reaches every living member once, at any distance and in any rank, and no stance spares - it, because the party hasn't declared anything yet. `resolve_gaze` skips a member who is - already incapacitated, and rolls a save versus paralysis on the combat stream for each of the - others. + it, because the party hasn't declared anything yet. `resolve_gaze` skips a member who is blind + or already incapacitated, and rolls a save versus paralysis on the combat stream for each of + the others. """ events: list[Event] = [] for monster in monsters: From 991e3ddbe0eef36c8b6fa57b4e148a9dc681998b Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 16:02:05 -0700 Subject: [PATCH 7/7] Remove the monster-tactics markers Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- tests/test_monster_tactics.py | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/tests/test_monster_tactics.py b/tests/test_monster_tactics.py index 5e597a8..f3363cc 100644 --- a/tests/test_monster_tactics.py +++ b/tests/test_monster_tactics.py @@ -58,8 +58,6 @@ from osrlib.data import load_classes, load_monsters from osrlib.persistence import replay_game, session_state -CHUNK = pytest.mark.xfail(reason="chunk: monster-tactics", strict=True) - HIT_CODES = frozenset({"combat.attack.hit", "combat.attack.auto_hit"}) ALWAYS_PASS = 20 @@ -285,7 +283,6 @@ def _until_spit_hits(game: GameSession, cobra_id: str, *, max_rounds: int = 30) class TestTheSpittingCobrasSpit: """A spit hit makes the target save versus poison or be blinded for good.""" - @CHUNK def test_the_catalog_gives_the_spit_an_ability_battle_applies(self): cobra = load_monsters().get("spitting_cobra") spit = next(attack for routine in cobra.attacks for attack in routine.attacks if attack.name == "spit") @@ -297,7 +294,6 @@ def test_the_catalog_gives_the_spit_an_ability_battle_applies(self): assert "A hit causes permanent blindness (save vs poison)." in blindness.prose assert cobra.ability("blinding_spit") is None, "one ability holds the spit, under the effect's tag" - @CHUNK def test_a_failed_save_blinds_for_good(self): game, hild, cobra = _cobra_battle(ALWAYS_FAIL) @@ -320,7 +316,6 @@ def test_a_failed_save_blinds_for_good(self): _play(game) assert has_condition(hild, Condition.BLIND) - @CHUNK def test_a_passed_save_leaves_the_target_seeing(self): game, hild, cobra = _cobra_battle(ALWAYS_PASS) @@ -331,7 +326,7 @@ def test_a_passed_save_leaves_the_target_seeing(self): assert not has_condition(hild, Condition.BLIND) def test_a_spit_that_misses_applies_nothing(self): - """A guard: a miss rolls no save and blinds no one, before and after the chunk.""" + """A miss rolls no save and blinds no one.""" game, hild, cobra = _cobra_battle(ALWAYS_FAIL) for _ in range(30): @@ -344,7 +339,6 @@ def test_a_spit_that_misses_applies_nothing(self): return pytest.fail("precondition: the cobra's spit missed within 30 rounds") - @CHUNK def test_a_member_the_spit_blinded_cannot_attack(self): """The SRD's combat rules: "A blind character is unable to attack." The refused round costs nothing.""" game, hild, cobra = _cobra_battle(ALWAYS_FAIL) @@ -376,7 +370,6 @@ def test_a_member_the_spit_blinded_cannot_attack(self): class TestGhoulsAttackOthers: """A ghoul or a thoul passes over a member it has paralysed while another target is in reach.""" - @CHUNK def test_the_catalog_marks_the_ghouls_and_the_thouls_paralysis(self): catalog = load_monsters() for template_id in PARALYSERS_THAT_ATTACK_OTHERS: @@ -394,7 +387,6 @@ def test_the_other_paralysers_have_no_such_clause(self): assert "attack others" not in paralysis.prose assert "attacks_others" not in paralysis.params - @CHUNK @pytest.mark.parametrize("template_id", PARALYSERS_THAT_ATTACK_OTHERS) def test_it_picks_a_member_who_isnt_paralysed(self, template_id): game = _battle(template_id) @@ -404,7 +396,6 @@ def test_it_picks_a_member_who_isnt_paralysed(self, template_id): assert set(_targets(game)) == {brakk.id} - @CHUNK @pytest.mark.parametrize("template_id", PARALYSERS_THAT_ATTACK_OTHERS) def test_it_picks_a_paralysed_member_before_a_statue(self, template_id): game = _battle(template_id) @@ -442,7 +433,6 @@ def test_a_paralyser_without_the_clause_still_attacks_a_paralysed_member(self, t assert set(_targets(game)) == {hild.id, brakk.id} - @CHUNK def test_the_policy_reads_the_param_and_not_the_monster(self): """A carcass crawler given `attacks_others` turns to others, so the rule is data, not a list of ids.""" game = _battle("carcass_crawler") @@ -461,7 +451,6 @@ def test_a_ghoul_without_the_param_attacks_a_paralysed_member(self): assert set(_targets(game)) == {hild.id, brakk.id} - @CHUNK @pytest.mark.parametrize("template_id", PARALYSERS_THAT_ATTACK_OTHERS) def test_in_battle_it_turns_from_the_member_it_paralysed(self, template_id): """The playtest's case: once a member is paralysed, the monster's attacks go to the member still standing.""" @@ -511,7 +500,6 @@ def test_its_routine_stays_on_one_target_within_a_round(self, template_id): class TestStatues: """A member turned to stone is out of the fight, so no monster picks one while another target is in reach.""" - @CHUNK @pytest.mark.parametrize("template_id", EVERY_KIND) def test_no_monster_picks_a_statue_while_another_target_is_in_reach(self, template_id): game = _battle(template_id) @@ -521,7 +509,6 @@ def test_no_monster_picks_a_statue_while_another_target_is_in_reach(self, templa assert set(_targets(game)) == {brakk.id} - @CHUNK def test_a_paralysed_member_comes_before_a_statue_for_any_monster(self): game = _battle("goblin") hild, brakk = game.party.members @@ -552,7 +539,6 @@ def test_a_front_rank_of_statues_is_attacked_as_before(self, template_id): assert actions[0].target_id == front[replica.randbelow(len(front))] assert stream.export_state() == replica.export_state() - @CHUNK def test_in_battle_a_basilisk_bites_the_member_still_standing(self): """The playtest's case: a basilisk kept biting members it had already turned to stone.""" game = _battle("basilisk") @@ -616,7 +602,6 @@ class TestNpcAdventurersAndStatues: to NPC adventurers. """ - @CHUNK def test_an_npc_never_picks_a_statue_while_another_target_is_in_reach(self): game = _npc_battle() hild, brakk = game.party.members @@ -702,7 +687,6 @@ def test_a_paralysed_member_changes_nothing_for_a_monster_without_the_clause(sel class TestABlindMemberMeetsNoGaze: """Someone who can't see can't meet a gaze, so a blind front-rank member is spared it in battle.""" - @CHUNK @pytest.mark.parametrize("template_id", ["basilisk", "medusa"]) def test_a_blind_front_rank_member_is_spared_the_gaze(self, template_id): game = _battle(template_id) @@ -774,7 +758,6 @@ def _opening( class TestTheSurpriseGaze: """Every member of a party a basilisk surprises meets its gaze once, as the encounter opens.""" - @CHUNK @pytest.mark.parametrize("stance", ENCOUNTER_STANCES) def test_it_comes_whatever_the_stance(self, stance): game, events = _opening("basilisk", stance) @@ -783,7 +766,6 @@ def test_it_comes_whatever_the_stance(self, stance): for member in game.party.members: assert len(_saves(events, member.id, "paralysis")) == 1, f"{member.id} meets the basilisk's gaze once" - @CHUNK def test_it_reaches_the_back_rank_in_melee_range_too(self): game, events = _opening("basilisk", ReactionResult.INDIFFERENT, distance_feet=5) @@ -791,7 +773,6 @@ def test_it_reaches_the_back_rank_in_melee_range_too(self): for member in game.party.members: assert len(_saves(events, member.id, "paralysis")) == 1 - @CHUNK def test_a_failed_save_turns_a_member_to_stone_outside_battle(self): game, events = _opening("basilisk", ReactionResult.INDIFFERENT, save_bonus=ALWAYS_FAIL) @@ -800,7 +781,6 @@ def test_a_failed_save_turns_a_member_to_stone_outside_battle(self): assert [save.code for save in _saves(events, member.id)] == ["combat.save.failed"] assert has_condition(member, Condition.PETRIFIED) - @CHUNK def test_engaging_later_brings_no_second_gaze(self): game, opening = _opening("basilisk", ReactionResult.INDIFFERENT) assert game.mode is SessionMode.ENCOUNTER, "precondition: no battle opened" @@ -815,7 +795,6 @@ def test_engaging_later_brings_no_second_gaze(self): assert not _saves(engage.events, member.id) assert not _saves(first_round, member.id) - @CHUNK def test_it_comes_when_the_basilisk_is_surprised_too(self): """The clause is about the characters, and a gaze is no action, so the basilisk's surprise changes nothing.""" game, events = _opening("basilisk", ReactionResult.INDIFFERENT, both_surprised=True) @@ -857,7 +836,6 @@ def test_a_medusa_that_surprises_the_party_doesnt_gaze_at_the_opening(self): for member in game.party.members: assert not _saves(events, member.id) - @CHUNK def test_a_blind_member_is_spared_the_surprise_gaze(self): """Someone who can't see can't meet a gaze.""" game, events = _opening("basilisk", ReactionResult.INDIFFERENT, blind=(0,)) @@ -886,7 +864,6 @@ def test_a_basilisk_that_joins_an_open_encounter_doesnt_gaze(self): assert engage.accepted assert not [event for event in engage.events if isinstance(event, SavingThrowRolledEvent)] - @CHUNK def test_the_playtests_spawn_meets_it_and_replays(self): """The playtest's case, by command: a referee spawn the basilisk surprises, which stays an encounter.""" for seed in SEEDS: