Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- [`Rest`][osrlib.crawl.commands.Rest] has a new field `hidden`, and [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] takes a matching `hidden` parameter, so a game can let the party hide while it rests. The OSE SRD lowers the chance of wandering monsters when the party rests quietly in an out-of-the-way place, and a game sets `hidden` to say the party does. Each wandering-monster check during a hidden rest takes 1 off the chance in 6, after noise and bright light add theirs. On a level with the usual 1 in 6 chance, the session rolls no check during a hidden rest. The field defaults to `False`, so a saved command without it still parses as a plain rest. In town no checks run, so `hidden` changes nothing there.
- Each attack routine has a new field, [`AttackRoutine.use`][osrlib.core.monsters.AttackRoutine.use], that gives when the monster uses the routine. Its value is a [`RoutineUse`][osrlib.core.monsters.RoutineUse]. The first routine is `PRIMARY`. A later routine is `ALTERNATIVE` when the OSE SRD lets the monster use it in place of the first in any round, like a thoul's weapon. It's `SITUATIONAL` when the SRD ties it to a situation osrlib doesn't model, like an elephant's trample or a giant's thrown boulder. It's `SPECIAL` when it's a breath, a gaze, or a spell. Every routine in the built-in monster and spell catalogs has its label. A routine with no label is `PRIMARY`, so in a monster template of your own, label each later routine, or the default policy picks it as often as the first.
- A policy of your own can choose which attack routine a monster uses, in the new field [`MonsterAction.routine`][osrlib.crawl.battle.MonsterAction.routine]. The field is an index into the template's `attacks`, and `None` uses the first routine. So your policy can have an elephant trample when your game decides it charges. Battle rolls the routine's attacks as melee attacks, and rolls nothing for a `SPECIAL` routine. An index the template doesn't have makes the action impossible, so the session skips it.
- [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] reports a wand, staff, or rod use or a turning that fizzled in a battle round (#145). Its code is `magic.device.fizzled` for a device and `magic.turning.fizzled` for a turning. `actor_id` is the member who declared it, `item_id` is the device's instance id (`None` for a turning), and `reason` is the rejection code of the check that failed. Tell the player the action failed. A device that fizzles keeps its charge.

### Changed

Expand All @@ -29,7 +30,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- A melee attack on a petrified creature hits automatically, as on a paralysed or sleeping one (#166). [`attack_roll`][osrlib.core.combat.attack_roll] reports `combat.attack.auto_hit` and draws no d20, so only the damage is rolled. A missile attack on a petrified creature is still rolled.
- [`UseItem`][osrlib.crawl.commands.UseItem] with a potion and a `target_id` that names someone other than the member using it is now refused with `items.use.target_not_allowed`. Before, the session accepted the command and the member in `character_id` drank the potion, so a front end that expected the target to drink got no warning. A `target_id` of `None`, or the id of the member using the potion, drinks the potion as before, and a healing device still takes another member (#174).
- A plain [`Rest`][osrlib.crawl.commands.Rest] in a dungeon now checks for wandering monsters at the level's full chance. Before, every rest took 1 off the chance, so a level with the usual 1 in 6 chance never rolled during a rest and resting was always safe. The session still adds 1 for noise and 1 for bright light, and a rest of a night or a day still checks once each turn and stops when monsters come. To rest quietly and take the 1 off, set the new `hidden` field. The `resting` keyword of [`wandering_check`][osrlib.crawl.exploration.wandering_check] still takes 1 off the chance. [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] now sets it from `hidden`, so `advance_turns(resting=True)` alone no longer lowers the chance. It still counts the turns as rest.
- Some command logs recorded under 2.0.1 don't replay under this release. A log that contains a command this release refuses makes [`replay_game`][osrlib.persistence.replay_game] raise [`ContentValidationError`][osrlib.errors.ContentValidationError]. Two commands that 2.0.1 accepted are refused now, so a log that contains one of them raises: a potion `UseItem` aimed at another member, and a temple service that would do nothing. Other logs replay without an error but play out differently. That happens with *sleep* cast at a drained monster, a lair the party left and came back to, a rest in a dungeon, a rest that a wandering monster interrupted, a trap save with a `modifier`, or a chute trap where every member passed the save. In battle, it also happens with a monster that regenerates (a troll, a thoul, or a vampire), a monster whose attack has an effect (like a ghoul's paralysing claw or a wight's level drain), a basilisk's or a medusa's gaze, a melee attack on a petrified creature, or 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). 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.
- An `attack` in a [`BattleDeclaration`][osrlib.crawl.commands.BattleDeclaration] with no `weapon_id` now uses the wielded weapon that suits the attack (#162). Before, the member struck unarmed, even with a sword in hand. The group's distance when the session accepts the round decides the attack: melee at 5 feet or less, missile beyond. The session takes the first wielded weapon, in the order the member equipped them, that can make that attack. For a missile attack it prefers the first whose long range reaches the group. A real weapon comes before an improvised one, like a torch, so a member who lit a torch and then drew a sword swings the sword. The torch still counts when the member wields nothing else that suits the attack. The session passes over a splash weapon, like a flask of oil, and an item with no weapon stats, like a wand. A member with no suitable weapon still strikes unarmed. The round plays out as if you had named the chosen weapon, and the command log records the declaration as you sent it. [`BattleDeclaration.weapon_id`][osrlib.crawl.commands.BattleDeclaration.weapon_id] describes the choice. A declaration that names a weapon works as before.
- A wand, staff, or rod use and a turning declared in [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] are checked again in the magic phase, just before they resolve, as a spell cast and a scroll read are (#145). Before, they resolved even when the round had changed something they depend on. For example, a staff of striking struck a group that had already run out of melee reach. Now a declaration that fails the second check fizzles, and the session reports it with a [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent]. A device that fizzles was never used, so it keeps its charge, isn't identified, and doesn't break the user's invisibility. A turning that fizzles rolls nothing. A device use or turning that passes the second check resolves with the same rolls as before.
- Some command logs recorded under 2.0.1 don't replay under this release. A log that contains a command this release refuses makes [`replay_game`][osrlib.persistence.replay_game] raise [`ContentValidationError`][osrlib.errors.ContentValidationError]. Two commands that 2.0.1 accepted are refused now, so a log that contains one of them raises: a potion `UseItem` aimed at another member, and a temple service that would do nothing. Other logs replay without an error but play out differently. That happens with *sleep* cast at a drained monster, a lair the party left and came back to, a rest in a dungeon, a rest that a wandering monster interrupted, a trap save with a `modifier`, or a chute trap where every member passed the save. In battle, it also happens with a monster that regenerates (a troll, a thoul, or a vampire), a monster whose attack has an effect (like a ghoul's paralysing claw or a wight's level drain), a basilisk's or a medusa's gaze, a melee attack on a petrified creature, a monster that can choose between attack routines (a spitting cobra, a thoul, a mule, a minotaur, a wererat, or a monster of your own with more than one unlabelled routine), an attack declared with no `weapon_id` by a member who wields a weapon, or a device use (a wand, staff, or rod) or a turning that now fizzles. Each of these changes has an entry in this section or under Fixed. Loading a 2.0.1 save with [`load_game`][osrlib.persistence.load_game] isn't affected, because a load restores the saved state and runs no commands. The loaded game then plays on under this release's rules.

### Fixed

Expand Down
4 changes: 3 additions & 1 deletion docs/adaptations.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,9 @@ The party moves as a formation and individual members cannot leave it (the Bard'

A party melee attack hits the first living, visible monster of the group's reachable rank (deterministic, no draw), and monsters pick uniformly on the monster-action stream. Under individual initiative the machine still resolves side blocks, ordered by the best individual total (the SRD's phase sequence is per side). Turn undead resolves in the magic phase, needs no declaration posting, and cannot be disrupted, because it is a class ability rather than a spell. The machine detects disruption per the RAW trigger (a declared caster successfully attacked or failing a save before acting) and releases concentration on any other declared action. A turning is one of those actions, so a cleric who declares one stops concentrating, even when confusion stops the turning before it resolves. A caster maintains one concentration spell at a time: casting a second ends the first, and a spell that needs no concentration leaves it alone (#167). Locked by `test_rules_calls.py::TestTurningEndsConcentration` and `test_rules_calls.py::TestOneConcentrationSpellAtATime`.

A declaration is judged twice: once when the round is accepted, and again in the magic phase immediately before it resolves, with every check it passed the first time, because the phases between the two can change what those checks read. Failing any one of them fizzles the declaration, whatever the round changed to cause the failure. Two cases that occur are an ally's *silence 15' radius* anchoring on the party's cell earlier in the same magic phase, which refuses a cast with `magic.cast.silenced_area`, and the party's last torch going out as its bearer dies in the missile phase, which refuses a scroll read with `exploration.action.requires_light`. A cast or scroll read the re-check refuses is not cast and never raises out of the round: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with `magic.cast.fizzled` on `SpellDisruptedEvent`, whose `reason` field contains the first rejection code the re-check produced. A caster the round already disrupted reports `magic.cast.disrupted` instead, because that check runs first. Each battle round advances the clock one round through the ledger. Locked by `test_battle.py`, the split refusal by `test_battle.py::TestAFormationMoveNeedsEveryDeclarer`, and the re-check by `test_battle.py::TestTheMagicPhaseRechecksADeclaration`.
An `attack` declaration that names no weapon attacks with the wielded weapon that suits the attack (#162). The SRD decides melee or missile by distance alone ("Melee: Close quarters weapon (5' or less)" and "Missile: Thrown or fired weapon (greater than 5' distance)"), so the group's distance when the round is accepted decides it: a melee attack at 5' or less, a missile attack beyond. The session takes the first wielded item, in the order the member equipped them, with the `melee` or `missile` quality. For a missile attack it prefers the first whose long range reaches the group, and when none reaches it takes the first missile weapon, which the session then refuses with `combat.attack.out_of_range`. A real weapon comes before an improvised one, a gear item whose stats come from its combat facet, like a torch, and equip order holds within each kind. A member who lights a torch and then draws a sword, the usual dungeon setup, swings the sword. The torch still counts when nothing else suits the attack, because the weapon table gives it the melee quality. A splash weapon never counts, because a `use_item` declaration throws it, and a magic item with no base weapon, like a wand, has no weapon stats. A member with nothing that suits the attack strikes unarmed. The choice is made once, when the round is accepted, and the round then plays out exactly as if the player had named that weapon, even when the distance changes before the attack resolves. The command log keeps the declaration as it was sent. Locked by `test_weapon_default_and_device_recheck.py`.

A declaration is judged twice: once when the round is accepted, and again in the magic phase immediately before it resolves, with every check it passed the first time, because the phases between the two can change what those checks read. Failing any one of them fizzles the declaration, whatever the round changed to cause the failure. Two cases that occur are an ally's *silence 15' radius* anchoring on the party's cell earlier in the same magic phase, which refuses a cast with `magic.cast.silenced_area`, and the party's last torch going out as its bearer dies in the missile phase, which refuses a scroll read with `exploration.action.requires_light`. A cast or scroll read the re-check refuses is not cast and never raises out of the round: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with `magic.cast.fizzled` on `SpellDisruptedEvent`, whose `reason` field contains the first rejection code the re-check produced. A caster the round already disrupted reports `magic.cast.disrupted` instead, because that check runs first. A wand, staff, or rod use and a turning get the same re-check (#145). A staff of striking aimed at a group that ran out of melee reach fizzles with `combat.attack.out_of_reach`, and a wand aimed at a group that fled the battle fizzles with `battle.declaration.unknown_group`. One that fizzles is reported by `DeclarationFizzledEvent`, at `magic.device.fizzled` or `magic.turning.fizzled`, with the same `reason` field. `SpellDisruptedEvent` stays for spells and scrolls, so its `spell_id` always names a spell. A fizzled device use costs what a `hold` costs: the device was never used, so it keeps its charge, isn't identified, and breaks no invisibility. A fizzled turning draws nothing and costs nothing. Each battle round advances the clock one round through the ledger. Locked by `test_battle.py`, the split refusal by `test_battle.py::TestAFormationMoveNeedsEveryDeclarer`, and the re-check by `test_battle.py::TestTheMagicPhaseRechecksADeclaration` and, for devices and turnings, `test_weapon_default_and_device_recheck.py`.

### Spell effect consumption in battle

Expand Down
50 changes: 50 additions & 0 deletions src/osrlib/core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"DamageAbsorbedEvent",
"DamageDealtEvent",
"DeathEvent",
"DeclarationFizzledEvent",
"EffectAttachedEvent",
"EffectExpiredEvent",
"EffectReleasedEvent",
Expand Down Expand Up @@ -1115,6 +1116,53 @@ class SpellDisruptedEvent(Event):
"""


class DeclarationFizzledEvent(Event):
"""A declared wand, staff, or rod use, or a declared turning, came to nothing, and cost nothing.

A session reports it during a battle round. In the round's magic phase, the session checks each declared
device use and turning again, just before it would resolve. If a check it passed at the start of the round now
fails, because something earlier in the round changed, the declaration fizzles. One example is a staff of
striking aimed at a group that runs out of melee reach before the magic phase. Another is a wand aimed at a
group that leaves the battle first. `reason` has the rejection code of the check that failed.

Nothing happened, and nothing was spent. The device was never used, so it keeps its charge and isn't
identified, and a turning has nothing to spend. Tell the player the action failed, and that the device still
has its charge. A cast or a scroll read that fizzles is reported by
[`SpellDisruptedEvent`][osrlib.core.events.SpellDisruptedEvent] instead, because a spell is lost.
"""

allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.device.fizzled", "magic.turning.fizzled"})
"""The two codes: `magic.device.fizzled` for a wand, staff, or rod use, and `magic.turning.fizzled` for a
turning."""

event_type: Literal["declaration_fizzled"] = "declaration_fizzled"
"""The fixed string that identifies this event class in a serialized event."""

code: str
"""Which declaration fizzled: `magic.device.fizzled` or `magic.turning.fizzled`."""

visibility: Visibility = Visibility.PLAYER
"""Player visibility: everyone sees the action fail."""

actor_id: str
"""The entity id of the member who declared the device use or the turning."""

item_id: str | None = None
"""The device's per-instance id, the `item_id` the declaration named, on a `magic.device.fizzled` event. It's
the id the player already used, so it reveals nothing about a device that isn't identified. `None` on a
`magic.turning.fizzled` event."""

reason: str
"""Why the declaration fizzled: the code of the first check that failed when the session checked it again.

Any check the declaration passed at the start of the round can fail, so read the code and don't assume a
cause. A staff of striking whose group ran out of reach gives `combat.attack.out_of_reach`, and a wand aimed at
a group that left the battle gives `battle.declaration.unknown_group`. Match on it as you match on the code of a
[`Rejection`][osrlib.core.validation.Rejection]. [The rejection code reference][rejection-codes] explains each
code.
"""


class SpellForgottenEvent(Event):
"""A caster forgot a memorized spell, because the caster no longer has room for it.

Expand Down Expand Up @@ -1300,6 +1348,7 @@ class MagicDispelledEvent(Event):
SpellsMemorizedEvent,
SpellCastEvent,
SpellDisruptedEvent,
DeclarationFizzledEvent,
SpellForgottenEvent,
SpellBookUpdatedEvent,
UndeadTurnedEvent,
Expand Down Expand Up @@ -1347,6 +1396,7 @@ class rather than one event: to build a table of handlers, to generate a JSON Sc
| SpellsMemorizedEvent
| SpellCastEvent
| SpellDisruptedEvent
| DeclarationFizzledEvent
| SpellForgottenEvent
| SpellBookUpdatedEvent
| UndeadTurnedEvent
Expand Down
Loading
Loading