From e7b263c89643459d43c6aa623c0edcd5cfb02890 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 17:01:34 -0700 Subject: [PATCH 1/3] State the rule that a party turned wholly to stone has fallen A basilisk that surprises the party can turn every member to stone, and the session then stayed in the encounter with nobody able to act. The wipe check ran only after a death and asked only whether a member lived. These are the contract stubs and acceptance tests for the petrified-party chunk. A member stands while they are neither dead nor petrified. When a command's events kill or petrify a member and nobody is left standing, the session ends in game_over as a death wipe does, with battle.ended.defeat first in a battle. Party.living_members() keeps its meaning, and the check stays tied to the event, so a stone party placed in town stays there and stone to flesh from a magic-user who joins with AddMember brings a statue back. The acceptance tests in tests/test_petrified_party.py are marked xfail for the chunk. The tests that pass today are unmarked guards for a party with a member still standing. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- docs/adaptations.md | 4 + src/osrlib/crawl/battle.py | 3 +- src/osrlib/crawl/commands.py | 10 +- src/osrlib/crawl/events.py | 15 +- src/osrlib/crawl/party.py | 8 +- src/osrlib/crawl/session.py | 33 ++- tests/test_petrified_party.py | 465 ++++++++++++++++++++++++++++++++++ 7 files changed, 512 insertions(+), 26 deletions(-) create mode 100644 tests/test_petrified_party.py diff --git a/docs/adaptations.md b/docs/adaptations.md index 44e8ea6..af9ae38 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -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`. diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index e5d5a78..7242166 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -1389,7 +1389,8 @@ 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. """ diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 1465046..21793f3 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -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 diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 12269dd..7ea7429 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -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"}) diff --git a/src/osrlib/crawl/party.py b/src/osrlib/crawl/party.py index d95f035..23e3c1c 100644 --- a/src/osrlib/crawl/party.py +++ b/src/osrlib/crawl/party.py @@ -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)] diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index 1eb676d..7275826 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -1049,9 +1049,10 @@ def execute(self, command: Command) -> CommandResult: lists every code. After an accepted command applies, the session records each party member's death with what - killed them. If the command's events left no party member alive, the session ends in - `game_over`, and a [`GameOverEvent`][osrlib.crawl.events.GameOverEvent] closes the result. - A session that has already ended stays in its mode. + killed them. If the command's events killed or petrified a member and left no member + standing, which means neither dead nor turned to stone, the session ends in `game_over`, + and a [`GameOverEvent`][osrlib.crawl.events.GameOverEvent] closes the result. A session + that has already ended stays in its mode. The result contains the whole chain in log order: the handler's events, then, for each registered listener in turn, the events of the commands that listener executed, however @@ -1977,22 +1978,28 @@ def _record_deaths(self, events: Sequence[Event]) -> bool: return died def _end_on_party_wipe(self) -> list[Event]: - """End the session when the death just recorded left nobody standing. - - This is the only path into `game_over`, whatever killed the party: a lost battle, a trap, a fall, - starvation, a poison that finished the last member while the referee moved the clock. Any - open encounter or battle clears, because a session that has ended has no live play state, + """End the session when the death or petrification just resolved left nobody standing. + + A member stands while they are neither dead nor petrified. A party turned wholly to stone + has fallen, as a dead party has, though a statue still counts among + [`Party.living_members`][osrlib.crawl.party.Party.living_members] for every other rule. + This is the only path into `game_over`, whatever felled the party: a lost battle, a trap, a fall, + starvation, a poison that finished the last member while the referee moved the clock, a + basilisk's gaze, a cockatrice's beak. Any open encounter or battle clears, because a session + that has ended has no live play state, and the session reports the ending as one [`GameOverEvent`][osrlib.crawl.events.GameOverEvent]. - This runs because of a death, not because of the party's state: it runs only for a command whose - events killed a member, so carrying an already-fallen party to town, which is the first - step of a revival, never re-enters game over. A session already in a terminal mode is left - alone, so a party that dies after the adventure concluded stays in `victory`, and a + This runs because of an event, not because of the party's state: it runs only for a command whose + events killed or petrified a member, so carrying an already-fallen party to town, which is the first + step of a revival, never re-enters game over. That holds for statues as it does for the dead, + so a stone party placed in town stays in `town`, where *stone to flesh* from a magic-user who + joins with [`AddMember`][osrlib.crawl.commands.AddMember] can bring it back. A session already + in a terminal mode is left alone, so a party that dies after the adventure concluded stays in `victory`, and a later death in a session that has already ended changes nothing. Returns: - The ending event, or nothing when a member still lives. + The ending event, or nothing when a member still stands. """ if self.mode.terminal or self.party.living_members(): return [] diff --git a/tests/test_petrified_party.py b/tests/test_petrified_party.py new file mode 100644 index 0000000..30b7764 --- /dev/null +++ b/tests/test_petrified_party.py @@ -0,0 +1,465 @@ +"""Acceptance tests for a party turned wholly to stone, which has fallen, so the game ends. + +**The gap.** A basilisk that surprises the party gazes at every member as the encounter opens. When every member +fails the save, the whole party is stone. The wipe check ran only after a death and asked only whether any member +lived, and a statue isn't dead. So the session stayed in the encounter, or in a battle where the monsters bit statues +round after round, with nobody able to act. + +**The contract.** A member *stands* while they are neither dead nor petrified. When a command's events petrify or kill +a party member and no member is left standing, the session ends in `game_over` with the usual `GameOverEvent`, exactly +as a party wipe by death does: the ending comes last in the result, once, with the reason "the party has fallen", and +any open encounter or battle clears. In a battle, `battle.ended.defeat` comes just before the ending, as it does when +the last member dies. The causes include a gaze at the opening of an encounter or in battle, a petrifying bite or +beak, and a death that leaves only statues behind. + +**What doesn't change.** A party with one member standing plays on, even when that member stands behind a front rank +of statues. `Party.living_members()` keeps its meaning: a statue isn't dead, so it still holds its rank and still +counts for every rule that reads the living. The check stays tied to the events, not to the party's state, so carrying +a stone party to town with `PlaceParty` doesn't end the session again. In town, *stone to flesh* cast by a magic-user +who joins with `AddMember` brings a statue back. + +**Determinism.** The ending makes no draws, so the random number streams after an all-stone opening match those of +the same opening with one more member who stood apart. Saves load into `game_over`, and a replay ends the same way. +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 replay test can't attach a ward through a command, so it scans seeds for natural +failed saves instead. Each test asserts its own preconditions, so a test that stops exercising its case fails loudly. +""" + +import json + +import pytest + +from osrlib.core.character import Character, party_to_document +from osrlib.core.clock import TimeUnit +from osrlib.core.effects import Condition, has_condition, kill +from osrlib.core.events import ConditionGainedEvent, DeathEvent +from osrlib.core.ruleset import Ruleset +from osrlib.core.spells import MemorizedSpell +from osrlib.crawl.commands import ( + AddMember, + AdvanceTime, + CastSpell, + EngageBattle, + MoveParty, + PlaceParty, + RollDice, + SessionMode, + SpawnMonsters, +) +from osrlib.crawl.dungeon import Direction, PartyLocation +from osrlib.crawl.session import GameSession +from osrlib.persistence import load_game, replay_game, save_game, session_state +from test_monster_tactics import ( + ALWAYS_FAIL, + ALWAYS_PASS, + SEEDS, + _battle, + _blind, + _character, + _force_saves, + _front_rank_ids, + _members, + _monster_id, + _petrify, + _play, + _saves, + _session, +) + +CHUNK = pytest.mark.xfail(reason="chunk: petrified-party", strict=True) + +QUIET_STANCES = ("indifferent", "friendly") +"""Stances that open no battle and run no later procedure in the opening command.""" + +MAX_ROUNDS = 40 +"""Rounds a battle test plays while it waits for an attack to land.""" + + +# ---------------------------------------------------------------------- helpers + + +def _petrified_ids(events) -> list[str]: + """The ids that gained the `petrified` condition in these events, in order.""" + return [ + event.target_id + for event in events + if isinstance(event, ConditionGainedEvent) and event.condition == Condition.PETRIFIED.value + ] + + +def _game_over_events(events) -> list: + return [event for event in events if event.code == "session.game_over"] + + +def _assert_ended(game: GameSession, events) -> None: + """The shape of every wipe: `game_over`, no play state left, and the ending last and once.""" + assert game.mode is SessionMode.GAME_OVER, f"the session should have ended, but it is in {game.mode.value}" + assert game.battle is None and game.encounter is None + assert events[-1].code == "session.game_over" + assert events[-1].reason == "the party has fallen" + assert len(_game_over_events(events)) == 1 + + +def _assert_plays_on(game: GameSession, events, mode: SessionMode) -> None: + assert game.mode is mode + assert not _game_over_events(events) + + +def _stance(events) -> list[str]: + return [event.stance for event in events if event.code == "encounter.stance.changed"] + + +def _surprised_by_basilisk( + names: tuple[str, ...] = ("Hild", "Brakk"), + *, + save_bonuses: tuple[int, ...] | None = None, + blind: tuple[int, ...] = (), + dead: tuple[int, ...] = (), + stances: tuple[str, ...] = QUIET_STANCES, + seeds=SEEDS, +) -> tuple[GameSession, list]: + """A party a basilisk surprises through `SpawnMonsters`, with each member's saves forced, and its events. + + `save_bonuses` gives each member's forced save bonus, and every member fails by default. The members at the + indexes in `blind` are blinded and those in `dead` are killed before the basilisk arrives. The seed is chosen from + what happened before any wipe check could run: the party is surprised, the basilisk isn't, and the opening stance + is one of `stances`. + """ + bonuses = save_bonuses if save_bonuses is not None else (ALWAYS_FAIL,) * len(names) + assert len(bonuses) == len(names) + for seed in seeds: + game = _session(seed, _members(*names)) + for member, bonus in zip(game.party.members, bonuses, strict=True): + _force_saves(game, member, bonus) + for index in blind: + _blind(game, game.party.members[index]) + for index in dead: + kill(game.party.members[index]) + result = game.execute(SpawnMonsters(template_id="basilisk", count_fixed=1, distance_feet=60)) + assert result.accepted + started = next(event for event in result.events if event.code == "encounter.started") + if not started.party_surprised or started.monsters_surprised: + continue + opening = _stance(result.events) + if not opening or opening[0] not in stances: + continue + return game, list(result.events) + raise AssertionError(f"precondition: no seed let a basilisk surprise the party with a stance in {stances}") + + +def _all_stone(game: GameSession, events) -> None: + """Assert that the opening's gaze turned every living member to stone, and killed nobody.""" + for member in game.party.members: + if has_condition(member, Condition.DEAD): + continue + assert [save.code for save in _saves(events, member.id, "paralysis")] == ["combat.save.failed"], ( + f"precondition: {member.id} met the gaze and failed the save" + ) + assert has_condition(member, Condition.PETRIFIED), f"precondition: {member.id} is stone" + assert not [event for event in events if isinstance(event, DeathEvent)], "precondition: the gaze killed nobody" + + +def _stone_party_in_game_over() -> tuple[GameSession, list]: + """Two members turned to stone by a surprise gaze, in a session that has ended.""" + game, events = _surprised_by_basilisk() + _all_stone(game, events) + _assert_ended(game, events) + return game, events + + +def _magic_user() -> Character: + """A magic-user who can cast *stone to flesh*, fresh enough for `AddMember` to take.""" + character = _character("Wenna", "magic_user", level=11, hp=30) + character.spell_book = ("stone_to_flesh",) + character.memorized_spells = (MemorizedSpell(spell_id="stone_to_flesh"),) + return character + + +# ---------------------------------------------------------------------- the surprise gaze + + +class TestASurpriseGazeThatPetrifiesEveryone: + """The playtest's case: a basilisk surprises the party, everyone meets its gaze, and everyone fails.""" + + @CHUNK + @pytest.mark.parametrize("names", [("Hild",), ("Hild", "Brakk", "Sable")], ids=["alone", "three"]) + def test_the_session_ends_with_no_battle(self, names): + game, events = _surprised_by_basilisk(names) + + _all_stone(game, events) + assert "battle.started" not in [event.code for event in events], "precondition: no battle opened" + _assert_ended(game, events) + + @CHUNK + def test_the_session_ends_when_the_basilisk_attacks_at_once(self): + """The gaze comes as the encounter opens, and the stance carries the encounter into a battle.""" + game, events = _surprised_by_basilisk(stances=("attacks",)) + + _all_stone(game, events) + _assert_ended(game, events) + + @CHUNK + def test_a_member_already_dead_doesnt_keep_the_session_open(self): + """A mix of the dead and the stone: Hild fell earlier, and Brakk and Sable turn to stone.""" + game, events = _surprised_by_basilisk(("Hild", "Brakk", "Sable"), dead=(0,)) + hild, _, _ = game.party.members + + assert has_condition(hild, Condition.DEAD), "precondition: Hild is dead as the encounter opens" + assert not _saves(events, hild.id), "precondition: the dead meet no gaze" + _all_stone(game, events) + _assert_ended(game, events) + + @CHUNK + def test_the_ending_makes_no_draws(self): + """The streams match those of the same opening with a third member who stood apart, blind, and played on.""" + ended, ended_events = _surprised_by_basilisk(("Hild", "Brakk")) + seed = ended.master_seed + on, on_events = _surprised_by_basilisk( + ("Hild", "Brakk", "Sable"), save_bonuses=(ALWAYS_FAIL,) * 3, blind=(2,), seeds=[seed] + ) + sable = on.party.members[2] + + assert not _saves(on_events, sable.id), "precondition: blind Sable rolled no save" + assert not has_condition(sable, Condition.PETRIFIED) + _assert_plays_on(on, on_events, SessionMode.ENCOUNTER) + _all_stone(ended, ended_events) + _assert_ended(ended, ended_events) + assert ended.streams.export_states() == on.streams.export_states() + + +class TestAMemberStillStandingPlaysOn: + """Guards: a party with one member who is neither dead nor stone plays on as it did before.""" + + def test_a_member_who_passes_the_surprise_gaze_keeps_the_encounter_open(self): + game, events = _surprised_by_basilisk(("Hild", "Brakk", "Sable"), save_bonuses=(ALWAYS_FAIL,) * 2 + (20,)) + hild, brakk, sable = game.party.members + + assert has_condition(hild, Condition.PETRIFIED) and has_condition(brakk, Condition.PETRIFIED) + assert [save.code for save in _saves(events, sable.id)] == ["combat.save.passed"], "precondition: Sable passed" + _assert_plays_on(game, events, SessionMode.ENCOUNTER) + engage = game.execute(EngageBattle()) + assert engage.accepted + _assert_plays_on(game, engage.events, SessionMode.BATTLE) + + def test_a_blind_member_who_meets_no_gaze_keeps_the_encounter_open(self): + game, events = _surprised_by_basilisk(("Hild", "Brakk", "Sable"), blind=(2,)) + hild, brakk, sable = game.party.members + + assert has_condition(hild, Condition.PETRIFIED) and has_condition(brakk, Condition.PETRIFIED) + assert not _saves(events, sable.id), "precondition: blind Sable met no gaze" + _assert_plays_on(game, events, SessionMode.ENCOUNTER) + + def test_a_back_rank_member_keeps_the_battle_going_behind_a_front_rank_of_statues(self): + game = _battle("basilisk", ("Hild", "Brakk", "Sable")) + hild, brakk, sable = game.party.members + _force_saves(game, hild, ALWAYS_FAIL) + _force_saves(game, brakk, ALWAYS_FAIL) + _force_saves(game, sable, ALWAYS_PASS) + assert _front_rank_ids(game) == [hild.id, brakk.id], "precondition: Sable stands in the back rank" + + events = _play(game) + + assert sorted(_petrified_ids(events)) == sorted([hild.id, brakk.id]), "precondition: the gaze took the front" + assert not has_condition(sable, Condition.PETRIFIED) + _assert_plays_on(game, events, SessionMode.BATTLE) + later = _play(game) + _assert_plays_on(game, later, SessionMode.BATTLE) + + def test_a_front_rank_member_who_passes_the_gaze_keeps_the_battle_going(self): + game = _battle("basilisk") + hild, brakk = game.party.members + _force_saves(game, hild, ALWAYS_FAIL) + _force_saves(game, brakk, ALWAYS_PASS) + + events = _play(game) + + assert _petrified_ids(events) == [hild.id], "precondition: the gaze took Hild alone" + _assert_plays_on(game, events, SessionMode.BATTLE) + + def test_a_statue_is_still_one_of_the_living(self): + """`living_members()` keeps its meaning: a statue isn't dead, and it still holds its rank.""" + game = _battle("basilisk", ("Hild", "Brakk", "Sable")) + hild, brakk, sable = game.party.members + _petrify(game, hild) + + assert game.party.living_members() == [hild, brakk, sable] + assert _front_rank_ids(game) == [hild.id, brakk.id] + + +# ---------------------------------------------------------------------- battle + + +class TestABattleThatPetrifiesTheLastStandingMember: + """In a battle, the round that leaves nobody standing ends in defeat, then the session ends.""" + + @CHUNK + def test_a_gaze_that_takes_the_whole_party_ends_the_session(self): + game = _battle("basilisk") + hild, brakk = game.party.members + for member in (hild, brakk): + _force_saves(game, member, ALWAYS_FAIL) + + events = _play(game) + + assert sorted(_petrified_ids(events)) == sorted([hild.id, brakk.id]), "precondition: the gaze took both" + assert not [event for event in events if isinstance(event, DeathEvent)], "precondition: nobody died" + _assert_ended(game, events) + assert [event.code for event in events][-2:] == ["battle.ended.defeat", "session.game_over"] + + @CHUNK + def test_a_petrifying_beak_that_takes_the_last_member_ends_the_session(self): + game = _battle("cockatrice", ("Hild",)) + (hild,) = game.party.members + _force_saves(game, hild, ALWAYS_FAIL) + cockatrice = _monster_id(game) + + for _ in range(MAX_ROUNDS): + assert game.mode is SessionMode.BATTLE, "precondition: the battle runs until Hild turns to stone" + events = _play(game) + if has_condition(hild, Condition.PETRIFIED): + break + else: + raise AssertionError("precondition: the cockatrice's beak never landed") + + assert _petrified_ids(events) == [hild.id] + hits = [ + event + for event in events + if event.code in ("combat.attack.hit", "combat.attack.auto_hit") and event.attacker_id == cockatrice + ] + assert hits, "precondition: a hit from the cockatrice did it" + _assert_ended(game, events) + assert [event.code for event in events][-2:] == ["battle.ended.defeat", "session.game_over"] + + @CHUNK + def test_a_gaze_that_takes_the_last_member_beside_a_fallen_one_ends_the_session(self): + """A mix of the dead and the stone: Hild fell earlier, and the gaze takes Brakk.""" + game = _battle("basilisk") + hild, brakk = game.party.members + kill(hild) + _force_saves(game, brakk, ALWAYS_FAIL) + assert _front_rank_ids(game) == [brakk.id], "precondition: the dead leave the ranks" + + events = _play(game) + + assert _petrified_ids(events) == [brakk.id], "precondition: the gaze took Brakk" + _assert_ended(game, events) + + @CHUNK + def test_a_death_that_leaves_only_statues_ends_the_session(self): + """A mix of the dead and the stone: Hild is stone, and the goblin kills Brakk, the last member standing.""" + game = _battle("goblin") + hild, brakk = game.party.members + _petrify(game, hild) + brakk.current_hp = 1 + + for _ in range(MAX_ROUNDS): + assert game.mode is SessionMode.BATTLE, "precondition: the battle runs until Brakk falls" + events = _play(game) + if has_condition(brakk, Condition.DEAD): + break + else: + raise AssertionError("precondition: the goblin never killed Brakk") + + assert [event.target_id for event in events if isinstance(event, DeathEvent)] == [brakk.id] + assert has_condition(hild, Condition.PETRIFIED) and not has_condition(hild, Condition.DEAD) + _assert_ended(game, events) + assert [event.code for event in events][-2:] == ["battle.ended.defeat", "session.game_over"] + + +# ---------------------------------------------------------------------- persistence + + +class TestSavesAndReplays: + """A stone party's ending is ordinary state: it saves, loads, replays, and refuses play like any other.""" + + @CHUNK + def test_a_stone_ending_round_trips_through_a_save(self): + game, _ = _stone_party_in_game_over() + + restored = load_game(json.loads(json.dumps(save_game(game)))) + + assert restored.mode is SessionMode.GAME_OVER + assert restored.battle is None and restored.encounter is None + assert all(has_condition(member, Condition.PETRIFIED) for member in restored.party.members) + assert session_state(restored) == session_state(game) + + @CHUNK + def test_a_stone_ending_replays(self): + """Natural failed saves, because a forced save comes from a ward no command can attach.""" + for seed in SEEDS: + members = _members("Hild") + party_document = party_to_document(members) + game = _session(seed, members) + result = game.execute(SpawnMonsters(template_id="basilisk", count_fixed=1, distance_feet=60)) + assert result.accepted + started = next(event for event in result.events if event.code == "encounter.started") + if not started.party_surprised or started.monsters_surprised: + continue + (hild,) = game.party.members + if [save.code for save in _saves(result.events, hild.id, "paralysis")] != ["combat.save.failed"]: + continue + assert has_condition(hild, Condition.PETRIFIED), "precondition: Hild is stone" + _assert_ended(game, list(result.events)) + + replayed = replay_game(game.master_seed, party_document, game.adventure, Ruleset(), game.command_log) + + assert replayed.mode is SessionMode.GAME_OVER + assert [event.model_dump(mode="json") for event in replayed.event_log] == [ + event.model_dump(mode="json") for event in game.event_log + ] + assert session_state(replayed) == session_state(game) + return + raise AssertionError("precondition: no seed let a basilisk surprise Hild and turn her to stone") + + @CHUNK + def test_a_play_command_after_the_ending_is_refused_and_consumes_nothing(self): + game, _ = _stone_party_in_game_over() + before = session_state(game) + + result = game.execute(MoveParty(direction=Direction.EAST)) + + assert not result.accepted + assert [rejection.code for rejection in result.rejections] == ["session.command.wrong_mode"] + assert session_state(game) == before + + +# ---------------------------------------------------------------------- the way back + + +class TestTheWayBackFromStone: + """Carrying the statues to town doesn't end the session again, and *stone to flesh* brings one back there.""" + + @CHUNK + def test_placing_the_statues_in_town_doesnt_end_the_session_again(self): + game, _ = _stone_party_in_game_over() + + placed = game.execute(PlaceParty(location=PartyLocation(kind="town"))) + + assert placed.accepted + _assert_plays_on(game, placed.events, SessionMode.TOWN) + assert all(has_condition(member, Condition.PETRIFIED) for member in game.party.members), ( + "precondition: the party is still stone" + ) + for command in (AdvanceTime(n=1, unit=TimeUnit.DAY), RollDice(expression="1d6")): + result = game.execute(command) + assert result.accepted + _assert_plays_on(game, result.events, SessionMode.TOWN) + + @CHUNK + def test_stone_to_flesh_from_a_magic_user_who_joins_in_town_brings_a_statue_back(self): + game, _ = _stone_party_in_game_over() + hild, brakk = game.party.members + assert game.execute(PlaceParty(location=PartyLocation(kind="town"))).accepted + + joined = game.execute(AddMember(character=_magic_user())) + assert joined.accepted, [rejection.code for rejection in joined.rejections] + wenna_id = next(event.character_id for event in joined.events if event.code == "session.member.added") + cast = game.execute( + CastSpell(character_id=wenna_id, spell_id="stone_to_flesh", mode="restore", targets=(hild.id,)) + ) + + assert cast.accepted, [rejection.code for rejection in cast.rejections] + assert not has_condition(hild, Condition.PETRIFIED), "Hild is flesh again" + assert has_condition(brakk, Condition.PETRIFIED) + _assert_plays_on(game, [*joined.events, *cast.events], SessionMode.TOWN) From c8288d360dae32b26e576488cabb10ec6aea9c31 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 17:07:24 -0700 Subject: [PATCH 2/3] End the game when no party member is left standing A party turned wholly to stone has fallen. A member stands while they are neither dead nor petrified, which the new private helper _anyone_standing in party.py tests. Party.living_members() keeps its meaning, so a statue still holds its rank. The session runs its wipe check when a command's events kill a member or give one the petrified condition, and the check ends the session when nobody stands. A battle's defeat check uses the same test, so battle.ended.defeat comes just before the ending. When a basilisk's surprise gaze leaves nobody standing as an encounter opens, no round runs against the statues. An attacking stance still opens its battle, because test_avert_eyes.py requires battle.started there, but the battle ends in defeat as it opens, before the monsters' free round. Any other stance stops at the stance. No golden file changes. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 1 + src/osrlib/crawl/battle.py | 3 ++- src/osrlib/crawl/encounter.py | 12 +++++++++++ src/osrlib/crawl/party.py | 13 ++++++++++++ src/osrlib/crawl/session.py | 39 ++++++++++++++++++++++------------- 5 files changed, 53 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7dda69..0756874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 7242166..2027a65 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -278,6 +278,7 @@ MonstersLeftBehindEvent, SpellDeclaredEvent, ) +from osrlib.crawl.party import _anyone_standing from osrlib.data import load_classes, load_spells __all__ = [ @@ -1396,7 +1397,7 @@ def _check_ends(session, *, party_retreating: bool) -> list[Event] | None: """ 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")] diff --git a/src/osrlib/crawl/encounter.py b/src/osrlib/crawl/encounter.py index b8ba254..72bdf55 100644 --- a/src/osrlib/crawl/encounter.py +++ b/src/osrlib/crawl/encounter.py @@ -156,6 +156,7 @@ StanceChangedEvent, SurpriseRolledEvent, ) +from osrlib.crawl.party import _anyone_standing from osrlib.data import load_classes __all__ = [ @@ -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 @@ -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 @@ -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: diff --git a/src/osrlib/crawl/party.py b/src/osrlib/crawl/party.py index 23e3c1c..be611fe 100644 --- a/src/osrlib/crawl/party.py +++ b/src/osrlib/crawl/party.py @@ -289,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 + ) diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index 7275826..1a11c03 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -110,8 +110,9 @@ from osrlib.core.character import ADVANCEMENT_STREAM, Character from osrlib.core.classes import XpAwardResult, apply_xp, level_title from osrlib.core.clock import ROUNDS_PER_DAY, ROUNDS_PER_TURN, GameClock, TimeUnit -from osrlib.core.effects import EFFECTS_STREAM, EffectsLedger +from osrlib.core.effects import EFFECTS_STREAM, Condition, EffectsLedger from osrlib.core.events import ( + ConditionGainedEvent, DamageDealtEvent, DeathEvent, EffectExpiredEvent, @@ -178,7 +179,7 @@ TriggerFiredEvent, XpAwardedEvent, ) -from osrlib.crawl.party import Party +from osrlib.crawl.party import Party, _anyone_standing from osrlib.crawl.quests import ObjectiveSpec, QuestSpec from osrlib.data import load_equipment, load_magic_items, load_monsters from osrlib.errors import ContentValidationError @@ -1126,7 +1127,8 @@ def execute(self, command: Command) -> CommandResult: return CommandResult(accepted=False, rejections=tuple(rejections)) self.command_log.append(command) self._pile_saved_items(events) - if self._record_deaths(events): + self._record_deaths(events) + if self._felled_a_member(events): events.extend(self._end_on_party_wipe()) self.event_log.extend(events) # The two lists do different things. `accumulated` is what the listeners are dispatched @@ -1945,7 +1947,7 @@ def _pile_saved_items(self, events: Sequence[Event]) -> None: exploration._remove_instance(member, instance) pile.magic_items.append(instance) - def _record_deaths(self, events: Sequence[Event]) -> bool: + def _record_deaths(self, events: Sequence[Event]) -> None: """Record party deaths with the clock round and the cause just resolved. The cause is `poison` when the killing resolution was a poison save (a failed @@ -1954,16 +1956,11 @@ def _record_deaths(self, events: Sequence[Event]) -> bool: poison* uses the difference between poison and other causes. Args: - events: The just-executed command's events, in order. - - Returns: - True when a party member died in them, which is when the session checks for a party - wipe. Monsters and NPC adventurers have ids that aren't - members' and never count. + events: The just-executed command's events, in order. Monsters and NPC adventurers have + ids that aren't members', so their deaths are never recorded. """ member_ids = {member.id for member in self.party.members} cause = "unknown" - died = False for event in events: if isinstance(event, SavingThrowRolledEvent) and event.category == "death": if event.code == "combat.save.failed": @@ -1974,8 +1971,22 @@ def _record_deaths(self, events: Sequence[Event]) -> bool: cause = "damage" if isinstance(event, DeathEvent) and event.target_id in member_ids: self.death_records[event.target_id] = DeathRecord(round=self.clock.rounds, cause=cause) - died = True - return died + + def _felled_a_member(self, events: Sequence[Event]) -> bool: + """Whether these events killed or petrified a party member, which is when the session checks for a wipe. + + Monsters and NPC adventurers have ids that aren't members', so their deaths and statues never count. + """ + member_ids = {member.id for member in self.party.members} + return any( + (isinstance(event, DeathEvent) and event.target_id in member_ids) + or ( + isinstance(event, ConditionGainedEvent) + and event.condition == Condition.PETRIFIED.value + and event.target_id in member_ids + ) + for event in events + ) def _end_on_party_wipe(self) -> list[Event]: """End the session when the death or petrification just resolved left nobody standing. @@ -2001,7 +2012,7 @@ def _end_on_party_wipe(self) -> list[Event]: Returns: The ending event, or nothing when a member still stands. """ - if self.mode.terminal or self.party.living_members(): + if self.mode.terminal or _anyone_standing(self.party): return [] self.battle = None self.encounter = None From c03624d21d27c1e34b49febebad96fcc5515745d Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 17:09:33 -0700 Subject: [PATCH 3/3] Remove the petrified-party markers Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- tests/test_petrified_party.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/test_petrified_party.py b/tests/test_petrified_party.py index 30b7764..fdf47d3 100644 --- a/tests/test_petrified_party.py +++ b/tests/test_petrified_party.py @@ -66,8 +66,6 @@ _session, ) -CHUNK = pytest.mark.xfail(reason="chunk: petrified-party", strict=True) - QUIET_STANCES = ("indifferent", "friendly") """Stances that open no battle and run no later procedure in the opening command.""" @@ -181,7 +179,6 @@ def _magic_user() -> Character: class TestASurpriseGazeThatPetrifiesEveryone: """The playtest's case: a basilisk surprises the party, everyone meets its gaze, and everyone fails.""" - @CHUNK @pytest.mark.parametrize("names", [("Hild",), ("Hild", "Brakk", "Sable")], ids=["alone", "three"]) def test_the_session_ends_with_no_battle(self, names): game, events = _surprised_by_basilisk(names) @@ -190,7 +187,6 @@ def test_the_session_ends_with_no_battle(self, names): assert "battle.started" not in [event.code for event in events], "precondition: no battle opened" _assert_ended(game, events) - @CHUNK def test_the_session_ends_when_the_basilisk_attacks_at_once(self): """The gaze comes as the encounter opens, and the stance carries the encounter into a battle.""" game, events = _surprised_by_basilisk(stances=("attacks",)) @@ -198,7 +194,6 @@ def test_the_session_ends_when_the_basilisk_attacks_at_once(self): _all_stone(game, events) _assert_ended(game, events) - @CHUNK def test_a_member_already_dead_doesnt_keep_the_session_open(self): """A mix of the dead and the stone: Hild fell earlier, and Brakk and Sable turn to stone.""" game, events = _surprised_by_basilisk(("Hild", "Brakk", "Sable"), dead=(0,)) @@ -209,7 +204,6 @@ def test_a_member_already_dead_doesnt_keep_the_session_open(self): _all_stone(game, events) _assert_ended(game, events) - @CHUNK def test_the_ending_makes_no_draws(self): """The streams match those of the same opening with a third member who stood apart, blind, and played on.""" ended, ended_events = _surprised_by_basilisk(("Hild", "Brakk")) @@ -292,7 +286,6 @@ def test_a_statue_is_still_one_of_the_living(self): class TestABattleThatPetrifiesTheLastStandingMember: """In a battle, the round that leaves nobody standing ends in defeat, then the session ends.""" - @CHUNK def test_a_gaze_that_takes_the_whole_party_ends_the_session(self): game = _battle("basilisk") hild, brakk = game.party.members @@ -306,7 +299,6 @@ def test_a_gaze_that_takes_the_whole_party_ends_the_session(self): _assert_ended(game, events) assert [event.code for event in events][-2:] == ["battle.ended.defeat", "session.game_over"] - @CHUNK def test_a_petrifying_beak_that_takes_the_last_member_ends_the_session(self): game = _battle("cockatrice", ("Hild",)) (hild,) = game.party.members @@ -331,7 +323,6 @@ def test_a_petrifying_beak_that_takes_the_last_member_ends_the_session(self): _assert_ended(game, events) assert [event.code for event in events][-2:] == ["battle.ended.defeat", "session.game_over"] - @CHUNK def test_a_gaze_that_takes_the_last_member_beside_a_fallen_one_ends_the_session(self): """A mix of the dead and the stone: Hild fell earlier, and the gaze takes Brakk.""" game = _battle("basilisk") @@ -345,7 +336,6 @@ def test_a_gaze_that_takes_the_last_member_beside_a_fallen_one_ends_the_session( assert _petrified_ids(events) == [brakk.id], "precondition: the gaze took Brakk" _assert_ended(game, events) - @CHUNK def test_a_death_that_leaves_only_statues_ends_the_session(self): """A mix of the dead and the stone: Hild is stone, and the goblin kills Brakk, the last member standing.""" game = _battle("goblin") @@ -373,7 +363,6 @@ def test_a_death_that_leaves_only_statues_ends_the_session(self): class TestSavesAndReplays: """A stone party's ending is ordinary state: it saves, loads, replays, and refuses play like any other.""" - @CHUNK def test_a_stone_ending_round_trips_through_a_save(self): game, _ = _stone_party_in_game_over() @@ -384,7 +373,6 @@ def test_a_stone_ending_round_trips_through_a_save(self): assert all(has_condition(member, Condition.PETRIFIED) for member in restored.party.members) assert session_state(restored) == session_state(game) - @CHUNK def test_a_stone_ending_replays(self): """Natural failed saves, because a forced save comes from a ward no command can attach.""" for seed in SEEDS: @@ -412,7 +400,6 @@ def test_a_stone_ending_replays(self): return raise AssertionError("precondition: no seed let a basilisk surprise Hild and turn her to stone") - @CHUNK def test_a_play_command_after_the_ending_is_refused_and_consumes_nothing(self): game, _ = _stone_party_in_game_over() before = session_state(game) @@ -430,7 +417,6 @@ def test_a_play_command_after_the_ending_is_refused_and_consumes_nothing(self): class TestTheWayBackFromStone: """Carrying the statues to town doesn't end the session again, and *stone to flesh* brings one back there.""" - @CHUNK def test_placing_the_statues_in_town_doesnt_end_the_session_again(self): game, _ = _stone_party_in_game_over() @@ -446,7 +432,6 @@ def test_placing_the_statues_in_town_doesnt_end_the_session_again(self): assert result.accepted _assert_plays_on(game, result.events, SessionMode.TOWN) - @CHUNK def test_stone_to_flesh_from_a_magic_user_who_joins_in_town_brings_a_statue_back(self): game, _ = _stone_party_in_game_over() hild, brakk = game.party.members