diff --git a/CHANGELOG.md b/CHANGELOG.md index 621ef19..11f9f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] has a new field [`replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id], so a member can make a fighting withdrawal or a retreat alone (#171). It names the back-rank member who steps up into the front rank in the withdrawing member's place. The new event [`MemberWithdrewEvent`][osrlib.crawl.events.MemberWithdrewEvent] reports each swap, with the code `battle.member.withdrew` for a fighting withdrawal and `battle.member.retreated` for a retreat. Its `character_id` is the member who stepped back and its `replacement_id` is the member who stepped up, so a battle screen can redraw the ranks from it. The field defaults to `None`, so a saved command without it still parses and [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] stays 4. - Each attack routine has a new field, [`AttackRoutine.use`][osrlib.core.monsters.AttackRoutine.use], that gives when the monster uses the routine. Its value is a [`RoutineUse`][osrlib.core.monsters.RoutineUse]. The first routine is `PRIMARY`. A later routine is `ALTERNATIVE` when the OSE SRD lets the monster use it in place of the first in any round, like a thoul's weapon. It's `SITUATIONAL` when the SRD ties it to a situation osrlib doesn't model, like an elephant's trample or a giant's thrown boulder. It's `SPECIAL` when it's a breath, a gaze, or a spell. Every routine in the built-in monster and spell catalogs has its label. A routine with no label is `PRIMARY`, so in a monster template of your own, label each later routine, or the default policy picks it as often as the first. - A policy of your own can choose which attack routine a monster uses, in the new field [`MonsterAction.routine`][osrlib.crawl.battle.MonsterAction.routine]. The field is an index into the template's `attacks`, and `None` uses the first routine. So your policy can have an elephant trample when your game decides it charges. Battle rolls the routine's attacks as melee attacks, and rolls nothing for a `SPECIAL` routine. An index the template doesn't have makes the action impossible, so the session skips it. +- [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] has a new field [`gaze_defense`][osrlib.crawl.commands.BattleDeclaration.gaze_defense], so a member can guard against a basilisk's or a medusa's petrifying gaze. Set it to `"avert_eyes"` or `"mirror"` with any action. The stance lasts the round it's declared in, whichever side acts first. A member who averts their eyes doesn't meet the gaze, but every attack roll they make that round takes a −4 penalty, and a basilisk or a medusa gains +2 to attack them. A member who fights by a mirror doesn't meet the gaze either, and every attack roll they make that round takes a −1 penalty. No monster gains a bonus against them. The member has to carry a `mirror`, or the session refuses the round with the new code `battle.declaration.no_mirror`. The penalties come from the basilisk's and the medusa's entries in the OSE SRD. A member who averts their eyes has the `averted_eyes` condition until the round ends. A stance makes no draw and emits no event. The basilisk's and the medusa's `averting_eyes` abilities are no longer `manual`. The field defaults to `None`, so a saved command without it still parses and [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] stays 4. - [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] reports a wand, staff, or rod use or a turning that fizzled in a battle round (#145). Its code is `magic.device.fizzled` for a device and `magic.turning.fizzled` for a turning. `actor_id` is the member who declared it, `item_id` is the device's instance id (`None` for a turning), and `reason` is the rejection code of the check that failed. Tell the player the action failed. A device that fizzles keeps its charge. ### Changed @@ -34,7 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - A party member can make a fighting withdrawal or a retreat alone, to pull a badly hurt character out of the front rank without the whole party leaving the fight (#171). Before, [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] refused a round in which only some of the members it expects to declare chose one of these moves, with `battle.declaration.formation_split`. That code is gone. A defensive move that every declarer makes still moves the whole party, as before. Any other is the declaring member's own. The member has to stand in the front rank, and steps back to trade places in the marching order with the member named in `replacement_id`. The party doesn't move and the battle goes on. The new order stays after the battle, until a [`ReorderParty`][osrlib.crawl.commands.ReorderParty] sets it again, and the session still refuses `ReorderParty` during a battle. Each rank stands 5 feet behind the one in front of it, and both members have to cover that distance. The withdrawing member covers it at up to half their encounter movement rate for a fighting withdrawal, or their full rate for a retreat. The replacement covers it at up to their full rate. *Haste* doubles either limit for the member who has it. The session refuses a member behind the front rank with `battle.declaration.not_in_front_rank`. It refuses a member who names no replacement, a replacement who can't step up, and a replacement too far back with three new codes: `battle.declaration.no_replacement`, `battle.declaration.replacement_unavailable`, and `battle.declaration.replacement_too_far`. The swap happens in the party's movement step, so monsters that win initiative strike at the ranks as they were declared. From the swap on, melee no longer reaches the member who stepped back. The replacement may declare a melee attack in the round they step up, and it resolves only if the swap happens. A member who retreats alone is easier to hit for the whole round: attackers get +2 against them and ignore their shield. A refused round costs no draw and no time, and a swap makes no draw. - An `attack` in a [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] with no `weapon_id` now uses the wielded weapon that suits the attack (#162). Before, the member struck unarmed, even with a sword in hand. The group's distance when the session accepts the round decides the attack: melee at 5 feet or less, missile beyond. The session takes the first wielded weapon, in the order the member equipped them, that can make that attack. For a missile attack it prefers the first whose long range reaches the group. A real weapon comes before an improvised one, like a torch, so a member who lit a torch and then drew a sword swings the sword. The torch still counts when the member wields nothing else that suits the attack. The session passes over a splash weapon, like a flask of oil, and an item with no weapon stats, like a wand. A member with no suitable weapon still strikes unarmed. The round plays out as if you had named the chosen weapon, and the command log records the declaration as you sent it. [`BattleDeclaration.weapon_id`][osrlib.crawl.commands.BattleDeclaration.weapon_id] describes the choice. A declaration that names a weapon works as before. - A wand, staff, or rod use and a turning declared in [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] are checked again in the magic phase, just before they resolve, as a spell cast and a scroll read are (#145). Before, they resolved even when the round had changed something they depend on. For example, a staff of striking struck a group that had already run out of melee reach. Now a declaration that fails the second check fizzles, and the session reports it with a [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent]. A device that fizzles was never used, so it keeps its charge, isn't identified, and doesn't break the user's invisibility. A turning that fizzles rolls nothing. A device use or turning that passes the second check resolves with the same rolls as before. -- Some command logs recorded under 2.0.1 don't replay under this release. A log that contains a command this release refuses makes [`replay_game`][osrlib.persistence.replay_game] raise [`ContentValidationError`][osrlib.errors.ContentValidationError]. Two commands that 2.0.1 accepted are refused now, so a log that contains one of them raises: a potion `UseItem` aimed at another member, and a temple service that would do nothing. Other logs replay without an error but play out differently. That happens with *sleep* cast at a drained monster, a *confusion* re-save, a protection scroll, or the 5 Hit Dice rule met with a drained monster, a *death spell* cast at a group, a lair the party left and came back to, a rest in a dungeon, a rest that a wandering monster interrupted, a trap save with a `modifier`, or a chute trap where every member passed the save. In battle, it also happens with a monster that regenerates (a troll, a thoul, or a vampire), a monster whose attack has an effect (like a ghoul's paralysing claw or a wight's level drain), a basilisk's or a medusa's gaze, a melee attack on a petrified creature, a monster that can choose between attack routines (a spitting cobra, a thoul, a mule, a minotaur, a wererat, or a monster of your own with more than one unlabelled routine), an attack declared with no `weapon_id` by a member who wields a weapon, or a device use (a wand, staff, or rod) or a turning that now fizzles. It also happens in a round in which the whole party retreats and a monster attacks a member before the party moves off or while the monster is confused. Each of these changes has an entry in this section or under Fixed. Loading a 2.0.1 save with [`load_game`][osrlib.persistence.load_game] isn't affected, because a load restores the saved state and runs no commands. The loaded game then plays on under this release's rules. +- Some command logs recorded under 2.0.1 don't replay under this release. A log that contains a command this release refuses makes [`replay_game`][osrlib.persistence.replay_game] raise [`ContentValidationError`][osrlib.errors.ContentValidationError]. Two commands that 2.0.1 accepted are refused now, so a log that contains one of them raises: a potion `UseItem` aimed at another member, and a temple service that would do nothing. Other logs replay without an error but play out differently. That happens with *sleep* cast at a drained monster, a *confusion* re-save, a protection scroll, or the 5 Hit Dice rule met with a drained monster, a *death spell* cast at a group, a lair the party left and came back to, a rest in a dungeon, a rest that a wandering monster interrupted, a trap save with a `modifier`, or a chute trap where every member passed the save. In battle, it also happens with a monster that regenerates (a troll, a thoul, or a vampire), a monster whose attack has an effect (like a ghoul's paralysing claw or a wight's level drain), a basilisk's or a medusa's gaze, including a basilisk that surprises the party, a melee attack on a petrified creature, a monster that can choose between attack routines (a spitting cobra, a thoul, a mule, a minotaur, a wererat, or a monster of your own with more than one unlabelled routine), an attack declared with no `weapon_id` by a member who wields a weapon, or a device use (a wand, staff, or rod) or a turning that now fizzles. It also happens in a round in which the whole party retreats and a monster attacks a member before the party moves off or while the monster is confused. Each of these changes has an entry in this section or under Fixed. Loading a 2.0.1 save with [`load_game`][osrlib.persistence.load_game] isn't affected, because a load restores the saved state and runs no commands. The loaded game then plays on under this release's rules. ### Fixed @@ -46,7 +47,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, but no command lets a member avert their eyes yet. So in play, every member in the front rank meets the gaze. 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. +- 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 declares the new `gaze_defense` field, described under Added, 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 saves versus paralysis, the save that 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. The basilisk's `surprise` ability is no longer `manual`, and its `params` have `meets_gaze` set to `true`. - Battle uses every attack routine a monster has. 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. The spitting cobra spits or bites, and its bite poisons. The thoul 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 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). diff --git a/docs/adaptations.md b/docs/adaptations.md index 32c9515..6e4b671 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -400,7 +400,13 @@ The policy draws only from the monster-action stream. Monsters with a scripted p 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. -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. `resolve_gaze` spares a creature with the `averted_eyes` condition, but no command gives a member that condition, so in play every member in the front rank meets the gaze. The SRD's penalties for fighting with averted eyes (−4 to the member's attacks and +2 to the basilisk's) aren't applied. A gazer that surprises the party from beyond 5' doesn't gaze in its free round, though the SRD says characters a basilisk surprises meet its gaze. Mirrors and the medusa's disguise are left to the referee. +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 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`. 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. diff --git a/docs/spec.md b/docs/spec.md index de3d8f4..e7ffeb8 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -151,7 +151,7 @@ The kernel owns time as a first-class concept; the framework decides when it pas - `core.clock` defines the units — the round (10 seconds), the turn (10 minutes), the day — and a `GameClock` that advances in those units and drives everything time-dependent. - `core.effects` is a lifecycle engine for anything with a duration or a periodic tick: spell effects, potion effects, poison and disease, burning light sources, and ongoing monster abilities like troll regeneration. An effect declares its duration, tick behavior, stacking rule, and expiry outcome; as the clock advances, the engine resolves ticks and expirations and emits events (`SpellExpired`, `TorchExpired`, `PoisonTick`). - Tick order is canonical: at each time-step boundary, expirations resolve first, then periodic ticks; simultaneous effects resolve in attachment order, tie-broken by effect ID. Whether troll regeneration or poison resolves first on the same round is defined, not incidental. -- The engine also owns named conditions, including indefinite and permanent states: paralysed/helpless, asleep, blind, charmed, petrified (recoverable — stone is not dead), diseased, exhausted, lycanthropy incubation, and the averted-eyes stance against gaze attacks (−4 to hit, +2 to be hit by the gazer; mirror counterplay stays `manual`-tagged prose). Conditions expose combat hooks (a helpless target is hit automatically and takes rolled damage; a sleeping creature dies to a blade) rather than being mere labels. +- The engine also owns named conditions, including indefinite and permanent states: paralysed/helpless, asleep, blind, charmed, petrified (recoverable — stone is not dead), diseased, exhausted, lycanthropy incubation, and the averted-eyes stance against gaze attacks (−4 to hit, +2 to be hit by the gazer, and a carried mirror's −1 as the alternative stance, while a gazer petrified by its own reflection stays `manual`-tagged prose). Conditions expose combat hooks (a helpless target is hit automatically and takes rolled damage; a sleeping creature dies to a blade) rather than being mere labels. - Effects attach to creatures, items, or locations: a burning oil pool or a *web* occupies cells and ticks on whatever enters or stands in them. - Consumables deplete through the same mechanism: lighting a torch attaches a 6-turn effect, a lantern flask burns for 24 turns, and rations and water are consumed daily. B/X leaves starvation and thirst to the referee, so consumption is tracked by default while deprivation penalties are a documented adaptation behind a `Ruleset` option. - The crawl layer advances the clock (movement, searching, and resting cost exploration turns; battles cost rounds). Kernel-only users drive the clock directly. diff --git a/src/osrlib/core/combat.py b/src/osrlib/core/combat.py index fca3f08..c037de5 100644 --- a/src/osrlib/core/combat.py +++ b/src/osrlib/core/combat.py @@ -3635,8 +3635,11 @@ def resolve_gaze( condition. A petrified creature isn't dead, and the effect can be ended. A creature that fights with its eyes averted takes a penalty to hit, which you apply through the `situational_modifier` of - its [`AttackContext`][osrlib.core.combat.AttackContext]. Using a mirror against the - gaze is left to the referee. + its [`AttackContext`][osrlib.core.combat.AttackContext], as you do the gazer's bonus + against it. This function doesn't model a mirror, so leave a creature that watches the + gazer in a mirror out of `engaged`. A battle in a + [`GameSession`][osrlib.crawl.session.GameSession] does all of this for you, from + [`BattleDeclaration.gaze_defense`][osrlib.crawl.commands.BattleDeclaration.gaze_defense]. Args: gazer: The gazing [`Creature`][osrlib.core.creature.Creature], usually a diff --git a/src/osrlib/core/effects.py b/src/osrlib/core/effects.py index da4bdf5..e3e7ba7 100644 --- a/src/osrlib/core/effects.py +++ b/src/osrlib/core/effects.py @@ -227,7 +227,10 @@ class Condition(StrEnum): AVERTED_EYES = "averted_eyes" """Fighting with eyes turned away from a gaze attack. [`resolve_gaze`][osrlib.core.combat.resolve_gaze] skips - the creature. Pass the attack penalty for fighting blind yourself, in the attack context.""" + the creature. Pass the attack penalty for fighting blind yourself, in the attack context. A battle in a + [`GameSession`][osrlib.crawl.session.GameSession] grants it for one round to a member who declares the + [`gaze_defense`][osrlib.crawl.commands.BattleDeclaration.gaze_defense] `avert_eyes`, and applies the + penalty and the gazer's bonus itself.""" POISONED = "poisoned" """Poisoned. The monsters that can't be poisoned, the undead and the cave locust among them, have it in diff --git a/src/osrlib/core/monsters.py b/src/osrlib/core/monsters.py index 77e453b..4e47037 100644 --- a/src/osrlib/core/monsters.py +++ b/src/osrlib/core/monsters.py @@ -694,7 +694,9 @@ class MonsterAbility(BaseModel): 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. + 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. """ name: str = Field(min_length=1) diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index aa86d24..9868814 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -77,8 +77,12 @@ 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, - but no command lets a member avert them yet. + [`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. - 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 @@ -225,7 +229,15 @@ ) from osrlib.core.creature import Creature from osrlib.core.dice import roll -from osrlib.core.effects import EFFECTS_STREAM, Condition, EffectDefinition, has_condition, kill +from osrlib.core.effects import ( + EFFECTS_STREAM, + Condition, + EffectDefinition, + grant_condition, + has_condition, + kill, + remove_condition, +) from osrlib.core.events import ( AttackRolledEvent, DeclarationFizzledEvent, @@ -2066,7 +2078,9 @@ def _resolve_striking(session, member, instance, declaration: BattleDeclaration) target = pool[0] template = magic_item_template(instance) stream = session.streams.get(COMBAT_STREAM) - context = AttackContext(distance_feet=MELEE_RANGE_FEET) + context = AttackContext( + distance_feet=MELEE_RANGE_FEET, situational_modifier=_gaze_stance_modifier(member, declaration) + ) rolled = attack_roll(member, target, instance, context=context, ruleset=session.ruleset, stream=stream) events = list(rolled.events) if rolled.hit and template.effect is not None: @@ -2132,12 +2146,19 @@ def _handle_resolve_battle_round(session, command: ResolveBattleRound) -> tuple[ front_rank = _projected_front_rank(session, swaps) for member, declaration in by_member.values(): rejections.extend(_validate_declaration(session, declaration, member, front_rank=front_rank)) + if declaration.gaze_defense == "mirror" and member.inventory.carried_item(_MIRROR_ITEM_ID) is None: + rejections.append(Rejection(code="battle.declaration.no_mirror", params={"character": member.id})) rejections.extend(withdrawal_rejections) if rejections: # The session rejects the whole command and lists every rejection. Partial # acceptance would complicate replay (see the adaptations register). return rejections, [] + # The gaze stances hold from here to the end of the round, whichever side acts first. + averting = _avert_eyes(by_member) + mirrored = frozenset( + member.id for member, declaration in by_member.values() if declaration.gaze_defense == "mirror" + ) state.round += 1 events: list[Event] = [BattleRoundEvent(round=state.round)] @@ -2234,6 +2255,7 @@ def monster_block() -> list[Event]: retreating=retreating, withdrawn=withdrawn, npc_actions=npc_actions, + mirrored=mirrored, ) blocks = (party_block, monster_block) if party_first else (monster_block, party_block) @@ -2262,6 +2284,7 @@ def monster_block() -> list[Event]: end = _check_ends(session, party_retreating=formation == "retreat") if end is not None: events.extend(end) + _end_averting(averting) return [], events @@ -2439,6 +2462,7 @@ def _resolve_party_attack(session, member, declaration, fired, fire_damaged) -> target = pool[0] # the first living, visible monster in the reachable rank context = AttackContext( distance_feet=group.distance_feet if missile else MELEE_RANGE_FEET, + situational_modifier=_gaze_stance_modifier(member, declaration), fired_last_round=member.id in state.fired_last_round, defender_retreating=group.fleeing, ) @@ -2607,7 +2631,11 @@ def _resolve_use_item(session, member, declaration, fire_damaged) -> list[Event] # combat facet (holy water, a flask of burning oil) before the round ran. return [] template = instance.template - context = AttackContext(distance_feet=group.distance_feet, lit=True) + context = AttackContext( + distance_feet=group.distance_feet, + situational_modifier=_gaze_stance_modifier(member, declaration), + lit=True, + ) result = resolve_splash_attack( member, pool[0], @@ -2854,9 +2882,10 @@ def _confused_party_overrides(session, by_member, fire_damaged) -> list[Event]: sends them at a fellow party member, and `no_action` leaves them babbling. """ events: list[Event] = [] - for member, _ in by_member.values(): + for member, declaration in by_member.values(): if incapacitated(member) or not has_condition(member, Condition.CONFUSED): continue + stance_modifier = _gaze_stance_modifier(member, declaration) confusion_effects = [ effect for effect in session.ledger.active_on(member.id) @@ -2891,7 +2920,7 @@ def _confused_party_overrides(session, by_member, fire_damaged) -> list[Event]: pool = _monster_pool(session, nearest) if pool: target = pool[session.streams.get(COMBAT_STREAM).randbelow(len(pool))] - context = AttackContext(distance_feet=MELEE_RANGE_FEET) + context = AttackContext(distance_feet=MELEE_RANGE_FEET, situational_modifier=stance_modifier) result = resolve_attack( member, target, @@ -2908,7 +2937,9 @@ def _confused_party_overrides(session, by_member, fire_damaged) -> list[Event]: if fellows: target = fellows[session.streams.get(COMBAT_STREAM).randbelow(len(fellows))] context = AttackContext( - distance_feet=MELEE_RANGE_FEET, defender_ally_ac_bonus=_ally_protection_bonus(session, target) + distance_feet=MELEE_RANGE_FEET, + situational_modifier=stance_modifier, + defender_ally_ac_bonus=_ally_protection_bonus(session, target), ) result = resolve_attack( member, @@ -2947,13 +2978,15 @@ def _monster_block( retreating: Collection[str] = frozenset(), withdrawn: Collection[str] = frozenset(), npc_actions: dict[str, list[MonsterAction]] | None = None, + mirrored: Collection[str] = frozenset(), ) -> list[Event]: """Resolve the monster side's block: morale, flight, gazes, and the actions each group's policy chose. `retreating` names the party members a retreat exposes this round (see `_retreating_members`), and every attack against one of them gets the retreat's penalties. `withdrawn` names the members who stepped back out of the front rank earlier in the round. A melee attack chosen against one of - them is skipped, because melee no longer reaches them. + them is skipped, because melee no longer reaches them. `mirrored` names the members who fight by + a mirror this round, whom a petrifying gaze passes by (see `_resolve_gazes`). """ from osrlib.crawl.session import MONSTER_ACTION_STREAM @@ -2979,7 +3012,7 @@ def _monster_block( if group.distance_feet > FLEE_EXIT_FEET: group.fled = True continue - events.extend(_resolve_gazes(session, group)) + events.extend(_resolve_gazes(session, group, free_round=free_round, mirrored=mirrored)) _watch_disruption(events, pending_casters, disrupted, acted) if npc_actions is not None and group.id in npc_actions: actions = npc_actions[group.id] @@ -3385,6 +3418,7 @@ def _resolve_monster_melee(session, monster, target, *, retreating: bool, routin continue context = AttackContext( distance_feet=MELEE_RANGE_FEET, + situational_modifier=_gazer_bonus(monster, target), defender_retreating=retreating, defender_ally_ac_bonus=_ally_protection_bonus(session, target), ) @@ -3559,24 +3593,40 @@ def _petrifying_gaze(monster) -> MonsterAbility | None: return None -def _resolve_gazes(session, group) -> list[Event]: - """Turn each gazer's petrifying gaze on the party's front rank, once a round, while its group is in melee. +def _surprise_gaze(monster) -> bool: + """Whether characters the monster surprises meet its gaze, as the basilisk's `surprise` ability says.""" + template = getattr(monster, "template", None) + ability = template.ability("surprise") if template is not None else None + return ability is not None and not ability.manual and bool(ability.params.get("meets_gaze")) + + +def _resolve_gazes(session, group, *, free_round: bool, mirrored: Collection[str]) -> list[Event]: + """Turn each gazer's petrifying gaze on the party, once a round. 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. `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. + 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. """ - if group.distance_feet > MELEE_RANGE_FEET: - return [] + in_melee = group.distance_feet <= MELEE_RANGE_FEET 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: + continue events.extend( resolve_gaze( monster, - _party_front_rank(session), + engaged, stream=session.streams.get(COMBAT_STREAM), ledger=session.ledger, clock=session.clock, @@ -3587,6 +3637,64 @@ def _resolve_gazes(session, group) -> list[Event]: return events +_AVERT_EYES_PENALTY = -4 +"""The attack roll penalty for fighting with averted eyes, the basilisk's and the medusa's "-4 penalty to-hit".""" + +_GAZER_BONUS_AGAINST_AVERTED = 2 +"""A gazer's attack bonus against a creature averting its eyes: "the basilisk gains a +2 bonus to attack".""" + +_MIRROR_PENALTY = -1 +"""The attack roll penalty for fighting by a mirror: "Fighting by looking in a mirror incurs a -1 penalty to attack".""" + +_MIRROR_ITEM_ID = "mirror" +"""The equipment catalog's mirror, which a member has to carry to fight by one.""" + + +def _avert_eyes(by_member) -> list: + """Give each member who declared `avert_eyes` the `averted_eyes` condition, and return those members. + + The condition has no effect behind it, so nothing draws and the grant's event is dropped: the stance + emits nothing of its own. A member who already has the same record, which only a host can give, isn't + returned, so `_end_averting` leaves that record alone. + """ + return [ + member + for member, declaration in by_member.values() + if declaration.gaze_defense == "avert_eyes" and grant_condition(member, Condition.AVERTED_EYES, None) + ] + + +def _end_averting(averting) -> None: + """Take back the `averted_eyes` condition `_avert_eyes` gave, at the end of the round, again without an event.""" + for member in averting: + remove_condition(member, Condition.AVERTED_EYES, None) + + +def _gaze_stance_modifier(member, declaration: BattleDeclaration) -> int: + """The penalty a member's gaze stance puts on every attack roll they make this round. + + Averting the eyes costs −4 and fighting by a mirror costs −1, whatever the attack is aimed at, + because the SRD gives both penalties without naming a target. + """ + if has_condition(member, Condition.AVERTED_EYES): + return _AVERT_EYES_PENALTY + if declaration.gaze_defense == "mirror": + return _MIRROR_PENALTY + return 0 + + +def _gazer_bonus(monster, target) -> int: + """A gazer's bonus to attack a target averting its eyes, from the monster's `averting_eyes` ability. + + Only a monster whose entry gives the bonus gains it, so an ogre beside a basilisk gains nothing. + """ + template = getattr(monster, "template", None) + ability = template.ability("averting_eyes") if template is not None else None + if ability is None or ability.manual or not has_condition(target, Condition.AVERTED_EYES): + return 0 + return _GAZER_BONUS_AGAINST_AVERTED + + def _resolve_breath(session, monster, group) -> list[Event]: """A breath weapon against the party, resolved through the deterministic rank-coverage footprint.""" params = monster.template.ability("breath_weapon").params diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 826c66b..d915060 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2425,6 +2425,9 @@ class BattleDeclaration(BaseModel): - `hold` does nothing. A member with no legal action declares it, because every member in `declarers` needs a declaration. + `gaze_defense` goes with any action. It guards the member against a petrifying gaze, like + a basilisk's or a medusa's, for this round only. + For a fight that opens and runs with declared attacks until it ends, see the [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] example. """ @@ -2562,6 +2565,38 @@ class BattleDeclaration(BaseModel): scroll, a wand, a staff, or a rod, or a mundane item's catalog id for an item thrown at a group, like a flask of oil. The session refuses a mundane item with no combat use, or a magic item that can't be used in battle, with `battle.declaration.item_unusable`.""" + gaze_defense: Literal["avert_eyes", "mirror"] | None = None + """How this member guards against a petrifying gaze this round, or `None` to fight looking + straight at the monsters. + + A petrifying gaze reaches each member of the party's front rank once a round while the + gazer's group is in melee, and each member it reaches saves versus paralysis or turns to + stone. The OSE SRD's basilisk entry says "Unless averting eyes or using a mirror, characters + in melee are affected each round", and this field picks one of the two. + + - `avert_eyes` gives the member the `averted_eyes` condition for the round, so the gaze + passes them by. The basilisk's and the medusa's entries set the cost: "-4 penalty + to-hit; the basilisk gains a +2 bonus to attack". Every attack roll the member makes this + round takes −4, whatever it's aimed at, because the SRD gives the penalty without naming a + target. Each attack a gazing monster makes on the member this round gains +2. The SRD + names the gazer for that bonus, so other monsters' attacks gain nothing. + - `mirror` has the member fight by watching a mirror, so the gaze passes them by too. The + basilisk's entry says "The reflection of a basilisk is harmless. Fighting by looking in a + mirror incurs a -1 penalty to attack." Every attack roll the member makes this round takes + −1, whatever it's aimed at, and no monster gains a bonus. The member has to carry a + `mirror` from the equipment catalog, or the session refuses the declaration with + `battle.declaration.no_mirror`. The SRD's rule that a gazer that sees its own reflection + saves or turns to stone is left to the referee. + + The stance goes with any `action`, and it lasts from the moment the session accepts the + 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. + [The adaptations register](https://mmacy.github.io/osrlib-python/adaptations/) describes the + gaze in full.""" class ResolveBattleRound(Command): @@ -2626,6 +2661,9 @@ class ResolveBattleRound(Command): `magic.cast.out_of_range`. - Turn-undead declarations: `magic.turning.not_a_turner`, `magic.turning.caster_incapacitated`. + - `battle.declaration.no_mirror` - a member who carries no mirror declares a + `gaze_defense` of `mirror`, with any action. The rejection has the member's id + as its `character` param. - Item declarations: `battle.declaration.item_unusable`, `battle.declaration.unknown_group`, `battle.declaration.no_target`, `items.use.not_usable`, `items.device.inert`, `items.scroll.spent`, diff --git a/src/osrlib/data/monsters.json b/src/osrlib/data/monsters.json index 9ca8dcf..12b4e2f 100644 --- a/src/osrlib/data/monsters.json +++ b/src/osrlib/data/monsters.json @@ -1039,9 +1039,11 @@ { "abilities": [ { - "manual": true, + "manual": false, "name": "Surprise", - "params": {}, + "params": { + "meets_gaze": true + }, "prose": "Characters surprised by a basilisk meet its gaze.", "tag": "surprise" }, @@ -1064,7 +1066,7 @@ "tag": "petrification" }, { - "manual": true, + "manual": false, "name": "Averting eyes", "params": {}, "prose": "-4 penalty to-hit; the basilisk gains a +2 bonus to attack.", @@ -17115,7 +17117,7 @@ "tag": "petrification" }, { - "manual": true, + "manual": false, "name": "Averting eyes", "params": {}, "prose": "-4 penalty to-hit; the medusa gains a +2 bonus to attack.", diff --git a/tests/goldens/phase5_milestone.json b/tests/goldens/phase5_milestone.json index 66d4cc4..ac38e92 100644 --- a/tests/goldens/phase5_milestone.json +++ b/tests/goldens/phase5_milestone.json @@ -35,6 +35,7 @@ { "action": "move", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -48,6 +49,7 @@ { "action": "move", "character_id": "character-0002", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -61,6 +63,7 @@ { "action": "move", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -74,6 +77,7 @@ { "action": "move", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -93,6 +97,7 @@ { "action": "attack", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -106,6 +111,7 @@ { "action": "attack", "character_id": "character-0002", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -119,6 +125,7 @@ { "action": "hold", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -132,6 +139,7 @@ { "action": "hold", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -151,6 +159,7 @@ { "action": "attack", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -164,6 +173,7 @@ { "action": "attack", "character_id": "character-0002", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -177,6 +187,7 @@ { "action": "hold", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -190,6 +201,7 @@ { "action": "hold", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -209,6 +221,7 @@ { "action": "attack", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -222,6 +235,7 @@ { "action": "attack", "character_id": "character-0002", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -235,6 +249,7 @@ { "action": "hold", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -248,6 +263,7 @@ { "action": "hold", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -267,6 +283,7 @@ { "action": "move", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -280,6 +297,7 @@ { "action": "move", "character_id": "character-0002", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -293,6 +311,7 @@ { "action": "move", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -306,6 +325,7 @@ { "action": "move", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -325,6 +345,7 @@ { "action": "move", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -338,6 +359,7 @@ { "action": "move", "character_id": "character-0002", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -351,6 +373,7 @@ { "action": "move", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -364,6 +387,7 @@ { "action": "move", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -423,6 +447,7 @@ { "action": "move", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -436,6 +461,7 @@ { "action": "move", "character_id": "character-0002", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -449,6 +475,7 @@ { "action": "move", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -462,6 +489,7 @@ { "action": "move", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -481,6 +509,7 @@ { "action": "attack", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -494,6 +523,7 @@ { "action": "attack", "character_id": "character-0002", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -507,6 +537,7 @@ { "action": "hold", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -520,6 +551,7 @@ { "action": "hold", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -539,6 +571,7 @@ { "action": "attack", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -552,6 +585,7 @@ { "action": "attack", "character_id": "character-0002", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -565,6 +599,7 @@ { "action": "hold", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -578,6 +613,7 @@ { "action": "hold", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -597,6 +633,7 @@ { "action": "attack", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -610,6 +647,7 @@ { "action": "attack", "character_id": "character-0002", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -623,6 +661,7 @@ { "action": "hold", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -636,6 +675,7 @@ { "action": "hold", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -661,6 +701,7 @@ { "action": "move", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -674,6 +715,7 @@ { "action": "move", "character_id": "character-0002", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -687,6 +729,7 @@ { "action": "move", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -700,6 +743,7 @@ { "action": "move", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -719,6 +763,7 @@ { "action": "attack", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -732,6 +777,7 @@ { "action": "attack", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -745,6 +791,7 @@ { "action": "hold", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -764,6 +811,7 @@ { "action": "attack", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -777,6 +825,7 @@ { "action": "attack", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -790,6 +839,7 @@ { "action": "hold", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -809,6 +859,7 @@ { "action": "attack", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -822,6 +873,7 @@ { "action": "attack", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -835,6 +887,7 @@ { "action": "hold", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": null, "replacement_id": null, @@ -854,6 +907,7 @@ { "action": "move", "character_id": "character-0001", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -867,6 +921,7 @@ { "action": "move", "character_id": "character-0003", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, @@ -880,6 +935,7 @@ { "action": "move", "character_id": "character-0004", + "gaze_defense": null, "item_id": null, "move": "close", "replacement_id": null, diff --git a/tests/test_avert_eyes.py b/tests/test_avert_eyes.py new file mode 100644 index 0000000..0fe88bd --- /dev/null +++ b/tests/test_avert_eyes.py @@ -0,0 +1,636 @@ +"""Acceptance tests for guarding against a petrifying gaze in battle: averting the eyes, a mirror, and surprise. + +Since #180 the session runs the basilisk's and the medusa's gaze once a round against the party's front rank, and +`resolve_gaze` spares a creature with the `averted_eyes` condition. No command gave a member that condition, so every +member in melee met the gaze every round. These tests pin the contract that closes the gap, from the OSE SRD's +basilisk and medusa entries. + +**The stance.** "Unless averting eyes or using a mirror, characters in melee are affected each round." A member's +`BattleDeclaration.gaze_defense` is `avert_eyes`, `mirror`, or `None`. It goes with any action and lasts the round +it's declared in: from the moment the round is accepted, so it holds whichever side acts first, until the round ends. +Neither stance draws anything or emits an event of its own, and a round with no stance draws exactly as before. + +**The penalties.** "Averting eyes: -4 penalty to-hit; the basilisk gains a +2 bonus to attack." The −4 applies to +every attack roll the averting member makes that round, whatever it's aimed at, because the SRD gives it without +naming a target. The +2 applies only to a gazer's attacks on the averting member, because the SRD names the gazer +for it. Other monsters gain nothing. + +**The mirror.** "The reflection of a basilisk is harmless. Fighting by looking in a mirror incurs a -1 penalty to +attack." The equipment catalog has a `mirror`, so a member who carries one may fight by it: the gaze passes them by, +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. + +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 +stance and one without, and check the difference in each attack's `modifier`, which no draw affects. Each test +asserts its own preconditions, so a test that stops exercising its case fails loudly. +""" + +import pytest +from pydantic import TypeAdapter, ValidationError + +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, InitiativeRolledEvent, SavingThrowRolledEvent +from osrlib.core.ruleset import Ruleset +from osrlib.core.spells import MemorizedSpell +from osrlib.core.tables import ReactionResult +from osrlib.crawl import encounter as encounter_module +from osrlib.crawl.adventure import Adventure, TownSpec +from osrlib.crawl.commands import ( + AnyCommand, + BattleDeclaration, + EngageBattle, + EnterDungeon, + EquipItem, + GrantItem, + ResolveBattleRound, + SessionMode, + SpawnMonsters, +) +from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec +from osrlib.crawl.party import Party +from osrlib.crawl.session import GameSession +from osrlib.data import load_classes +from osrlib.persistence import replay_game, session_state + +ALWAYS_PASS = 20 +"""A save bonus no d20 can fail with.""" + +ALWAYS_FAIL = -20 +"""A save penalty no d20 can pass with.""" + +PARALYSIS = "paralysis" +"""The save category of the gaze and of the basilisk's petrifying bite.""" + +SEEDS = range(1, 80) +"""Seeds the helpers scan for a setup they need, like an encounter neither side is surprised in.""" + + +# ---------------------------------------------------------------------- setup + + +def _character(name: str, class_id: str = "fighter", *, level: int = 3, hp: int = 400, **fields) -> 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, + **fields, + ) + + +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], *, grants=(), equips=()) -> GameSession: + """A session in the corridor, after `grants` and `equips` as `(member index, item id)` pairs, all by command.""" + game = GameSession.new(Party(members=members), _adventure(), seed=seed) + for index, item_id in grants: + assert game.execute(GrantItem(character_id=game.party.members[index].id, item_id=item_id)).accepted + for index, item_id in equips: + assert game.execute(EquipItem(character_id=game.party.members[index].id, item_id=item_id)).accepted + assert game.execute(EnterDungeon(dungeon_id="crypt")).accepted + return game + + +def _force_saves(game: GameSession, bonus: int) -> None: + """Make every save each party member rolls pass (+20) or fail (−20).""" + for member in game.party.members: + definition = EffectDefinition(kind="test_save_ward", modifiers=(ModifierSpec(kind="save_bonus", value=bonus),)) + effect, _ = game.ledger.attach( + definition, member.id, clock=game.clock, allocator=game.allocator, registry=game.registry() + ) + assert effect is not None + + +def _battle( + template_id: str, + names: tuple[str, ...] = ("Hild",), + *, + distance_feet: int = 5, + grants=(), + equips=(), + save_bonus: int | None = ALWAYS_PASS, + first_seed: int = SEEDS.start, + members: list[Character] | None = None, +) -> GameSession: + """A battle against one monster that neither side was surprised in, built by command. + + The helper takes the first seed from `first_seed` on that gives such a battle, so two calls with the same + arguments give the same session, and a test can play one with a stance and one without. + """ + for seed in range(first_seed, SEEDS.stop): + roster = [member.model_copy(deep=True) for member in members] if members else _members(*names) + game = _session(seed, roster, grants=grants, equips=equips) + 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 + if save_bonus is not None: + _force_saves(game, save_bonus) + return game + raise AssertionError(f"precondition: no seed opened a {template_id} battle with neither side surprised") + + +def _group_id(game: GameSession) -> str: + return game.encounter.groups[0].id + + +def _monster_ids(game: GameSession) -> set[str]: + return {monster_id for group in game.encounter.groups for monster_id in group.monster_ids} + + +def _declare(member_id: str, action: str = "hold", stance: str | None = None, **fields) -> BattleDeclaration: + return BattleDeclaration(character_id=member_id, action=action, gaze_defense=stance, **fields) + + +def _play(game: GameSession, *declared: BattleDeclaration) -> list: + """Resolve one round: the members in `declared` as given, every other able member holding with no stance.""" + 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, _declare(member.id)) for member in able) + assert set(by_id) <= {member.id for member in able}, "precondition: every declared member can act" + result = game.execute(ResolveBattleRound(declarations=declarations)) + assert result.accepted, [rejection.code for rejection in result.rejections] + return list(result.events) + + +def _saves(events, target_id: str, category: str | None = PARALYSIS) -> 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 _petrifying_bite_hits(events, defender_id: str) -> int: + """The basilisk's bites that hit the defender, each of which adds a save versus paralysis of its own.""" + return sum( + 1 + for event in events + if isinstance(event, AttackRolledEvent) + and event.defender_id == defender_id + and event.attack_name == "bite" + and event.code in ("combat.attack.hit", "combat.attack.auto_hit") + ) + + +def _gaze_saves(events, member_id: str) -> int: + """How many times the member met a gaze: saves versus paralysis less the petrifying bites that hit them.""" + return len(_saves(events, member_id)) - _petrifying_bite_hits(events, member_id) + + +def _modifiers(events, *, attacker_ids, defender_ids) -> list[int]: + return [ + event.modifier + for event in events + if isinstance(event, AttackRolledEvent) + and event.attacker_id in attacker_ids + and event.defender_id in defender_ids + and event.code != "combat.attack.auto_hit" + ] + + +def _party_acted_first(game: GameSession, events) -> bool: + initiative = next(event for event in events if isinstance(event, InitiativeRolledEvent)) + return initiative.order[0] in {member.id for member in game.party.members} + + +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 _stream_states(game: GameSession) -> dict: + return {key: state.model_dump(mode="json") for key, state in game.streams.export_states().items()} + + +# ---------------------------------------------------------------------- the field + + +class TestTheField: + """The declaration's new field. These pass before and after the chunk, because the stub ships the field.""" + + def test_it_defaults_to_no_stance(self): + assert BattleDeclaration(character_id="character-0001", action="hold").gaze_defense is None + + @pytest.mark.parametrize("stance", ["avert_eyes", "mirror"]) + def test_it_takes_either_stance_with_any_action(self, stance): + for action in ("attack", "cast", "turn_undead", "move", "use_item", "hold"): + assert _declare("character-0001", action, stance).gaze_defense == stance + + def test_it_refuses_anything_else_when_the_declaration_is_built(self): + with pytest.raises(ValidationError): + BattleDeclaration(character_id="character-0001", action="hold", gaze_defense="squint") + + def test_a_round_with_a_stance_round_trips_through_its_json(self): + command = ResolveBattleRound(declarations=(_declare("character-0001", "hold", "avert_eyes"),)) + adapter = TypeAdapter(AnyCommand) + restored = adapter.validate_python(command.model_dump(mode="json")) + assert restored == command + assert restored.declarations[0].gaze_defense == "avert_eyes" + + +# ---------------------------------------------------------------------- averting spares the gaze + + +class TestAStanceSparesTheGaze: + """A member who averts their eyes, or fights by a mirror, never meets the gaze in that round.""" + + @pytest.mark.parametrize("template_id", ["basilisk", "medusa"]) + def test_an_averting_member_never_meets_the_gaze_whichever_side_acts_first(self, template_id): + game = _battle(template_id, ("Hild", "Brakk")) + hild, brakk = game.party.members + orders = set() + + for _ in range(6): + assert game.encounter.groups[0].distance_feet == 5, "precondition: the gazer is in melee" + events = _play(game, _declare(hild.id, "hold", "avert_eyes")) + orders.add(_party_acted_first(game, events)) + assert _gaze_saves(events, brakk.id) == 1, "precondition: the gaze ran this round" + assert _gaze_saves(events, hild.id) == 0, "the averting member met the gaze" + assert not has_condition(hild, Condition.PETRIFIED) + + assert orders == {True, False}, "precondition: both sides acted first in some round" + + def test_a_failed_save_petrifies_the_one_who_looked_and_not_the_one_who_averted(self): + game = _battle("medusa", ("Hild", "Brakk"), save_bonus=ALWAYS_FAIL) + hild, brakk = game.party.members + + events = _play(game, _declare(hild.id, "hold", "avert_eyes")) + + assert _saves(events, brakk.id), "precondition: the gaze reached the member who looked" + assert has_condition(brakk, Condition.PETRIFIED) + assert not _saves(events, hild.id) + assert not has_condition(hild, Condition.PETRIFIED) + + def test_a_mirror_spares_the_gaze(self): + game = _battle("medusa", ("Hild", "Brakk"), grants=((0, "mirror"),), save_bonus=ALWAYS_FAIL) + hild, brakk = game.party.members + assert hild.inventory.carried_item("mirror") is not None, "precondition: Hild carries a mirror" + + for round_number in range(1, 4): + events = _play(game, _declare(hild.id, "hold", "mirror")) + if round_number == 1: + assert _saves(events, brakk.id), "precondition: the gaze reached the member who looked" + assert has_condition(brakk, Condition.PETRIFIED) + assert not _saves(events, hild.id), f"the mirror didn't spare Hild in round {round_number}" + assert not has_condition(hild, Condition.PETRIFIED) + + @pytest.mark.parametrize("stance", ["avert_eyes", "mirror"]) + def test_the_stance_lasts_one_round(self, stance): + game = _battle("medusa", ("Hild", "Brakk"), grants=((0, "mirror"),)) + hild, brakk = game.party.members + + guarded = _play(game, _declare(hild.id, "hold", stance)) + assert _gaze_saves(guarded, brakk.id) == 1, "precondition: the gaze ran in the guarded round" + assert _gaze_saves(guarded, hild.id) == 0 + assert not has_condition(hild, Condition.AVERTED_EYES), "the stance ends with its round" + + exposed = _play(game) + assert _gaze_saves(exposed, brakk.id) == 1, "precondition: the gaze ran in the next round" + assert _gaze_saves(exposed, hild.id) == 1, "with no stance declared, the member meets the gaze again" + + @pytest.mark.parametrize("action", ["attack", "cast", "use_item", "turn_undead", "hold", "move"]) + def test_the_stance_goes_with_every_action(self, action): + hild = _character("Hild", "cleric", memorized_spells=(MemorizedSpell(spell_id="cure_light_wounds"),)) + for seed in SEEDS: + game = _battle( + "medusa", grants=((0, "potion_of_healing"),), first_seed=seed, members=[hild, _character("Brakk")] + ) + hild_id, brakk_id = (member.id for member in game.party.members) + fields = { + "attack": {"target_group_id": _group_id(game)}, + "cast": {"spell_id": "cure_light_wounds", "spell_mode": "heal", "targets": (hild_id,)}, + "use_item": {"item_id": game.party.members[0].inventory.carried_item("potion_of_healing").instance_id}, + "turn_undead": {}, + "hold": {}, + "move": {"move": "fighting_withdrawal"}, + }[action] + others = [_declare(brakk_id, "move", move="fighting_withdrawal")] if action == "move" else [] + events = _play(game, _declare(hild_id, action, "avert_eyes", **fields), *others) + if _gaze_saves(events, brakk_id) != 1: + # The party withdrew out of reach before the medusa acted. Try another seed. + assert action == "move", "precondition: the gaze ran this round" + continue + assert _gaze_saves(events, hild_id) == 0, f"averting with {action} met the gaze" + return + raise AssertionError("precondition: no seed let the medusa act before the party withdrew") + + +# ---------------------------------------------------------------------- not averting is unchanged + + +class TestNotAvertingIsUnchanged: + """A member with no stance meets the gaze and keeps their numbers, whatever the members beside them do.""" + + def test_a_looker_beside_an_averter_still_meets_the_gaze(self): + game = _battle("basilisk", ("Hild", "Brakk")) + hild, brakk = game.party.members + + for _ in range(3): + events = _play(game, _declare(hild.id, "hold", "avert_eyes")) + assert _gaze_saves(events, hild.id) == 0 + assert _gaze_saves(events, brakk.id) == 1 + + def test_a_looker_beside_an_averter_keeps_their_attack_numbers(self): + """A guard: the averter's stance changes nothing about the member beside them, or about attacks on them.""" + plain = _battle("basilisk", ("Hild", "Brakk")) + guarded = _battle("basilisk", ("Hild", "Brakk")) + hild, brakk = (member.id for member in plain.party.members) + basilisk = _monster_ids(plain) + + plain_events, guarded_events = [], [] + for _ in range(4): + attack = _declare(brakk, "attack", target_group_id=_group_id(plain)) + plain_events += _play(plain, attack) + guarded_events += _play(guarded, attack, _declare(hild, "hold", "avert_eyes")) + + plain_on_brakk = _modifiers(plain_events, attacker_ids=basilisk, defender_ids={brakk}) + assert plain_on_brakk, "precondition: the basilisk attacked Brakk" + assert _modifiers(guarded_events, attacker_ids=basilisk, defender_ids={brakk}) == plain_on_brakk + plain_by_brakk = _modifiers(plain_events, attacker_ids={brakk}, defender_ids=basilisk) + assert len(plain_by_brakk) == 4, "precondition: Brakk attacked every round" + assert _modifiers(guarded_events, attacker_ids={brakk}, defender_ids=basilisk) == plain_by_brakk + + +# ---------------------------------------------------------------------- the penalties + + +def _paired(template_id: str, *, distance_feet: int = 5, grants=(), equips=()) -> tuple[GameSession, GameSession]: + """Two identical one-member battles, one to play with a stance and one without.""" + sessions = tuple(_battle(template_id, distance_feet=distance_feet, grants=grants, equips=equips) for _ in range(2)) + assert _state_without_logs(sessions[0]) == _state_without_logs(sessions[1]), "precondition: identical setups" + return sessions + + +class TestThePenalties: + """Averting costs −4 on every attack roll and gives a gazer +2 against the averter. A mirror costs −1.""" + + def test_averting_costs_minus_four_and_the_gazer_gains_plus_two_for_that_round(self): + plain, guarded = _paired("basilisk") + hild = plain.party.members[0].id + basilisk = _monster_ids(plain) + attack = _declare(hild, "attack", target_group_id=_group_id(plain)) + + plain_first = _play(plain, attack) + guarded_first = _play(guarded, _declare(hild, "attack", "avert_eyes", target_group_id=_group_id(guarded))) + + mine = _modifiers(plain_first, attacker_ids={hild}, defender_ids=basilisk) + theirs = _modifiers(plain_first, attacker_ids=basilisk, defender_ids={hild}) + assert len(mine) == 1 and len(theirs) == 1, "precondition: Hild and the basilisk each attacked once" + assert _modifiers(guarded_first, attacker_ids={hild}, defender_ids=basilisk) == [mine[0] - 4] + assert _modifiers(guarded_first, attacker_ids=basilisk, defender_ids={hild}) == [theirs[0] + 2] + + # The next round has no stance, and the numbers are back. + plain_next = _play(plain, attack) + guarded_next = _play(guarded, attack) + assert _modifiers(guarded_next, attacker_ids={hild}, defender_ids=basilisk) == _modifiers( + plain_next, attacker_ids={hild}, defender_ids=basilisk + ) + assert _modifiers(guarded_next, attacker_ids=basilisk, defender_ids={hild}) == _modifiers( + plain_next, attacker_ids=basilisk, defender_ids={hild} + ) + + def test_the_minus_four_applies_to_an_attack_on_any_monster_and_no_other_monster_gains(self): + plain, guarded = _paired("ogre") + hild = plain.party.members[0].id + ogre = _monster_ids(plain) + + plain_events = _play(plain, _declare(hild, "attack", target_group_id=_group_id(plain))) + guarded_events = _play(guarded, _declare(hild, "attack", "avert_eyes", target_group_id=_group_id(guarded))) + + mine = _modifiers(plain_events, attacker_ids={hild}, defender_ids=ogre) + theirs = _modifiers(plain_events, attacker_ids=ogre, defender_ids={hild}) + assert len(mine) == 1 and theirs, "precondition: Hild and the ogre each attacked" + assert _modifiers(guarded_events, attacker_ids={hild}, defender_ids=ogre) == [mine[0] - 4] + assert _modifiers(guarded_events, attacker_ids=ogre, defender_ids={hild}) == theirs, "only a gazer gains +2" + + def test_the_minus_four_applies_to_a_missile_attack(self): + plain, guarded = _paired("ogre", distance_feet=60, grants=((0, "short_bow"),), equips=((0, "short_bow"),)) + hild = plain.party.members[0].id + ogre = _monster_ids(plain) + + plain_events = _play(plain, _declare(hild, "attack", target_group_id=_group_id(plain))) + guarded_events = _play(guarded, _declare(hild, "attack", "avert_eyes", target_group_id=_group_id(guarded))) + + shots = [event for event in plain_events if isinstance(event, AttackRolledEvent) and event.attacker_id == hild] + assert len(shots) == 1 and shots[0].attack_name == "Short bow", "precondition: Hild shot her bow" + assert _modifiers(guarded_events, attacker_ids={hild}, defender_ids=ogre) == [shots[0].modifier - 4] + + def test_a_mirror_costs_minus_one_and_gives_the_gazer_nothing(self): + plain, guarded = _paired("basilisk", grants=((0, "mirror"),)) + hild = plain.party.members[0].id + basilisk = _monster_ids(plain) + + plain_events = _play(plain, _declare(hild, "attack", target_group_id=_group_id(plain))) + guarded_events = _play(guarded, _declare(hild, "attack", "mirror", target_group_id=_group_id(guarded))) + + mine = _modifiers(plain_events, attacker_ids={hild}, defender_ids=basilisk) + theirs = _modifiers(plain_events, attacker_ids=basilisk, defender_ids={hild}) + assert len(mine) == 1 and len(theirs) == 1, "precondition: Hild and the basilisk each attacked once" + assert _modifiers(guarded_events, attacker_ids={hild}, defender_ids=basilisk) == [mine[0] - 1] + assert _modifiers(guarded_events, attacker_ids=basilisk, defender_ids={hild}) == theirs + + +# ---------------------------------------------------------------------- surprise + + +def _surprised( + template_id: str, distance_feet: int, names=("Hild", "Brakk", "Sable"), *, save_bonus: int = ALWAYS_PASS +) -> tuple[GameSession, list]: + """A party surprised by one attacking monster, and the opening events, which hold the monster's free round. + + The party's front rank is Hild and Brakk. Sable is in the back rank. + """ + for seed in SEEDS: + game = _session(seed, _members(*names)) + _force_saves(game, 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=ReactionResult.ATTACKS, + monsters_aware=True, + ) + if not any(event.code == "encounter.started" and event.party_surprised for event in events): + continue + codes = [event.code for event in events] + assert "battle.started" in codes, "precondition: the monster attacked at once" + assert game.battle is None or game.battle.round == 1, "precondition: the monster's free round ran" + return game, events + raise AssertionError(f"precondition: no seed let a {template_id} surprise the party") + + +class TestTheSurpriseGaze: + """Characters a basilisk surprises meet its gaze in its free round, 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): + game, events = _surprised("basilisk", distance_feet) + + for member in game.party.members: + assert _gaze_saves(events, member.id) == 1, f"{member.id} meets the basilisk's gaze once" + gaze_rolls = [event for event in events if isinstance(event, AttackRolledEvent) and event.attack_name == "gaze"] + assert not gaze_rolls, "a gaze takes no attack roll" + + def test_the_surprise_gaze_petrifies_on_a_failed_save(self): + game, events = _surprised("basilisk", 60, save_bonus=ALWAYS_FAIL) + + for member in game.party.members: + assert _saves(events, member.id), f"precondition: {member.id} met the gaze" + assert has_condition(member, Condition.PETRIFIED) + + def test_a_medusa_that_surprises_the_party_from_afar_doesnt_gaze(self): + """A guard: the medusa's entry has no surprise clause, so beyond 5' her free round reaches no one.""" + game, events = _surprised("medusa", 60) + + assert game.encounter.groups[0].distance_feet > 5, "precondition: the medusa is still out of melee" + for member in game.party.members: + assert not _saves(events, member.id, None) + + def test_a_medusa_that_surprises_the_party_in_melee_gazes_at_the_front_rank(self): + """A guard: at 5' the medusa's free round has the ordinary front-rank gaze.""" + game, events = _surprised("medusa", 5) + hild, brakk, sable = game.party.members + + assert len(_saves(events, hild.id)) == 1 + assert len(_saves(events, brakk.id)) == 1 + assert not _saves(events, sable.id, None) + + +# ---------------------------------------------------------------------- rejected rounds + + +class TestARefusedRoundCostsNothing: + """Validation stays a pure pre-phase: a refused round draws nothing, takes no time, and leaves no stance.""" + + def test_a_round_refused_for_another_declaration_leaves_no_stance(self): + """A guard: the stance is granted only once the whole round is accepted.""" + game = _battle("medusa", ("Hild", "Brakk")) + hild, brakk = game.party.members + before = _state_without_logs(game) + + result = game.execute( + ResolveBattleRound( + declarations=( + _declare(hild.id, "hold", "avert_eyes"), + _declare(brakk.id, "attack", target_group_id="no-such-group"), + ) + ) + ) + + assert not result.accepted + assert [rejection.code for rejection in result.rejections] == ["battle.declaration.unknown_group"] + assert not result.events + assert not has_condition(hild, Condition.AVERTED_EYES) + assert _state_without_logs(game) == before + + def test_a_mirror_stance_from_a_member_without_a_mirror_is_refused(self): + game = _battle("medusa", ("Hild", "Brakk"), grants=((1, "mirror"),)) + hild, brakk = game.party.members + assert hild.inventory.carried_item("mirror") is None, "precondition: Hild carries no mirror" + before = _state_without_logs(game) + + result = game.execute( + ResolveBattleRound(declarations=(_declare(hild.id, "hold", "mirror"), _declare(brakk.id, "hold", "mirror"))) + ) + + assert not result.accepted + assert [rejection.code for rejection in result.rejections] == ["battle.declaration.no_mirror"] + assert not result.events + assert _state_without_logs(game) == before + + def test_a_mirror_stance_from_a_member_with_a_mirror_is_accepted(self): + """A guard: carrying a mirror, not wielding one, is all the stance needs.""" + game = _battle("medusa", ("Hild", "Brakk"), grants=((0, "mirror"),)) + hild, _ = game.party.members + assert hild.inventory.carried_item("mirror") is not None, "precondition: Hild carries a mirror" + + _play(game, _declare(hild.id, "attack", "mirror", target_group_id=_group_id(game))) + + +# ---------------------------------------------------------------------- determinism + + +class TestDeterminism: + """A stance draws nothing and emits nothing of its own, and a battle with stances replays exactly.""" + + @pytest.mark.parametrize("stance", ["avert_eyes", "mirror"]) + def test_a_stance_that_spares_no_gaze_and_costs_no_attack_changes_nothing(self, stance): + """A guard: a back-rank member who holds with a stance against an ogre leaves every draw and event as it was.""" + names = ("Hild", "Brakk", "Sable") + plain = _battle("ogre", names, grants=((2, "mirror"),)) + guarded = _battle("ogre", names, grants=((2, "mirror"),)) + sable = plain.party.members[2].id + + for _ in range(3): + plain_events = _play(plain) + guarded_events = _play(guarded, _declare(sable, "hold", stance)) + assert [event.model_dump(mode="json") for event in guarded_events] == [ + event.model_dump(mode="json") for event in plain_events + ] + assert _stream_states(guarded) == _stream_states(plain) + assert not has_condition(guarded.party.members[2], Condition.AVERTED_EYES) + + def test_a_battle_with_stances_replays_from_its_command_log(self): + members = [_character("Hild"), _character("Brakk")] + party_document = party_to_document(members) + game = _battle("medusa", members=members, grants=((0, "mirror"),), save_bonus=None) + hild, brakk = (member.id for member in game.party.members) + + spared = False + for stance in ("avert_eyes", "mirror", None, "avert_eyes"): + if game.mode is not SessionMode.BATTLE: + break + able = {member.id for member in game.party.living_members() if not incapacitated(member)} + declared = [_declare(hild, "attack", stance, target_group_id=_group_id(game))] if hild in able else [] + events = _play(game, *declared) + if stance is not None and hild in able and _saves(events, brakk): + assert not _saves(events, hild), "precondition: the stance spared Hild the gaze Brakk met" + spared = True + assert spared, "precondition: some round's stance spared Hild a gaze" + + 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 _stream_states(replayed) == _stream_states(game) + assert session_state(replayed) == session_state(game) diff --git a/tools/docs/rejection_codes.json b/tools/docs/rejection_codes.json index b673e13..df2f2c6 100644 --- a/tools/docs/rejection_codes.json +++ b/tools/docs/rejection_codes.json @@ -4,6 +4,7 @@ "battle.declaration.item_unusable": "The declared item has no usable combat effect: it isn't a weapon-capable item in the character's inventory, or it's a magic item category (other than a potion, scroll, rod, staff, or wand) that has no defined combat use.", "battle.declaration.missing_move": "A move action is declared without specifying which kind of movement (closing distance, retreating, or a fighting withdrawal) the character is taking.", "battle.declaration.missing_spell": "A cast action is declared without specifying which spell to cast or which mode of that spell to use.", + "battle.declaration.no_mirror": "A character declares the mirror gaze defense, fighting by watching a petrifying gazer in a mirror, without carrying a mirror from the equipment catalog.", "battle.declaration.no_replacement": "A front-rank character declares a fighting withdrawal or a retreat that not every character declaring that round makes, and names no member to step up into their place. A character who withdraws alone trades places with a member from a back rank, because a rank has no empty places.", "battle.declaration.no_target": "The chosen enemy group has no living, visible monster in its front rank that can currently be targeted.", "battle.declaration.not_in_front_rank": "A character who isn't in the party's front rank declares a melee attack, or a fighting withdrawal or a retreat that not every character declaring that round makes. The formation-width rule in effect this round means only front-rank members are within melee range, and only they can step back out of it. A member who steps up into the front rank this round as another character's replacement may declare a melee attack.", diff --git a/tools/srd_compile/monsters.py b/tools/srd_compile/monsters.py index f7db6d9..848289e 100644 --- a/tools/srd_compile/monsters.py +++ b/tools/srd_compile/monsters.py @@ -124,8 +124,14 @@ ("Gelatinous_Cube.md", None, "Paralysis"): ("paralysis", {"duration": "2d4", "unit": "turn"}, False), ("Basilisk.md", None, "Petrifying touch"): ("petrification", {"vector": "touch"}, False), ("Basilisk.md", None, "Petrifying gaze"): ("petrification", {"vector": "gaze"}, False), + # Battle gazes at every member of a party the basilisk surprises, and applies the averting + # penalties to a member who declares the stance. The mirrors bullets stay manual, because a + # gazer that sees its own reflection is left to the referee. + ("Basilisk.md", None, "Surprise"): ("surprise", {"meets_gaze": True}, False), + ("Basilisk.md", None, "Averting eyes"): ("averting_eyes", {}, False), ("Cockatrice.md", None, "Petrification"): ("petrification", {"vector": "touch"}, False), ("Medusa.md", None, "Petrification"): ("petrification", {"vector": "gaze"}, False), + ("Medusa.md", None, "Averting eyes"): ("averting_eyes", {}, False), ("Gorgon.md", None, "Petrifying breath"): ("petrification", {"vector": "breath"}, False), ("Killer_Bee.md", None, "Poison"): ("poison", {"outcome": "death"}, False), ("Scorpion%2C_Giant.md", None, "Poison"): ("poison", {"outcome": "death"}, False),