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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

- Discovering a secret door clears the `room_traps` search attempts of both cells of that edge, so every member may search either cell again (#45, #129). A [`Search`][osrlib.crawl.commands.Search] for `room_traps` covers the searched cell's door edges, and an undiscovered secret door hides the trap beyond it along with itself, so a member who searches while the door is still wall spends their attempt with no chance at that trap. The refund fires on a `secret_doors` search that finds the door and on the referee's [`SetDoorState`][osrlib.crawl.commands.SetDoorState] with `discovered=True`, and it clears nothing else: `secret_doors` and `construction` attempts on that cell, and every attempt on every other cell, stand. The refund reaches the far cell too, whichever side the discovery came from, because a search from either cell covers the same edge.
- [`check_fatigue`][osrlib.crawl.exploration.check_fatigue] attaches the unrested-fatigue penalty to each living member who lacks it, so a member who joins an already-tired party, or who loses the effect some other way, is fatigued beside their companions rather than waiting for the next [`Rest`][osrlib.crawl.commands.Rest] to reset the counter (#100). `exploration.fatigue.gained` is emitted when at least one member gained the effect on the call, and the call returns nothing when every living member already carries it, so a fully fatigued party reports the check as quiet.
- A battle declaration is judged again in the magic phase, immediately before it resolves, so a cast or a scroll read the rules can no longer allow fizzles instead of going off or raising out of the round (#129). [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] re-runs every check a `cast` declaration and a scroll-read `use_item` declaration passed when the round was accepted, against the state as it stands then and before anything is spent or drawn, and failing any one of them fizzles the declaration. That is the rule; which check fails is whatever the round did to the caster. Two that happen: an ally's *silence 15' radius* anchors on the party's cell when a creature passes its save, so a silence resolving earlier in the same magic phase silences a caster whose declaration was accepted with no silence on the cell, and the party's last torch goes out as its bearer dies in the missile phase, leaving a scroll reader without the light a read needs. A refused declaration is not cast: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with a [`SpellDisruptedEvent`][osrlib.core.events.SpellDisruptedEvent] at the new code `magic.cast.fizzled`, whose new `reason` field holds the first rejection code the re-check produced, `magic.cast.silenced_area` for the silence and `exploration.action.requires_light` for the lost torch. Read `reason` rather than assuming a case, and match on it the way you match on a rejection's own code. `reason` defaults `None`, so a log written before the field existed still parses, and a `magic.cast.disrupted` event leaves it unset: a caster the round already disrupted keeps reporting `magic.cast.disrupted`, because that check runs before the re-check. A scroll read that fizzles emits its `items.scroll.read` event first, because the reader did read it. A declaration the re-check accepts resolves as it always did, with the same draws in the same order.
- A scroll read is judged at the scroll's own caster level, in the kernel and in the crawl layer alike (#117). [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] validates and resolves at the spell's minimum caster level, so the two things that scale with caster level follow the scroll rather than the reader: a *magic missile* scroll takes one target however high the reader's level, and a per-level range is measured from the scroll's reach. The kernel exports the pre-check itself as [`validate_scroll_cast`][osrlib.core.spells.validate_scroll_cast], which builds the scroll-level caster and returns the rejections `cast_from_scroll` would raise on; `cast_from_scroll` runs it, and so do [`UseItem`][osrlib.crawl.commands.UseItem] and the `use_item` battle declaration, so there is one statement of the rule and a front end deciding whether to offer a read can ask it too. The crawl therefore refuses exactly what the kernel would refuse, before any state change, draw, or event: a 6th-level magic-user reading a *magic missile* scroll is refused `magic.cast.target_count` for three targets and keeps the scroll.
- [`generate_treasure`][osrlib.core.treasure.generate_treasure], [`generate_treasure_entries`][osrlib.core.treasure.generate_treasure_entries], and [`generate_unguarded_treasure`][osrlib.core.treasure.generate_unguarded_treasure] raise `ValueError` for a `tier` that is neither `"basic"` nor `"expert"` before their first draw, the check [`generate_magic_item`][osrlib.core.treasure.generate_magic_item] already makes. A bad tier costs no draws and yields no hoard, so a caller who passes a typo sees the bug rather than a plausible pile of coins and gems. A valid call's draw sequence is unchanged.

Expand Down
2 changes: 1 addition & 1 deletion docs/adaptations.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ Pursuit compares the party's slowest running rate (full feet per round) against

### The battle round

Battle declarations arrive as one command per round with one declaration per living, able member, rejected whole on any invalid declaration (partial acceptance would tangle the replay contract). Formation width is the frontage the party's own space offers under `formation_width_limit`: the widest square of unbroken floor that includes the party's cell, at five feet of frontage each — RAW's "2–3 characters in a 10' passage" pinned to its conservative end and then applied to whatever space the party stands in, so a ten-foot passage holds two however long it runs and a room holds its shorter side. Ranks recompute from the living marching order at round start and melee reaches the front rank only, both directions, with no firing-into-melee penalty (none exists in the SRD). The party moves as a formation — individual members cannot leave it (the Bard's Tale convention): all-retreat moves off at the full encounter rate (Combat.md's "full encounter movement rate"; the running pursuit begins when the battle converts at round end), all-fighting-withdrawal backs off at half encounter rate, and the first `close` declaration advances the formation on its named group, stopping at 5'. A `fighting_withdrawal` or a `retreat` that not every declarer chose is therefore not a legal declaration for that member: the round is refused whole with `battle.declaration.formation_split`, one rejection per defensive move the round split on, naming the declarers who chose it and the declarers who did not. `close` is not a defensive move and needs no agreement: a lone `close` advances the formation, and a `close` declared beside a `fighting_withdrawal` or a `retreat` counts among the others that split the round, so that round is refused and nothing advances. A round whose only declarer declares a defensive move is legal, because everyone agreed. A fighting withdrawal here is a pure move and pinned as one: RAW gives the withdrawing combatant the backward move and takes nothing away, so the attack stays with them, but a member declares one thing per round and the whole formation moves together, so a round the party withdraws in is a round it does not attack in. The SRD's attack-while-withdrawing half is not represented. The move set is the SRD's two defensive moves and `close`, with no fourth. Retiring the fourth value is schema version 4, with a lossless migration that clears it off a logged declaration and leaves a member who declared it holding, as that round played out. A party melee attack lands on the first living, visible monster of the group's reachable rank (deterministic, no draw); 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 — a class ability, not 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. Each battle round advances the clock one round through the ledger. Locked by `test_battle.py`, and the split refusal by `test_battle.py::TestAFormationMoveNeedsEveryDeclarer`.
Battle declarations arrive as one command per round with one declaration per living, able member, rejected whole on any invalid declaration (partial acceptance would tangle the replay contract). Formation width is the frontage the party's own space offers under `formation_width_limit`: the widest square of unbroken floor that includes the party's cell, at five feet of frontage each — RAW's "2–3 characters in a 10' passage" pinned to its conservative end and then applied to whatever space the party stands in, so a ten-foot passage holds two however long it runs and a room holds its shorter side. Ranks recompute from the living marching order at round start and melee reaches the front rank only, both directions, with no firing-into-melee penalty (none exists in the SRD). The party moves as a formation — individual members cannot leave it (the Bard's Tale convention): all-retreat moves off at the full encounter rate (Combat.md's "full encounter movement rate"; the running pursuit begins when the battle converts at round end), all-fighting-withdrawal backs off at half encounter rate, and the first `close` declaration advances the formation on its named group, stopping at 5'. A `fighting_withdrawal` or a `retreat` that not every declarer chose is therefore not a legal declaration for that member: the round is refused whole with `battle.declaration.formation_split`, one rejection per defensive move the round split on, naming the declarers who chose it and the declarers who did not. `close` is not a defensive move and needs no agreement: a lone `close` advances the formation, and a `close` declared beside a `fighting_withdrawal` or a `retreat` counts among the others that split the round, so that round is refused and nothing advances. A round whose only declarer declares a defensive move is legal, because everyone agreed. A fighting withdrawal here is a pure move and pinned as one: RAW gives the withdrawing combatant the backward move and takes nothing away, so the attack stays with them, but a member declares one thing per round and the whole formation moves together, so a round the party withdraws in is a round it does not attack in. The SRD's attack-while-withdrawing half is not represented. The move set is the SRD's two defensive moves and `close`, with no fourth. Retiring the fourth value is schema version 4, with a lossless migration that clears it off a logged declaration and leaves a member who declared it holding, as that round played out. A party melee attack lands on the first living, visible monster of the group's reachable rank (deterministic, no draw); 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 — a class ability, not 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 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, which is the rule; the cases are whatever the round did. Two that happen 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 holds 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`.

### Phase 3 effect consumption in battle

Expand Down
36 changes: 29 additions & 7 deletions src/osrlib/core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -1022,20 +1022,30 @@ class SpellCastEvent(Event):


class SpellDisruptedEvent(Event):
"""A caster was interrupted and the spell came to nothing.

[`disrupt_casting`][osrlib.core.spells.disrupt_casting] emits this when a caster who declared a spell is hit,
or otherwise stopped, before it goes off. The memorized copy is lost exactly as if it had been cast.
"""A declared spell came to nothing, and the caster lost it anyway.

Two things produce one, and `code` tells them apart.
[`disrupt_casting`][osrlib.core.spells.disrupt_casting] emits `magic.cast.disrupted` when a caster who
declared a spell is hit, or otherwise stopped, before it goes off. The battle round emits
`magic.cast.fizzled` when it judges the declaration again in the magic phase, just before the spell would
resolve, and any one of the checks it passed at the top of the round no longer passes, because the phases
between the two changed what that check reads. An ally's *silence 15' radius* anchoring on the party's
cell and the party's last torch going out as its bearer dies are two that happen; the rule is the whole
set of checks, not a list of cases. `reason` carries the rejection code behind a fizzle.

Either way nothing resolved and the memorized copy is gone, exactly as if the spell had been cast, so tell
the player the spell failed and the prepared copy is spent. A scroll read that fizzles spends the scroll
the same way, and the caster loses no memorized copy, because a read never used one.
"""

allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.cast.disrupted"})
"""The only code this event uses."""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.cast.disrupted", "magic.cast.fizzled"})
"""The two codes this event uses: `magic.cast.disrupted` and `magic.cast.fizzled`."""

event_type: Literal["spell_disrupted"] = "spell_disrupted"
"""The wire name for this event type."""

code: str = "magic.cast.disrupted"
"""Fixed at `magic.cast.disrupted`."""
"""Which of the two failures this is, defaulting to `magic.cast.disrupted`."""

visibility: Visibility = Visibility.PLAYER
"""Player visibility: the caster's spell visibly fails."""
Expand All @@ -1049,6 +1059,18 @@ class SpellDisruptedEvent(Event):
reversed: bool = False
"""True when the lost copy was the spell's reversed form."""

reason: str | None = None
"""Why a `magic.cast.fizzled` spell failed: the first rejection code the magic phase's re-check produced.

Any check a declaration passes at the top of the round can produce it, so read the code rather than
assuming a case: an ally's silence on the party's cell reads `magic.cast.silenced_area`, and a scroll
read left in the dark reads `exploration.action.requires_light`. Match on this the way you match on a
[`Rejection`][osrlib.core.validation.Rejection]'s own code, and
[the rejection code reference][rejection-codes] says what each one means. `None` on a
`magic.cast.disrupted` event, which needs no reason beyond the blow that landed, and on a log written
before the field existed.
"""


class SpellForgottenEvent(Event):
"""A memorized spell slipped away because the caster no longer has room for it.
Expand Down
Loading
Loading