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 @@ -35,6 +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, so a game that handled it should handle the three new codes instead: `battle.declaration.no_replacement`, `battle.declaration.replacement_unavailable`, and `battle.declaration.replacement_too_far`. A defensive move that every declarer makes still moves the whole party, as before. Any other fighting withdrawal or retreat is that member's alone. 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. The attack events name the weapon the member used. [`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, the session doesn't identify it, and the member who declared it stays invisible if they were. A turning that fizzles rolls nothing. A device use or turning that passes the second check resolves with the same rolls as before.
- A party turned wholly to stone ends the game, as a party that has died does. A member stands while they are neither dead nor petrified. When a command kills or petrifies a member and no member is left standing, the session moves to `game_over` and reports a [`GameOverEvent`][osrlib.crawl.events.GameOverEvent] last. Before, a statue counted as alive, so a party that a basilisk's gaze turned to stone stayed in the encounter or in a battle with nobody able to act. In a battle, `battle.ended.defeat` comes just before the ending. When a basilisk's surprise gaze turns the whole party to stone as an encounter opens, the monsters get no round against the statues. A statue still counts among [`Party.living_members`][osrlib.crawl.party.Party.living_members] and keeps its rank. The temple can't cure stone, so the way back is a magic-user who casts *stone to flesh* in town. Place the party in town with [`PlaceParty`][osrlib.crawl.commands.PlaceParty], which doesn't end the game again. Then add the magic-user with [`AddMember`][osrlib.crawl.commands.AddMember] and cast the spell with [`CastSpell`][osrlib.crawl.commands.CastSpell]. The ending makes no draws.
- Some command logs recorded under 2.0.1 replay differently under this release. 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. Replaying a 2.0.1 log with [`replay_game`][osrlib.persistence.replay_game] can differ in two ways. Each change named below has an entry in this section or under Fixed.
- The replay raises [`ContentValidationError`][osrlib.errors.ContentValidationError] when the log contains a command this release refuses. Three commands that 2.0.1 accepted are refused now: a potion `UseItem` aimed at another member, a temple service that would do nothing, and a battle round in which a member behind the front rank strikes with a staff of striking.
- The replay runs without an error but plays out differently when the log contains any of these:
Expand Down
4 changes: 4 additions & 0 deletions docs/adaptations.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ Save versus death or poison, and failure is death, either immediate or an onset-

While a target is petrified its other attached effects suspend, with no ticks and durations frozen, so an adventurer who was poisoned before turning to stone is still poisoned after *stone to flesh*. Petrification is permanent but recoverable, because a petrified creature isn't dead. Locked by `test_effects.py::TestLedger::test_petrification_suspends_other_effects`.

### A party turned wholly to stone has fallen

The SRD doesn't say what happens when no adventurer is left but statues, so this is a judgement (pinned). A member stands while they are neither dead nor petrified. When a command's events kill or petrify a member and no member is left standing, the session ends in `game_over` exactly as it does when the last member dies, with `battle.ended.defeat` first in a battle. A statue isn't dead, so it still counts among `Party.living_members()` and keeps its rank, and the check is tied to the killing or petrifying event, not to the party's state. So a stone party carried to town with `PlaceParty` stays in `town`. The temple's services are clerical and *stone to flesh* is a magic-user's spell, so the way back is a magic-user who joins with `AddMember` and casts it. Locked by `test_petrified_party.py`.

### The canonical tick order

At each round boundary, expirations resolve before ticks, and simultaneous effects resolve in attachment order, tie-broken by effect id. Only the effects engine (and the kernel's death routine, for `dead`) writes creature conditions. Locked by `test_effects.py::TestLedger`.
Expand Down
6 changes: 4 additions & 2 deletions src/osrlib/crawl/battle.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@
MonstersLeftBehindEvent,
SpellDeclaredEvent,
)
from osrlib.crawl.party import _anyone_standing
from osrlib.data import load_classes, load_spells

__all__ = [
Expand Down Expand Up @@ -1389,13 +1390,14 @@ def _flee_if_unwilling(session, group) -> list[Event]:
def _check_ends(session, *, party_retreating: bool) -> list[Event] | None:
"""Return the end-of-battle events when a terminal state holds, else `None`.

Defeat ends the battle and does nothing else. The session's wipe check makes
Defeat ends the battle and does nothing else. Defeat holds when no member stands,
which means every member is dead or petrified. The session's wipe check makes
the transition to `game_over` and posts the ending event, so a party lost to a
blade trap and a party lost to ogres end the same way.
"""
from osrlib.crawl import encounter as encounter_module

if not session.party.living_members():
if not _anyone_standing(session.party):
session.battle = None
session.encounter = None
return [BattleEndedEvent(code="battle.ended.defeat")]
Expand Down
10 changes: 6 additions & 4 deletions src/osrlib/crawl/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,10 +291,12 @@ class SessionMode(StrEnum):
[`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] is the only play command
the session accepts. Send one per round until the session leaves this mode."""
GAME_OVER = "game_over"
"""Every party member is dead and the session has ended. The session refuses play commands,
and referee commands still work. [`PlaceParty`][osrlib.crawl.commands.PlaceParty] is the only
way out: placing the dead party in town lets you buy *raise dead* at the temple with
[`PurchaseHealing`][osrlib.crawl.commands.PurchaseHealing]."""
"""No party member is left standing, each one dead or turned to stone, and the session has
ended. The session refuses play commands, and referee commands still work.
[`PlaceParty`][osrlib.crawl.commands.PlaceParty] is the only way out: placing the fallen
party in town lets you buy *raise dead* at the temple with
[`PurchaseHealing`][osrlib.crawl.commands.PurchaseHealing], or bring in a magic-user with
[`AddMember`][osrlib.crawl.commands.AddMember] to cast *stone to flesh* on a statue."""
VICTORY = "victory"
"""A quest that concludes the adventure has completed, and the session has ended. The
session refuses play commands and never leaves this mode, so show the game's final
Expand Down
12 changes: 12 additions & 0 deletions src/osrlib/crawl/encounter.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
StanceChangedEvent,
SurpriseRolledEvent,
)
from osrlib.crawl.party import _anyone_standing
from osrlib.data import load_classes

__all__ = [
Expand Down Expand Up @@ -368,6 +369,11 @@ def start_encounter(
basilisk's does, every living member meets that monster's petrifying gaze once, here, at any
distance and whatever the stance. Each saves versus paralysis or turns to stone. That holds when
the monsters are surprised too. A battle that opens from this encounter doesn't repeat the gaze.
When the gaze leaves no member standing, which means every member is dead or petrified, no
round runs against the statues. An attacking stance opens a battle that ends in defeat at once,
before the monsters' free round, and any other stance stops the encounter at the stance. A
command that opens the encounter, like [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters],
then ends the session in `game_over`.

A monster whose template has the `regeneration` ability, like a troll or a thoul, starts to
regenerate when this function opens its encounter. Before the function rolls anything, it
Expand Down Expand Up @@ -412,6 +418,7 @@ def start_encounter(
`pinned_stance`, and the [`StanceChangedEvent`][osrlib.crawl.events.StanceChangedEvent].
The saving throws of a basilisk's surprise gaze come among them. Then come the events of
anything the stance or surprise starts, which can include a battle and its first round.
When the gaze left nobody standing, a battle ends as it opens and no round runs.

Examples:
```python
Expand Down Expand Up @@ -575,6 +582,11 @@ def start_encounter(
)
)
return events
if not _anyone_standing(session.party):
# The surprise gaze turned the last members standing to stone, so no round runs against
# statues, and the command that opened the encounter ends the session in `game_over`. An
# attacking stance's battle, above, ends in defeat as it opens, before the monsters' free round.
return events
if stance is ReactionResult.HOSTILE:
state.hostile_deadline = 1 + state.monsters_skip_rounds
if party_surprised and not both:
Expand Down
15 changes: 9 additions & 6 deletions src/osrlib/crawl/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -1754,17 +1754,20 @@ class TimeAdvancedEvent(Event):


class GameOverEvent(Event):
"""Every party member is dead and the session has ended.
"""No party member is left standing, and the session has ended.

Emitted by the command during which the last member died, whatever killed them: a
lost battle, a trap, a fall down a chute, starvation, or a poison that took effect
while the referee moved the clock. It comes after that command's other events, and
the session mode becomes `game_over`.
A member stands while they are neither dead nor turned to stone. Emitted by the command
during which the last standing member died or was petrified, whatever felled them: a
lost battle, a trap, a fall down a chute, starvation, a poison that took effect
while the referee moved the clock, or a basilisk's gaze. It comes after that command's other
events, and the session mode becomes `game_over`.

The session refuses play commands from then on. Referee commands still work, and
[`PlaceParty`][osrlib.crawl.commands.PlaceParty] takes the session out of
`game_over`. Placing the fallen party in town lets the temple raise the dead with
[`PurchaseHealing`][osrlib.crawl.commands.PurchaseHealing].
[`PurchaseHealing`][osrlib.crawl.commands.PurchaseHealing]. The temple has no cure for
stone, so a statue needs *stone to flesh* from a magic-user, who can join the party in town
with [`AddMember`][osrlib.crawl.commands.AddMember].
"""

allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.game_over"})
Expand Down
21 changes: 19 additions & 2 deletions src/osrlib/crawl/party.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,13 @@ def living_members(self) -> list[Character]:
uses this list, so a member who dies stops counting toward movement, ranks, and checks at
once, without leaving the party.

A member turned to stone isn't dead, so they stay in this list and keep their rank. The
session's `game_over` check asks a narrower question: whether any member is neither dead
nor petrified.

Returns:
The members without the `dead` condition, in marching order. Empty when the whole party
has fallen, which is the session's `game_over` condition.
The members without the `dead` condition, in marching order. Empty when every member
is dead.
"""
return [member for member in self.members if not has_condition(member, Condition.DEAD)]

Expand Down Expand Up @@ -285,3 +289,16 @@ def reorder(self, character_ids: Sequence[str]) -> None:
if len(by_id) != len(self.members) or sorted(character_ids) != sorted(by_id):
raise ValueError("reorder must name every current member exactly once")
self.members = [by_id[character_id] for character_id in character_ids]


def _anyone_standing(party: Party) -> bool:
"""Whether any member stands, which means they are neither dead nor petrified.

The session's `game_over` check and a battle's defeat check ask this. A statue still counts
among [`living_members`][osrlib.crawl.party.Party.living_members] for every other rule, so a
party turned wholly to stone has living members but nobody standing.
"""
return any(
not has_condition(member, Condition.DEAD) and not has_condition(member, Condition.PETRIFIED)
for member in party.members
)
Loading
Loading