From 15debbd664e9b120caa8cb1058d4b9a778bc381f Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 13:59:02 -0700 Subject: [PATCH 1/3] Add the AddMember contract and its acceptance tests Issue #172 asks for a logged referee command that adds a member to a running party. This commit adds the contract and the tests. The handler is a stub that raises NotImplementedError, so the chunk's tests are marked xfail for chunk: add-member. The contract: - AddMember(character=...) is legal in town and exploring. Every other mode refuses it with session.command.wrong_mode. During an encounter or a battle, surprise, the chase pace, and who declares are already settled. Once the session has ended, a new member would restart play. - The character must have no id (party.add.id_assigned), no condition or stat modifier (party.add.under_effects), and no magic item or valuable (party.add.carries_instances). - The session adds a copy at the back of the marching order. Its id is the next character-NNNN from the allocator, passing over ids that members already have. - It emits a player-visible MemberAddedEvent with the code session.member.added. - Coins the new member brings into a dungeon are added to the treasure snapshot, so they earn no experience. - The command isn't an authored consequence, because adventure documents carry no characters. The spec's consequence exclusions now name it. Existing tests changed with the census: the referee command set, its mode carve-outs, the samples, and the consequence exclusions. The fuzz leaves AddMember out until the chunk merges, and test_add_member.py fuzzes it under the marker. The rejection-code docs gate is marked xfail until the handler emits the new codes. The catalog page check now ignores the JSON Schema pattern on Character.race, which it read as cross-reference markup. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- docs/spec.md | 2 +- src/osrlib/crawl/commands.py | 107 ++++- src/osrlib/crawl/events.py | 31 ++ src/osrlib/crawl/session.py | 6 + src/osrlib/messages.py | 3 + tests/test_add_member.py | 711 +++++++++++++++++++++++++++++++++ tests/test_commands.py | 30 +- tests/test_crawl_properties.py | 68 +++- tests/test_docs_gates.py | 2 + tests/test_docs_site.py | 7 +- tests/test_events_kernel.py | 1 + tests/test_referee_items.py | 7 +- tests/test_terminal_modes.py | 7 +- 13 files changed, 961 insertions(+), 21 deletions(-) create mode 100644 tests/test_add_member.py diff --git a/docs/spec.md b/docs/spec.md index 994b1f7..9f1cede 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -200,7 +200,7 @@ An adventure can be won, and its content can be wired — the lever that opens t - Rejected consequences are dropped and recorded as referee-visibility notes — no retry and no queue, because deferred consequences firing later would be undebuggable. (A triggered ambush can collide with a wandering encounter opened by the same movement; the spawn drops and the note says so.) - Trigger-granted treasure follows the normal valuation rules at the moment it lands: granted mid-dungeon it counts in the next return delta, granted at town it earns no treasure XP. Authored XP belongs in `AwardXP`. - A consequence addresses characters through party selectors, never a literal id: `@party` expands at issue time to one command per living member in marching order, `@first` to the first living member, `@carrier` (on `RemoveItem` only) to the first living member who carries the item, and validation rejects a document that names a character id, because character ids are allocated per session and no document can know one. The expansion happens as the commands issue, so the log stays concrete and replays exactly. -- The consequence surface is the referee command vocabulary minus three: the lifecycle commands (the interpreter's own vocabulary, never authored), `IdentifyItem` (its item ids are session-scoped instance ids, the same unknowability as character ids), and `RollDice` (a draw whose result no authored construct can read, so an authored roll would be a no-op that perturbs the adjudication stream). +- The consequence surface is the referee command vocabulary minus four: the lifecycle commands (the interpreter's own vocabulary, never authored), `IdentifyItem` (its item ids are session-scoped instance ids, the same unknowability as character ids), `RollDice` (a draw whose result no authored construct can read, so an authored roll would be a no-op that perturbs the adjudication stream), and `AddMember` (its payload is a whole character, and an adventure document carries no characters). - Monster-defeat events emit at battle end, so "the portcullis opens mid-fight as the boss falls" is not authorable; stated so authors' expectations are set. **The interpreter and the command log.** The interpreter observes events, decides, and acts exclusively by issuing commands — anything it merely remembered would be lost to a replay. Quest, trigger, and journal state live in the engine session, mutated only by a small family of lifecycle referee commands the interpreter issues (authors author quest and trigger specs, never these commands): `MarkTriggerFired` records fired-state before a trigger's consequences issue; `ActivateQuest`, `RevealObjective`, `CompleteObjective`, and `CompleteQuest` advance quest state, emit the player-visible events carrying the authored narrative, and append journal entries; `AddJournalEntry` gives one-off triggers a journal voice; `RecordNote` has no state effect and emits a referee-visibility event — the mechanism behind dropped-consequence and truncation records. These are ordinary logged, replayed commands. The base command model gains an optional `source` field, ignored by execution, which the interpreter stamps with the owning trigger or quest id — so "why did the party get 500 XP" is answerable from the log alone. diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 0c16804..ecdfb3a 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -29,14 +29,15 @@ Referee commands are issued by game logic rather than by a player: a [`Listener`][osrlib.crawl.session.Listener] you write, the interpreter that runs an adventure's triggers, or an LLM referee. They grant and take away items and coins, award -XP, set flags, spawn monsters, end encounters, move the clock, and record quest progress. -Each one's summary starts with "Referee:". They're legal in every mode, and the session logs -and replays them like any other command. The exceptions are the ones that would restart play -once the session has ended: [`PlaceParty`][osrlib.crawl.commands.PlaceParty] is illegal in `victory`, and +XP, add party members, set flags, spawn monsters, end encounters, move the clock, and record +quest progress. Each one's summary starts with "Referee:". They're legal in every mode, and the +session logs and replays them like any other command. The exceptions are the ones that would +restart play once the session has ended: [`PlaceParty`][osrlib.crawl.commands.PlaceParty] is illegal in `victory`, and [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters] and [`SpawnNpcParty`][osrlib.crawl.commands.SpawnNpcParty] are illegal in `victory` and `game_over`. [`EndEncounter`][osrlib.crawl.commands.EndEncounter] closes an open encounter, so -it's legal only in `encounter` and `battle`. +it's legal only in `encounter` and `battle`. [`AddMember`][osrlib.crawl.commands.AddMember] +changes who is in the party, so it's legal only in `town` and `exploring`. Each command class's docstring states its contract in three sections. `Modes:` lists the session modes that accept it, `Rejections:` lists the codes it can be refused with, and `Events:` @@ -89,6 +90,7 @@ [`GrantCoins`][osrlib.crawl.commands.GrantCoins], [`RemoveItem`][osrlib.crawl.commands.RemoveItem], [`RemoveCoins`][osrlib.crawl.commands.RemoveCoins], + [`AddMember`][osrlib.crawl.commands.AddMember], [`AwardXP`][osrlib.crawl.commands.AwardXP], [`IdentifyItem`][osrlib.crawl.commands.IdentifyItem], [`SetFlag`][osrlib.crawl.commands.SetFlag], @@ -166,6 +168,7 @@ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, field_validator, model_validator +from osrlib.core.character import Character from osrlib.core.clock import TimeUnit from osrlib.core.dice import parse from osrlib.core.events import Event @@ -179,6 +182,7 @@ "CONSEQUENCE_COMMAND_CLASSES", "ActivateQuest", "AddJournalEntry", + "AddMember", "AdvanceTime", "AnyCommand", "AwardXP", @@ -260,8 +264,9 @@ class SessionMode(StrEnum): `game_over` and `victory` are the terminal modes, which mean the session has ended. Play commands are refused in both. Referee commands stay legal in both, except the ones that would restart play: [`PlaceParty`][osrlib.crawl.commands.PlaceParty] is illegal in - `victory`, and [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters] and - [`SpawnNpcParty`][osrlib.crawl.commands.SpawnNpcParty] are illegal in both. No play + `victory`, and [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters], + [`SpawnNpcParty`][osrlib.crawl.commands.SpawnNpcParty], and + [`AddMember`][osrlib.crawl.commands.AddMember] are illegal in both. No play command leaves a terminal mode. A referee's `PlaceParty` is the only way out of `game_over`, and no command leaves `victory`. """ @@ -359,8 +364,9 @@ class defined outside osrlib, and the set, and so do the referee commands [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters], [`SpawnNpcParty`][osrlib.crawl.commands.SpawnNpcParty], - [`PlaceParty`][osrlib.crawl.commands.PlaceParty], and - [`EndEncounter`][osrlib.crawl.commands.EndEncounter]. Each command's `Modes:` section lists + [`PlaceParty`][osrlib.crawl.commands.PlaceParty], + [`EndEncounter`][osrlib.crawl.commands.EndEncounter], and + [`AddMember`][osrlib.crawl.commands.AddMember]. Each command's `Modes:` section lists its set.""" @classmethod @@ -2900,6 +2906,86 @@ class RemoveCoins(Command): """The coins to take, by denomination. The purse must contain at least this many of each.""" +class AddMember(Command): + """Referee: add a character to the party during play. + + Use it to bring a new character into a running game: a replacement for a character who + died, a prisoner the party set free, or an adventurer the party meets in town. Build the + character the way you build a starting party member, with + [`create_character`][osrlib.core.character.create_character], and send it here. The + session gives the new member an id, puts them at the back of the marching order, and logs + the command, so a saved game and a replay both have the new member. + + The id comes from the session's [`IdAllocator`][osrlib.core.monsters.IdAllocator] under + the `character` prefix, the way [`GameSession.new`][osrlib.crawl.session.GameSession.new] + assigns the starting party's ids, so it's the next `character-NNNN`. The session passes + over an id that a member already has, so the new id is unique in the party. Read it from the + event, and pass it in the `character_id` of later commands. + + The new member joins at the back of the marching order, so the party's front rank stays as + it was. To move them, send [`ReorderParty`][osrlib.crawl.commands.ReorderParty], which is + legal in the same modes. + + The session adds a copy of `character`, not the object you pass, because the command log + keeps the command as it arrived and a replay adds the member from it. The object you pass + is left as it was, with no id. Read the new member with + [`GameSession.member`][osrlib.crawl.session.GameSession.member]. + + The character must arrive fresh. Every condition and stat modifier on a character belongs + to an effect on the ledger of the game that applied it, and this session can't end an + effect it doesn't have, so the session refuses a character who has any, dead included. A + magic item or a valuable has an instance id from the game that created it, which could + match an id this session hands out, so the session refuses a character who carries one. + Mundane gear and coins are fine. Give magic items afterwards with + [`GrantItem`][osrlib.crawl.commands.GrantItem], which creates them with this session's ids. + + When the party is in a dungeon, the coins the new member brings aren't treasure the party + found. The session adds them to + [`treasure_snapshot_cp`][osrlib.crawl.session.GameSession.treasure_snapshot_cp], so they + earn no experience when the party comes back to town. + + Adding a member makes no draws and takes no time. The command is legal only in `town` and + `exploring`. During an encounter or a battle, the party's surprise, its chase pace, and the + members who declare each round are already settled, and once the session has ended, a new + member would restart play. To replace a party that was wiped out, first move it to town with + [`PlaceParty`][osrlib.crawl.commands.PlaceParty], which takes the session out of + `game_over`, and then add the new character. + + Modes: + `town`, `exploring` + + Rejections: + - `session.command.wrong_mode` - an encounter or a battle is open, or the session + has ended. + - `party.add.id_assigned` - the character's `id` isn't `None`. The character has an + id from another game, or is already a member. + - `party.add.under_effects` - the character has a condition, like `dead` or + `asleep`, or a stat modifier. + - `party.add.carries_instances` - the character carries a magic item or a valuable, + equipped or not. + + The session checks in this order and reports the first that fails. + + Events: + [`MemberAddedEvent`][osrlib.crawl.events.MemberAddedEvent] with the new member's id, + name, class, and level. + """ + + allowed_modes: ClassVar[frozenset[SessionMode]] = _FIELD_MODES + + command_type: Literal["add_member"] = "add_member" + character: Character + """The character who joins, with `id` set to `None`, no conditions or stat modifiers, and no + magic items or valuables. Constructing the command copies the character, so a change you + make to your object afterwards doesn't reach the command, and the command log keeps the + character as you sent it.""" + + @field_validator("character", mode="after") + @classmethod + def _own_the_character(cls, value: Character) -> Character: + return value.model_copy(deep=True) + + class AwardXP(Command): """Referee: give one character experience points, apart from the XP the session awards. @@ -3743,6 +3829,7 @@ class CompleteQuest(Command): EndEncounter, RemoveItem, RemoveCoins, + AddMember, ) """Every command class, in a fixed order. These are the members of the [`AnyCommand`][osrlib.crawl.commands.AnyCommand] union. @@ -3815,6 +3902,8 @@ class CompleteQuest(Command): id, which the session assigns during play, so no document can contain one. - An adventure document can't use the result of [`RollDice`][osrlib.crawl.commands.RollDice], so an authored roll would only use up draws from the adjudication stream. +- [`AddMember`][osrlib.crawl.commands.AddMember] carries a whole character, and an adventure + document carries no characters. In an authored consequence, the `character_id` of a grant, a removal, or an award is a selector, `"@party"` or `"@first"`, never a member id, because the session assigns diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 807e4b4..27845c7 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -122,6 +122,7 @@ "LightEvent", "ListenedEvent", "LocationEnteredEvent", + "MemberAddedEvent", "MonsterDefeatedEvent", "MonsterFledEvent", "MonstersLeftBehindEvent", @@ -2044,6 +2045,35 @@ class ItemsRemovedEvent(Event): """The coins removed, as their value in gold pieces. 0 for a removal of items.""" +class MemberAddedEvent(Event): + """A new member joined the party during play. + + Emitted by [`AddMember`][osrlib.crawl.commands.AddMember]. The member is already in the + party, at the back of the marching order, so add them to the party on screen. Their full + sheet is in the views, under `character_id`. To find out why they joined, read the + `source` of the command that emitted the event. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.member.added"}) + """The only message code this event uses.""" + + event_type: Literal["member_added"] = "member_added" + """The event type, `member_added`, which identifies this class in serialized form.""" + code: str = "session.member.added" + """The message code, always `session.member.added`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the players see who joins them.""" + character_id: str + """The id the session gave the new member, like `"character-0005"`. Later commands and + events refer to the member by this id.""" + name: str + """The new member's name, so a log reads on its own without the party at hand.""" + class_id: str + """The new member's class id, like `"fighter"`.""" + level: int + """The new member's experience level when they joined.""" + + CRAWL_EVENT_CLASSES: tuple[type[Event], ...] = ( PartyMovedEvent, LocationEnteredEvent, @@ -2102,6 +2132,7 @@ class ItemsRemovedEvent(Event): QuestCompletedEvent, AdventureCompletedEvent, ItemsRemovedEvent, + MemberAddedEvent, ) """The event classes defined in this module, in the order they're declared. diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index 9121343..fb53c94 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -130,6 +130,7 @@ from osrlib.crawl.commands import ( ActivateQuest, AddJournalEntry, + AddMember, AdvanceTime, AwardXP, Command, @@ -2210,6 +2211,10 @@ def _handle_remove_coins(session: GameSession, command: RemoveCoins) -> tuple[li return [], [ItemsRemovedEvent(character_id=_member_id(member), coins_gp_value=command.coins.value_gp)] +def _handle_add_member(session: GameSession, command: AddMember) -> tuple[list[Rejection], list[Event]]: + raise NotImplementedError("chunk: add-member") + + def _handle_award_xp(session: GameSession, command: AwardXP) -> tuple[list[Rejection], list[Event]]: try: member = session.member(command.character_id) @@ -2633,4 +2638,5 @@ def _handle_end_encounter(session: GameSession, command: EndEncounter) -> tuple[ EndEncounter: _handle_end_encounter, RemoveItem: _handle_remove_item, RemoveCoins: _handle_remove_coins, + AddMember: _handle_add_member, } diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index 5ee5660..24074b7 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -437,6 +437,9 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: + (f"{event.coins_gp_value} gp in coin" if event.coins_gp_value else "") + "." ), + "session.member.added": lambda event: ( + f"{event.name}, a level {event.level} {event.class_id}, joins the party as {event.character_id}." + ), } diff --git a/tests/test_add_member.py b/tests/test_add_member.py new file mode 100644 index 0000000..3e498f2 --- /dev/null +++ b/tests/test_add_member.py @@ -0,0 +1,711 @@ +"""Acceptance tests for `AddMember`, the referee command that adds a character to a running party. + +Issue #172. A running session had no way to add a party member, because only +`GameSession.new` assigned member ids. So a game couldn't bring in a replacement for a +fallen character, and the retainer rules had nothing to build on. This chunk is the +command alone. The retainer rules come later, on top of it. + +The contract these tests pin: + +- `AddMember(character=...)` is a referee command, legal in `town` and `exploring` + only. In `encounter`, `battle`, `game_over`, and `victory` the session refuses it with + `session.command.wrong_mode`. +- The character must arrive fresh: no id (`party.add.id_assigned`), no condition or stat + modifier (`party.add.under_effects`), and no magic item or valuable + (`party.add.carries_instances`). The session reports the first of these that fails. + Every refusal costs nothing: no draws, no time, no log entry, and no change. +- An accepted command adds a copy of the character at the back of the marching order, + with the next `character-NNNN` id from the session's allocator, passing over ids that + members already have. It makes no draws and takes no time, and it emits one + player-visible `MemberAddedEvent` with the code `session.member.added`. +- Coins the new member brings into a dungeon aren't treasure the party found. +- Saves, the player view, and the referee view include the new member, and a replay of + the command log adds the same member with the same id. +- The new member plays: later commands address them by id, they slow the party when they + are slower, and they declare in battle. + +The tests in `TestTheContract` check the command and event models, and the two mode tests +in `TestRefusals` check the session's mode gate. All of these exist before the handler +does, so they pass on the stubs and carry no marker. Every other test is marked for the +chunk. +""" + +import json + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from pydantic import TypeAdapter + +from crawl_fixtures import build_adventure, build_party +from osrlib.core.abilities import AbilityScore +from osrlib.core.character import Character, party_to_document +from osrlib.core.clock import TimeUnit +from osrlib.core.effects import ActiveCondition, ActiveModifier, Condition +from osrlib.core.events import Visibility +from osrlib.core.items import CoinPurse, Inventory, MagicItemInstance, ValuableInstance, equip, purchase +from osrlib.core.ruleset import Ruleset +from osrlib.core.tables import ReactionResult +from osrlib.crawl.adventure import Adventure, TownSpec +from osrlib.crawl.commands import ( + ALL_COMMAND_CLASSES, + CONSEQUENCE_COMMAND_CLASSES, + AddMember, + AdvanceTime, + AnyCommand, + AwardXP, + BattleDeclaration, + EngageBattle, + EnterDungeon, + GrantCoins, + GrantItem, + MoveParty, + PlaceParty, + RemoveCoins, + ReorderParty, + ResolveBattleRound, + SessionMode, + TravelToTown, + parse_command, +) +from osrlib.crawl.dungeon import ( + AreaSpec, + Direction, + DungeonSpec, + Edge, + EdgeKind, + KeyedEncounter, + KeyedMonster, + LevelSpec, + WanderingSpec, +) +from osrlib.crawl.events import AdventureXpAwardEvent, MemberAddedEvent, parse_any_event +from osrlib.crawl.exploration import exploration_rate +from osrlib.crawl.party import Party +from osrlib.crawl.session import GameSession +from osrlib.data import load_classes, load_equipment +from osrlib.messages import format_message +from osrlib.persistence import load_game, replay_game, save_game, session_state +from test_crawl_properties import command_strategy +from test_terminal_modes import poison_everyone + +CHUNK = pytest.mark.xfail(reason="chunk: add-member", strict=True) + +SEED = 41 +STOCK_IDS = ("character-0001", "character-0002", "character-0003", "character-0004") +"""The ids `GameSession.new` gives the stock four-member party, in marching order.""" +NEW = "character-0005" +"""The id the first added member gets in a session with the stock party.""" +NEXT = "character-0006" +"""The id the second added member gets.""" + +FIELD_MODES = ("town", "exploring") +REFUSED_MODES = ("encounter", "battle", "game_over", "victory") + + +# ---------------------------------------------------------------------- fixtures + + +def recruit(name: str = "Ylva", class_id: str = "fighter", *, gp: int = 0, armour: str | None = None) -> Character: + """A fresh level 1 character with no id, the shape `AddMember` takes. + + Args: + name: The character's name. + class_id: The character's class. + gp: Gold pieces left in the purse after any armour is bought. + armour: A catalog id of body armour to buy and wear, or `None` for none. + """ + definition = load_classes().get(class_id) + inventory = Inventory() + if armour is not None: + template = load_equipment().get(armour) + inventory.purse = CoinPurse(gp=template.cost_gp) + equip(inventory, definition, purchase(inventory, template)) + inventory.purse = CoinPurse(gp=gp) + return Character( + name=name, + class_id=class_id, + race=definition.race, + level=1, + xp=0, + scores={ability: 12 for ability in AbilityScore}, + alignment="neutral", + max_hp=7, + current_hp=7, + inventory=inventory, + ) + + +def quiet_adventure() -> Adventure: + """The shared two-level delve, with no wandering monsters.""" + return build_adventure(wandering_chance=0) + + +def guard_adventure() -> Adventure: + """One level, a corridor three cells long, entered at (0, 0), with two goblins at (2, 0). + + The goblins expect the party and are pinned indifferent, so walking in always opens an + encounter and never a battle. They keep no hoard, and the level has no wandering monsters. + """ + edges = {f"{x},0:west": Edge(kind=EdgeKind.OPEN) for x in range(1, 3)} + guardroom = AreaSpec( + id="guardroom", + name="Guardroom", + cells=((2, 0),), + encounter=KeyedEncounter( + monsters=(KeyedMonster(template_id="goblin", count_fixed=2),), + aware=True, + stance=ReactionResult.INDIFFERENT, + hoard=False, + ), + ) + level = LevelSpec( + number=1, + width=3, + height=1, + entrance=(0, 0), + edges=edges, + areas=(guardroom,), + wandering=WanderingSpec(chance_in_six=0), + ) + return Adventure( + name="The Guardroom", + town=TownSpec(name="Threshold", travel_turns={"crypt": 1}), + dungeons=(DungeonSpec(id="crypt", name="Crypt", levels=(level,)),), + ) + + +def new_session(adventure: Adventure | None = None) -> GameSession: + """A session in town with the stock party and no listener.""" + session = GameSession.new(build_party(), adventure if adventure is not None else quiet_adventure(), seed=SEED) + assert tuple(member.id for member in session.party.members) == STOCK_IDS + return session + + +def enter(session: GameSession, dungeon_id: str = "delve") -> None: + assert session.execute(EnterDungeon(dungeon_id=dungeon_id)).accepted + assert session.mode is SessionMode.EXPLORING + + +def into_guardroom(session: GameSession) -> None: + """From the entrance, walk into the goblins' room and open the encounter.""" + for _ in range(2): + assert session.execute(MoveParty(direction=Direction.EAST)).accepted + assert session.mode is SessionMode.ENCOUNTER + + +def engage(session: GameSession) -> None: + assert session.execute(EngageBattle()).accepted + assert session.mode is SessionMode.BATTLE + + +def wipe(session: GameSession) -> None: + """Poison every member, and let one turn pass so the session ends in `game_over`.""" + poison_everyone(session) + assert session.execute(AdvanceTime(n=1, unit=TimeUnit.TURN)).accepted + assert session.mode is SessionMode.GAME_OVER + assert not session.party.living_members() + + +def session_in(mode: str) -> GameSession: + """A session with the stock party in `mode`, reached by play where play can reach it.""" + if mode == "town": + session = new_session() + elif mode == "exploring": + session = new_session() + enter(session) + elif mode in ("encounter", "battle"): + session = new_session(guard_adventure()) + enter(session, "crypt") + into_guardroom(session) + if mode == "battle": + engage(session) + elif mode == "game_over": + session = new_session() + wipe(session) + else: + # Only a concluding quest enters `victory`, so the tests assign the mode directly, + # as the terminal-mode tests do. + session = new_session() + session.mode = SessionMode.VICTORY + assert session.mode.value == mode + return session + + +def stream_states(session: GameSession) -> dict: + return {key: state.model_dump(mode="json") for key, state in session.streams.export_states().items()} + + +def frozen_state(session: GameSession) -> str: + return json.dumps(session_state(session), sort_keys=True) + + +def assert_refused(session: GameSession, command: AddMember, code: str) -> None: + """The command is refused with `code` alone, at no cost: no draws, no time, no change.""" + state = frozen_state(session) + streams = stream_states(session) + rounds = session.clock.rounds + logged = len(session.command_log) + members = len(session.party.members) + result = session.execute(command) + assert not result.accepted + assert [rejection.code for rejection in result.rejections] == [code] + assert list(result.events) == [] + assert stream_states(session) == streams, "a refusal draws nothing" + assert session.clock.rounds == rounds, "a refusal spends no time" + assert len(session.command_log) == logged, "a refusal is not logged" + assert len(session.party.members) == members, "a refusal adds nobody" + assert frozen_state(session) == state, "a refusal changes nothing" + + +def add(session: GameSession, character: Character | None = None) -> MemberAddedEvent: + """Add a member and return the one event the command emits.""" + result = session.execute(AddMember(character=character if character is not None else recruit())) + assert result.accepted, result.rejections + assert len(result.events) == 1 + event = result.events[0] + assert isinstance(event, MemberAddedEvent) + return event + + +def member_ids(session: GameSession) -> tuple[str | None, ...]: + return tuple(member.id for member in session.party.members) + + +def replayed(session: GameSession, adventure: Adventure) -> GameSession: + """A listener-free replay of the session's log, from serialized commands.""" + return replay_game( + SEED, + party_to_document(build_party().members), + adventure, + Ruleset(), + [json.loads(json.dumps(command.model_dump(mode="json"))) for command in session.command_log], + ) + + +# ---------------------------------------------------------------------- the models + + +class TestTheContract: + """The command and event models. These exist before the handler, so they carry no marker.""" + + def test_it_is_appended_after_the_removal_commands(self): + assert ALL_COMMAND_CLASSES.index(AddMember) == ALL_COMMAND_CLASSES.index(RemoveCoins) + 1 + assert AddMember.model_fields["command_type"].default == "add_member" + + def test_it_is_legal_in_town_and_exploring_only(self): + assert AddMember.allowed_modes == frozenset({SessionMode.TOWN, SessionMode.EXPLORING}) + + def test_it_is_not_an_authored_consequence(self): + assert AddMember not in CONSEQUENCE_COMMAND_CLASSES + + def test_it_round_trips_through_json(self): + command = AddMember(character=recruit(gp=12, armour="leather"), source="game:tavern") + payload = json.loads(json.dumps(command.model_dump(mode="json"))) + assert payload["command_type"] == "add_member" + assert payload["source"] == "game:tavern" + assert payload["character"]["id"] is None + assert payload["character"]["name"] == "Ylva" + parsed = parse_command(payload) + assert parsed == command + assert type(parsed) is AddMember + assert TypeAdapter(AnyCommand).validate_python(payload) == command + + def test_constructing_the_command_copies_the_character(self): + hire = recruit() + command = AddMember(character=hire) + assert command.character is not hire + hire.current_hp = 2 + hire.name = "Somebody else" + assert command.character.current_hp == 7 + assert command.character.name == "Ylva" + + def test_the_event_round_trips_and_renders(self): + event = MemberAddedEvent(character_id=NEW, name="Ylva", class_id="fighter", level=1) + assert event.event_type == "member_added" + assert event.code == "session.member.added" + assert MemberAddedEvent.allowed_codes == frozenset({"session.member.added"}) + assert event.visibility is Visibility.PLAYER + assert parse_any_event(json.loads(json.dumps(event.model_dump(mode="json")))) == event + assert format_message(event) == "Ylva, a level 1 fighter, joins the party as character-0005." + + +# ---------------------------------------------------------------------- acceptance + + +class TestAccepted: + @CHUNK + @pytest.mark.parametrize("mode", FIELD_MODES) + def test_the_member_joins_at_the_back_of_the_marching_order(self, mode): + session = session_in(mode) + location = session.dungeon_state.location.model_dump(mode="json") + event = add(session, recruit("Ylva", "cleric")) + assert event == MemberAddedEvent(character_id=NEW, name="Ylva", class_id="cleric", level=1) + assert member_ids(session) == (*STOCK_IDS, NEW) + assert session.member(NEW).name == "Ylva" + assert session.mode.value == mode, "adding a member changes no mode" + assert session.dungeon_state.location.model_dump(mode="json") == location, "and moves nobody" + + @CHUNK + @pytest.mark.parametrize("mode", FIELD_MODES) + def test_it_draws_nothing_takes_no_time_and_is_logged(self, mode): + session = session_in(mode) + streams = stream_states(session) + rounds = session.clock.rounds + command = AddMember(character=recruit()) + assert session.execute(command).accepted + assert stream_states(session) == streams + assert session.clock.rounds == rounds + assert session.command_log[-1] is command + + @CHUNK + def test_the_event_is_player_visible_and_logged(self): + session = new_session() + event = add(session) + assert event.visibility is Visibility.PLAYER + assert session.event_log[-1] == event + assert format_message(event) == "Ylva, a level 1 fighter, joins the party as character-0005." + + @CHUNK + def test_the_session_adds_a_copy_and_leaves_the_command_as_it_arrived(self): + session = new_session() + hire = recruit(gp=30, armour="chainmail") + command = AddMember(character=hire) + assert session.execute(command).accepted + member = session.member(NEW) + assert member is not hire + assert member is not command.character + assert hire.id is None, "the caller's object gets no id" + assert command.character.id is None, "the logged command keeps the character as it arrived" + assert member.model_dump(mode="json", exclude={"id"}) == hire.model_dump(mode="json", exclude={"id"}) + member.current_hp = 1 + member.inventory.purse.gp = 0 + assert command.character.current_hp == 7 + assert command.character.inventory.purse.gp == 30 + + @CHUNK + def test_mundane_gear_and_coins_come_along(self): + session = new_session() + hire = recruit(gp=25, armour="plate_mail") + add(session, hire) + member = session.member(NEW) + assert member.inventory.worn_armour is not None + assert member.inventory.worn_armour.template.id == "plate_mail" + assert member.inventory.purse.gp == 25 + assert member.armour_class == hire.armour_class + + +class TestIds: + @CHUNK + def test_the_new_id_is_the_next_character_id(self): + session = new_session() + assert session.allocator.counters["character"] == len(STOCK_IDS) + assert add(session).character_id == NEW + assert session.allocator.counters["character"] == len(STOCK_IDS) + 1 + + @CHUNK + def test_two_members_take_consecutive_ids(self): + session = new_session() + assert add(session, recruit("Ylva")).character_id == NEW + assert add(session, recruit("Orm")).character_id == NEXT + assert member_ids(session) == (*STOCK_IDS, NEW, NEXT) + assert [member.name for member in session.party.members[-2:]] == ["Ylva", "Orm"] + + @CHUNK + def test_the_id_passes_over_ids_members_already_have(self): + # `GameSession.new` keeps an id a member already has, such as one from an earlier + # session, and allocates nothing for it. So the allocator's next id can be taken. + members = build_party().members[:2] + members[0].id = "character-0001" + members[1].id = "character-0002" + session = GameSession.new(Party(members=members), quiet_adventure(), seed=SEED) + assert session.allocator.counters.get("character", 0) == 0 + event = add(session) + assert event.character_id == "character-0003" + assert member_ids(session) == ("character-0001", "character-0002", "character-0003") + + @CHUNK + def test_no_entity_of_another_kind_takes_an_id(self): + session = new_session() + counters = dict(session.allocator.counters) + add(session) + counters["character"] += 1 + assert session.allocator.counters == counters + + +class TestMarchingOrder: + @CHUNK + def test_the_front_rank_stays_as_it_was(self): + session = new_session() + front = [member.id for member in session.party.ranks(2)[0]] + add(session) + ranks = session.party.ranks(2) + assert [member.id for member in ranks[0]] == front + assert ranks[-1][-1].id == NEW + + @CHUNK + def test_a_dead_member_ahead_stays_in_place(self): + from osrlib.core.effects import kill + + session = new_session() + kill(session.member(STOCK_IDS[3])) + add(session) + assert member_ids(session) == (*STOCK_IDS, NEW) + assert [member.id for member in session.party.living_members()] == [*STOCK_IDS[:3], NEW] + + @CHUNK + @pytest.mark.parametrize("mode", FIELD_MODES) + def test_reorder_party_can_move_the_new_member_to_the_front(self, mode): + session = session_in(mode) + add(session) + order = (NEW, *STOCK_IDS) + assert session.execute(ReorderParty(order=order)).accepted + assert member_ids(session) == order + assert session.party.ranks(2)[0][0].id == NEW + + +# ---------------------------------------------------------------------- refusals + + +class TestRefusals: + # The session's mode gate runs before any handler, so the two mode tests hold on the stub + # and carry no marker. + + @pytest.mark.parametrize("mode", REFUSED_MODES) + def test_every_other_mode_refuses_it(self, mode): + session = session_in(mode) + assert_refused(session, AddMember(character=recruit()), "session.command.wrong_mode") + + @CHUNK + def test_a_character_with_an_id_is_refused(self): + session = new_session() + hire = recruit() + hire.id = "character-0099" + assert_refused(session, AddMember(character=hire), "party.add.id_assigned") + + @CHUNK + def test_a_current_member_is_refused(self): + session = new_session() + assert_refused(session, AddMember(character=session.member(STOCK_IDS[0])), "party.add.id_assigned") + + @CHUNK + def test_a_dead_character_is_refused(self): + session = new_session() + hire = recruit() + hire.conditions = (ActiveCondition(condition=Condition.DEAD),) + assert_refused(session, AddMember(character=hire), "party.add.under_effects") + + @CHUNK + def test_a_character_under_a_timed_condition_is_refused(self): + session = new_session() + hire = recruit() + hire.conditions = (ActiveCondition(condition=Condition.ASLEEP, effect_id="effect-0001"),) + assert_refused(session, AddMember(character=hire), "party.add.under_effects") + + @CHUNK + def test_a_character_with_a_stat_modifier_is_refused(self): + session = new_session() + hire = recruit() + hire.stat_modifiers = (ActiveModifier(kind="attack_bonus", value=1, effect_id="effect-0001"),) + assert_refused(session, AddMember(character=hire), "party.add.under_effects") + + @CHUNK + def test_a_carried_magic_item_is_refused(self): + session = new_session() + hire = recruit() + hire.inventory.items.append(MagicItemInstance(instance_id="magic-item-0001", template_id="potion_of_healing")) + assert_refused(session, AddMember(character=hire), "party.add.carries_instances") + + @CHUNK + def test_an_equipped_magic_item_is_refused(self): + session = new_session() + hire = recruit() + hire.inventory.rings = [MagicItemInstance(instance_id="magic-item-0001", template_id="ring_of_protection")] + assert_refused(session, AddMember(character=hire), "party.add.carries_instances") + + @CHUNK + def test_a_valuable_is_refused(self): + session = new_session() + hire = recruit() + hire.inventory.valuables = [ValuableInstance(instance_id="valuable-0001", kind="gem", value_gp=50)] + assert_refused(session, AddMember(character=hire), "party.add.carries_instances") + + @CHUNK + def test_the_first_failing_check_is_the_one_reported(self): + session = new_session() + hire = recruit() + hire.inventory.valuables = [ValuableInstance(instance_id="valuable-0001", kind="gem", value_gp=50)] + hire.conditions = (ActiveCondition(condition=Condition.DEAD),) + assert_refused(session, AddMember(character=hire), "party.add.under_effects") + hire.id = "character-0099" + assert_refused(session, AddMember(character=hire), "party.add.id_assigned") + + def test_the_mode_is_checked_before_the_character(self): + session = session_in("encounter") + hire = recruit() + hire.id = "character-0099" + assert_refused(session, AddMember(character=hire), "session.command.wrong_mode") + + +# ---------------------------------------------------------------------- treasure + + +class TestTreasure: + @CHUNK + def test_coins_brought_into_a_dungeon_earn_no_experience(self): + session = new_session() + enter(session) + snapshot = session.treasure_snapshot_cp + assert snapshot is not None + add(session, recruit(gp=500)) + assert session.treasure_snapshot_cp == snapshot + 500 * 100 + result = session.execute(TravelToTown()) + assert result.accepted + assert not [event for event in result.events if isinstance(event, AdventureXpAwardEvent)] + assert session.member(NEW).xp == 0 + + @CHUNK + def test_coins_brought_in_town_leave_the_snapshot_alone(self): + session = new_session() + assert session.treasure_snapshot_cp is None + add(session, recruit(gp=500)) + assert session.treasure_snapshot_cp is None + enter(session) + assert session.treasure_snapshot_cp == session.party_valuation_cp() + + +# ---------------------------------------------------------------------- views, saves, replay + + +class TestViewsSavesAndReplay: + @CHUNK + def test_the_player_view_lists_the_new_member_last(self): + session = new_session() + add(session) + view = session.view(Visibility.PLAYER) + assert tuple(sheet.id for sheet in view.party) == (*STOCK_IDS, NEW) + sheet = view.party[-1] + assert (sheet.name, sheet.class_id, sheet.level, sheet.current_hp, sheet.max_hp) == ("Ylva", "fighter", 1, 7, 7) + + @CHUNK + def test_the_referee_view_has_the_whole_sheet(self): + session = new_session() + add(session, recruit(gp=10, armour="leather")) + view = session.view(Visibility.REFEREE) + assert view.party.members[-1] == session.member(NEW) + assert view.party.members[-1] is not session.member(NEW) + + @CHUNK + @pytest.mark.parametrize("mode", FIELD_MODES) + def test_a_save_round_trips_the_new_member(self, mode): + session = session_in(mode) + add(session, recruit(gp=10, armour="leather")) + restored = load_game(json.loads(json.dumps(save_game(session)))) + assert session_state(restored) == session_state(session) + assert restored.member(NEW) == session.member(NEW) + assert add(restored, recruit("Orm")).character_id == NEXT, "the restored allocator continues the count" + + @CHUNK + def test_a_replay_adds_the_same_member_with_the_same_id(self): + session = new_session() + enter(session) + add(session, recruit(gp=10, armour="leather")) + assert session.execute(AwardXP(character_id=NEW, amount=250)).accepted + assert session.execute(MoveParty(direction=Direction.EAST)).accepted + add(session, recruit("Orm", "thief")) + again = replayed(session, quiet_adventure()) + assert member_ids(again) == (*STOCK_IDS, NEW, NEXT) + assert session_state(again) == session_state(session) + + @CHUNK + def test_a_replay_from_the_live_log_shares_no_character_with_the_original(self): + session = new_session() + add(session) + again = replay_game( + SEED, party_to_document(build_party().members), quiet_adventure(), Ruleset(), session.command_log + ) + assert again.member(NEW) is not session.member(NEW) + session.member(NEW).current_hp = 1 + assert again.member(NEW).current_hp == 7 + + +# ---------------------------------------------------------------------- the new member plays + + +class TestTheNewMemberPlays: + @CHUNK + def test_later_referee_commands_address_the_new_member(self): + session = new_session() + add(session) + for command in ( + GrantItem(character_id=NEW, item_id="torch", quantity=6), + GrantCoins(character_id=NEW, coins={"gp": 40}), + RemoveCoins(character_id=NEW, coins={"gp": 15}), + AwardXP(character_id=NEW, amount=100), + ): + assert session.execute(command).accepted, command.command_type + member = session.member(NEW) + assert member.inventory.purse.gp == 25 + assert member.xp == 100 + assert any(instance.template.id == "torch" for instance in member.inventory.all_instances()) + + @CHUNK + def test_the_new_member_moves_with_the_party(self): + session = new_session() + enter(session) + assert exploration_rate(session) == 120 + add(session, recruit(armour="plate_mail")) + assert exploration_rate(session) == 60, "the party walks at the pace of its slowest member" + result = session.execute(MoveParty(direction=Direction.EAST)) + assert result.accepted + assert session.dungeon_state.location.position == (1, 0) + assert member_ids(session) == (*STOCK_IDS, NEW) + + @CHUNK + def test_the_new_member_declares_in_battle(self): + session = new_session(guard_adventure()) + enter(session, "crypt") + add(session) + into_guardroom(session) + engage(session) + declarers = session.view(Visibility.PLAYER).encounter.declarers + assert NEW in declarers + assert declarers[-1] == NEW + holds = tuple(BattleDeclaration(character_id=member_id, action="hold") for member_id in declarers) + assert session.execute(ResolveBattleRound(declarations=holds)).accepted + + @CHUNK + def test_a_wiped_party_is_replaced_in_town_and_plays_on(self): + session = session_in("game_over") + assert session.execute(PlaceParty(location={"kind": "town"})).accepted + assert session.mode is SessionMode.TOWN + add(session, recruit("Ylva")) + assert [member.id for member in session.party.living_members()] == [NEW] + enter(session) + assert session.execute(MoveParty(direction=Direction.EAST)).accepted + + +# ---------------------------------------------------------------------- the fuzz contract + + +@CHUNK +@settings(max_examples=40, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@given( + seed=st.integers(min_value=0, max_value=2**32), + commands=st.lists(command_strategy(include_pending=True), min_size=1, max_size=25), +) +def test_fuzzed_sequences_with_added_members_never_raise_and_round_trip(seed, commands): + """Schema-valid commands, `AddMember` among them, reject and never throw. Ids stay unique. + + A fresh member joins in town first, so every example has one, and the fuzz can address + them as `character-0005`. + """ + session = GameSession.new(build_party(), build_adventure(), seed=seed) + add(session) + for command in commands: + session.execute(command) # must never raise + ids = [member.id for member in session.party.members] + assert None not in ids + assert len(set(ids)) == len(ids) + restored = load_game(json.loads(json.dumps(save_game(session)))) + assert session_state(restored) == session_state(session) diff --git a/tests/test_commands.py b/tests/test_commands.py index 748370f..9443bbc 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -4,6 +4,8 @@ import pytest +from osrlib.core.abilities import AbilityScore +from osrlib.core.character import Character from osrlib.core.validation import Rejection from osrlib.crawl.commands import ( ALL_COMMAND_CLASSES, @@ -42,6 +44,7 @@ "end_encounter", "remove_item", "remove_coins", + "add_member", } # The referee commands a terminal session withholds, each because it would resume @@ -61,9 +64,31 @@ "end_encounter": frozenset(SessionMode) - frozenset({SessionMode.ENCOUNTER, SessionMode.BATTLE}), } +# The referee commands that change who is in the party, so only the two modes where the +# party is between encounters accept them: an open encounter or battle has already +# settled surprise, the chase pace, and who declares, and an ended session would restart. +ROSTER_CARVE_OUTS = { + "add_member": frozenset(SessionMode) - frozenset({SessionMode.TOWN, SessionMode.EXPLORING}), +} + # Every referee command's withheld modes, keyed by command type. A referee command # missing from this map is legal in every mode. -REFEREE_CARVE_OUTS = RESUME_PLAY_CARVE_OUTS | ENCOUNTER_BOUND_CARVE_OUTS +REFEREE_CARVE_OUTS = RESUME_PLAY_CARVE_OUTS | ENCOUNTER_BOUND_CARVE_OUTS | ROSTER_CARVE_OUTS + + +def _recruit() -> Character: + """A fresh level 1 fighter with no id, the shape `AddMember` carries.""" + return Character( + name="Ylva", + class_id="fighter", + race="human", + level=1, + xp=0, + scores={ability: 12 for ability in AbilityScore}, + alignment="neutral", + max_hp=7, + current_hp=7, + ) def sample_command(command_class): @@ -127,6 +152,7 @@ def sample_command(command_class): "EndEncounter": dict(outcome="parley", resolve_area=True), "RemoveItem": dict(character_id="pc-1", item_id="torch", quantity=2), "RemoveCoins": dict(character_id="pc-1", coins={"gp": 5}), + "AddMember": dict(character=_recruit()), } return command_class(**samples[command_class.__name__]) @@ -210,6 +236,8 @@ def test_the_exclusions_are_the_lifecycle_family_identify_item_and_roll_dice(sel "identify_item", # A draw no authored construct reads. "roll_dice", + # A whole character, which no adventure document carries. + "add_member", } def test_the_consequence_classes_are_a_subset_of_the_command_census(self): diff --git a/tests/test_crawl_properties.py b/tests/test_crawl_properties.py index 5b83a42..4537d80 100644 --- a/tests/test_crawl_properties.py +++ b/tests/test_crawl_properties.py @@ -15,6 +15,8 @@ build_party, build_portcullis_adventure, ) +from osrlib.core.abilities import AbilityScore +from osrlib.core.character import Character from osrlib.core.events import Visibility from osrlib.core.items import Coins from osrlib.crawl.commands import ( @@ -33,7 +35,16 @@ from osrlib.crawl.session import GameSession from osrlib.crawl.triggers import TownEnteredPattern -CHARACTER_IDS = ["character-0001", "character-0002", "character-0003", "character-0004", "character-0099"] +# "character-0005" is the id the first member an `AddMember` adds gets, so the fuzz can +# address a member who joined during play. +CHARACTER_IDS = [ + "character-0001", + "character-0002", + "character-0003", + "character-0004", + "character-0099", + "character-0005", +] ITEM_IDS = [ "torch", "sword", @@ -105,11 +116,62 @@ def plant_magic_items(session) -> None: ) -def command_strategy(): - """Schema-valid commands with plausible-to-nonsense field values.""" +PENDING_COMMANDS = frozenset({"AddMember"}) +"""Command classes whose handler belongs to a chunk still in flight. + +The fuzz leaves them out until the chunk merges, because a stub handler raises. The +chunk's own tests fuzz them under its xfail marker by passing `include_pending=True`. +The lead empties this set when the chunk's markers come off. +""" + +RECRUIT_SHAPES = ("fresh", "fresh", "with_id", "dead", "with_magic") +"""The characters the fuzz offers `AddMember`: mostly fresh ones, plus one shape for each refusal. + +"fresh" is listed twice so the accepted path is the likelier draw. +""" + + +def recruit(shape: str) -> Character: + """Build a level 1 fighter in one of the `RECRUIT_SHAPES`.""" + from osrlib.core.effects import ActiveCondition, Condition + from osrlib.core.items import Inventory, MagicItemInstance + + character = Character( + name="Ylva", + class_id="fighter", + race="human", + level=1, + xp=0, + scores={ability: 12 for ability in AbilityScore}, + alignment="neutral", + max_hp=7, + current_hp=7, + ) + if shape == "with_id": + character.id = "character-0002" + elif shape == "dead": + character.conditions = (ActiveCondition(condition=Condition.DEAD),) + elif shape == "with_magic": + character.inventory = Inventory( + items=[MagicItemInstance(instance_id="magic-item-0001", template_id="potion_of_healing")] + ) + return character + + +def command_strategy(*, include_pending: bool = False): + """Schema-valid commands with plausible-to-nonsense field values. + + Args: + include_pending: Whether to draw the classes in `PENDING_COMMANDS` too. + """ samples = [] for command_class in ALL_COMMAND_CLASSES: name = command_class.__name__ + if name in PENDING_COMMANDS and not include_pending: + continue + if name == "AddMember": + samples.append(st.builds(command_class, character=st.sampled_from(RECRUIT_SHAPES).map(recruit))) + continue if name in ("PlaceParty",): samples.append( st.builds( diff --git a/tests/test_docs_gates.py b/tests/test_docs_gates.py index 78f5650..acca72f 100644 --- a/tests/test_docs_gates.py +++ b/tests/test_docs_gates.py @@ -158,6 +158,8 @@ def test_documented_modes_equal_allowed_modes(self): wrong.append(f"{cls.__name__}: documents {sorted(documented)}, allows {sorted(actual)}") assert not wrong, "\n".join(wrong) + # `AddMember` documents the `party.add.*` codes its handler will emit, and the stub emits none yet. + @pytest.mark.xfail(reason="chunk: add-member", strict=True) def test_documented_rejection_codes_exist_in_the_source(self, vocabulary): wrong = [] for cls in self._commands(): diff --git a/tests/test_docs_site.py b/tests/test_docs_site.py index 70abfee..0754f8e 100644 --- a/tests/test_docs_site.py +++ b/tests/test_docs_site.py @@ -18,6 +18,11 @@ _CROSSREF = re.compile(r"\]\[[^\]\s]+\]") +# A JSON Schema `pattern` is an anchored regular expression, and a character class such as +# `^[a-z][a-z0-9_]*$` looks like cross-reference markup to `_CROSSREF`. The catalog page +# check drops these string literals from the rendered page before it searches. +_SCHEMA_PATTERN = re.compile(r""\^[^&]*?\$"") + @pytest.fixture(scope="module") def site(tmp_path_factory: pytest.TempPathFactory) -> Path: @@ -81,6 +86,6 @@ def test_the_catalog_pages_render_no_crossref_markup(self, site: Path): offenders = [] for catalog in ("commands", "events"): for page in sorted((site / "reference" / catalog).glob("*/index.html")): - if _CROSSREF.search(page.read_text(encoding="utf-8")): + if _CROSSREF.search(_SCHEMA_PATTERN.sub("", page.read_text(encoding="utf-8"))): offenders.append(f"{catalog}/{page.parent.name}") assert offenders == [] diff --git a/tests/test_events_kernel.py b/tests/test_events_kernel.py index 4b2c94b..db84eeb 100644 --- a/tests/test_events_kernel.py +++ b/tests/test_events_kernel.py @@ -97,6 +97,7 @@ def sample_event(event_class, code): ), "ItemsLeftBehindEvent": dict(item_ids=("valuable-0001",), coins_gp_value=40), "ItemsRemovedEvent": dict(character_id="pc-1", item_ids=("torch", "magic-item-0001"), coins_gp_value=10), + "MemberAddedEvent": dict(character_id="character-0005", name="Ylva", class_id="fighter", level=1), "LightEvent": dict(character_id="pc-1", source="torch"), "RestedEvent": dict(kind="night"), "FatigueEvent": dict(), diff --git a/tests/test_referee_items.py b/tests/test_referee_items.py index 8e3ef04..0fc3a61 100644 --- a/tests/test_referee_items.py +++ b/tests/test_referee_items.py @@ -153,10 +153,11 @@ def assert_replays(session: GameSession, adventure_document: Adventure) -> None: class TestTheCommandSurface: - def test_the_removal_commands_are_registered_at_the_end_of_the_census(self): - from osrlib.crawl.commands import RemoveCoins, RemoveItem + def test_the_removal_commands_are_registered_after_end_encounter(self): + from osrlib.crawl.commands import EndEncounter, RemoveCoins, RemoveItem - assert set(ALL_COMMAND_CLASSES[-2:]) == {RemoveItem, RemoveCoins} + after = ALL_COMMAND_CLASSES.index(EndEncounter) + 1 + assert set(ALL_COMMAND_CLASSES[after : after + 2]) == {RemoveItem, RemoveCoins} assert RemoveItem.model_fields["command_type"].default == "remove_item" assert RemoveCoins.model_fields["command_type"].default == "remove_coins" diff --git a/tests/test_terminal_modes.py b/tests/test_terminal_modes.py index c7aebd6..74ab7b7 100644 --- a/tests/test_terminal_modes.py +++ b/tests/test_terminal_modes.py @@ -125,10 +125,11 @@ def assert_session_ended(session: GameSession, events) -> None: class TestTheLegalityCensus: - """Terminal-mode membership follows referee-ness, with four exceptions. + """Terminal-mode membership follows referee-ness, with five exceptions. - Three referee commands are withheld because they would resume play, and - `EndEncounter` because an ended session has no encounter to close. + Three referee commands are withheld because they would resume play, `EndEncounter` + because an ended session has no encounter to close, and `AddMember` because a new + member would restart play. """ def test_terminal_names_exactly_game_over_and_victory(self): From 738df4c46457af3db6955d3a87e2726c07c7150d Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 14:04:36 -0700 Subject: [PATCH 2/3] Implement AddMember, the referee command that adds a party member during play The handler checks the character in the contract's order: no id, then no condition or stat modifier, then no magic item or valuable. A refusal returns before anything changes. An accepted command appends a deep copy of the command's character at the back of the marching order, gives it the next character id from the session's allocator (skipping any id a member already has), adds its coins to the treasure snapshot when a delve is under way, and emits MemberAddedEvent. It makes no draws and takes no time. The three rejection codes have descriptions in tools/docs/rejection_codes.json. The docstrings that said ReorderParty is the only command that changes the party, or that only GameSession.new assigns character ids, now name AddMember. The LLM referee page lists the command, and the changelog has an Added bullet. No golden file changed. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 1 + docs/front-ends/llm-referees.md | 1 + src/osrlib/core/character.py | 6 +++-- src/osrlib/crawl/party.py | 15 ++++++++---- src/osrlib/crawl/session.py | 41 ++++++++++++++++++++++++++++----- tools/docs/rejection_codes.json | 3 +++ 6 files changed, 54 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e37413..365defc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - [`SpawnNpcParty`][osrlib.crawl.commands.SpawnNpcParty] takes an exact band size in the new `count_fixed` field, so a referee can place a band of four adventurers (#167). Before, the size had to be a dice expression, and `"4"` isn't one. Set at most one of `count_fixed` and `count_dice`. With neither, the session rolls the B/X number for the band's kind as before. A saved command without the field still parses. - A game can end an encounter peacefully with the referee command [`EndEncounter`][osrlib.crawl.commands.EndEncounter], when the party pays a toll, talks its way past, or walks away (#167). The command closes the encounter, and the battle if one is open. Set `outcome` to `"parley"` when the party talks or pays its way past, and to `"departed"` when the party walks away from monsters that let it go. The command moves no coins, so to charge a toll, issue a [`RemoveCoins`][osrlib.crawl.commands.RemoveCoins] beside it. The session logs the command, so a saved game and a replay close the encounter too. Before, the only way was to call [`end_encounter`][osrlib.crawl.encounter.end_encounter] directly, and a replay of that game stayed in the encounter. Set `resolve_area` to `True` to mark the keyed area the monsters came from as resolved, so they aren't there the next time the party walks in. An adventure can use the command as a trigger's consequence too. - A game can take items and coins away with logged, replayable referee commands, so it can run a shop that buys back armour, charge a price or a toll, or use up a quest item (#173). [`RemoveItem`][osrlib.crawl.commands.RemoveItem] takes units of a mundane item, or one magic item or valuable by its instance id. An equipped item comes off first, and any effect it attached ends. A revealed curse doesn't stop the removal. [`RemoveCoins`][osrlib.crawl.commands.RemoveCoins] takes exactly the denominations the command lists and makes no change from other coins. Both are legal in every mode, cost no draws and no time, and report an [`ItemsRemovedEvent`][osrlib.crawl.events.ItemsRemovedEvent] with the code `session.items.removed`. When the member in `character_id` doesn't have the item or the coins, the session refuses the command with `items.remove.not_carried` or `items.coins.insufficient`. +- A game can add a character to a running party with the referee command [`AddMember`][osrlib.crawl.commands.AddMember], so it can bring in a replacement for a character who died or an adventurer the party meets (#172). Build the character as you build a starting member and send it in the command. The command is legal in `town` and `exploring`. In every other mode the session refuses it with `session.command.wrong_mode`. The session adds a copy of the character at the back of the marching order and gives it the next `character-NNNN` id. It reports the new member in a player-visible [`MemberAddedEvent`][osrlib.crawl.events.MemberAddedEvent] with the code `session.member.added`. Read the id from the event and use it in later commands. The character must arrive fresh. The session refuses a character who already has an id with `party.add.id_assigned`, one who has a condition or a stat modifier with `party.add.under_effects`, and one who carries a magic item or a valuable with `party.add.carries_instances`. Mundane gear and coins come along. Coins a new member brings into a dungeon earn no experience. The command makes no draws and takes no time, and the session logs it, so a saved game and a replay have the new member too. - [`GrantItem`][osrlib.crawl.commands.GrantItem] can grant a magic item by its catalog id, so a game can sell potions or hand out a magic reward (#173). Each copy arrives unidentified, with a separate instance id in the [`ItemAcquiredEvent`][osrlib.crawl.events.ItemAcquiredEvent]. The session rolls whatever the item's entry in the OSE SRD leaves to chance, like a wand's charges, on the treasure stream, as it does for a magic item found in a cache. A mundane grant is unchanged. - An adventure document can use `RemoveItem`, `RemoveCoins`, and a `GrantItem` of a magic item as trigger consequences and quest rewards (#173). A `RemoveItem` can name its member with [`CARRIER_SELECTOR`][osrlib.crawl.triggers.CARRIER_SELECTOR] (`"@carrier"`), which the interpreter replaces with the first living member in marching order who carries the item. When nobody does, the interpreter drops the command with a note. A quest reward written this way uses up the quest item when the quest completes. [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] checks an authored `GrantItem`'s or `RemoveItem`'s item id against the equipment and magic item catalogs, and accepts `"@carrier"` only on a `RemoveItem`. - [`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. diff --git a/docs/front-ends/llm-referees.md b/docs/front-ends/llm-referees.md index 5ca40af..30c5d74 100644 --- a/docs/front-ends/llm-referees.md +++ b/docs/front-ends/llm-referees.md @@ -63,6 +63,7 @@ Player commands let the model drive the party's turn. Referee commands let it *r - [`EndEncounter`][osrlib.crawl.commands.EndEncounter] - close the encounter when the party talks its way past or walks away, and optionally mark a keyed area resolved so its monsters don't come back - [`GrantItem`][osrlib.crawl.commands.GrantItem], [`GrantCoins`][osrlib.crawl.commands.GrantCoins], [`AwardXP`][osrlib.crawl.commands.AwardXP] - place rewards directly, magic items included - [`RemoveItem`][osrlib.crawl.commands.RemoveItem], [`RemoveCoins`][osrlib.crawl.commands.RemoveCoins] - take items or coins away: a price, a toll, armour a shop buys back +- [`AddMember`][osrlib.crawl.commands.AddMember] - bring a new character into the party in town or while exploring, like a replacement for a fallen member. The new member joins at the back of the marching order, and the event gives their id - [`SetDoorState`][osrlib.crawl.commands.SetDoorState] - rewrite any door's state anywhere: lock it, wedge it, reveal it - [`PlaceParty`][osrlib.crawl.commands.PlaceParty] and [`AdvanceTime`][osrlib.crawl.commands.AdvanceTime] - teleport the party, advance the clock - [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry] and [`RecordNote`][osrlib.crawl.commands.RecordNote] - what the agent records in the game world: a journal entry is written for the players and appears in their view, and a note is for the referee only and stays out of the player view diff --git a/src/osrlib/core/character.py b/src/osrlib/core/character.py index 1c7ac28..52981f6 100644 --- a/src/osrlib/core/character.py +++ b/src/osrlib/core/character.py @@ -229,8 +229,10 @@ class Character(BaseModel): """The character's id within a game, like `"character-0001"`, or `None` before a session assigns one. [`GameSession.new`][osrlib.crawl.session.GameSession.new] assigns an id to each party member whose id is `None`, - from the session's [`IdAllocator`][osrlib.core.monsters.IdAllocator]. Commands refer to a character by this id. An - event about a character with no id uses the character's `name` instead. + from the session's [`IdAllocator`][osrlib.core.monsters.IdAllocator], and + [`AddMember`][osrlib.crawl.commands.AddMember] assigns one the same way to a character who joins during play. + Commands refer to a character by this id. An event about a character with no id uses the character's `name` + instead. """ name: str = Field(min_length=1) diff --git a/src/osrlib/crawl/party.py b/src/osrlib/crawl/party.py index bd5c409..85b6dd5 100644 --- a/src/osrlib/crawl/party.py +++ b/src/osrlib/crawl/party.py @@ -8,8 +8,9 @@ The order of the member list is the marching order, and it isn't recorded anywhere else, so the first member is the one in front. [`ReorderParty`][osrlib.crawl.commands.ReorderParty] is the -only command that changes that order. The session calls -[`reorder`][osrlib.crawl.party.Party.reorder] when it accepts one. +only command that rearranges that order. The session calls +[`reorder`][osrlib.crawl.party.Party.reorder] when it accepts one. The referee command +[`AddMember`][osrlib.crawl.commands.AddMember] adds a new member at the back. Dead members stay in the list, with their gear still on them. No command removes a member. The rules that count party members skip the dead: movement rate, combat ranks, traps, and food and @@ -34,7 +35,8 @@ class Party(BaseModel): Construct one with at least one member and pass it to [`GameSession.new`][osrlib.crawl.session.GameSession.new], which assigns each member an id and - keeps the party for the life of the session. The methods return facts about the group as a whole: + keeps the party for the life of the session. To add a member during play, execute + [`AddMember`][osrlib.crawl.commands.AddMember]. The methods return facts about the group as a whole: who is still alive, how fast the group walks, and who stands where in a fight. A party is mutable. The session heals, wounds, and re-equips its members in place, and assigning @@ -74,7 +76,9 @@ class Party(BaseModel): """The party's characters, in marching order: index 0 walks in front and meets whatever the party walks into first. At least one member is required. [`GameSession.new`][osrlib.crawl.session.GameSession.new] assigns ids when the party joins a - session, so a member you just built has `None` as its `id` until then. Change the order by + session, so a member you just built has `None` as its `id` until then. To add a member during + play, execute [`AddMember`][osrlib.crawl.commands.AddMember], which assigns the new member's + id and puts them at the back, rather than appending to this list. Change the order by executing [`ReorderParty`][osrlib.crawl.commands.ReorderParty], not by assigning to this field, so the change is logged and a replay reproduces it.""" @@ -97,7 +101,8 @@ def member(self, character_id: str) -> Character: Use this to turn an id from a command or an event into the character it refers to. Events identify characters by id, not by name. The ids come from [`GameSession.new`][osrlib.crawl.session.GameSession.new], which gives each member an id like - `character-0001`, in party order. Inside a session, + `character-0001`, in party order, and from [`AddMember`][osrlib.crawl.commands.AddMember], + which gives a member who joins during play the next one. Inside a session, [`GameSession.member`][osrlib.crawl.session.GameSession.member] does the same lookup. Args: diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index fb53c94..9adb906 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -120,7 +120,7 @@ SavingThrowRolledEvent, Visibility, ) -from osrlib.core.items import EquipmentCatalog, ItemInstance +from osrlib.core.items import EquipmentCatalog, ItemInstance, MagicItemInstance from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, MonsterCatalog, MonsterInstance, spawn_monster from osrlib.core.rng import RngStreams, StreamName from osrlib.core.ruleset import Ruleset @@ -167,6 +167,7 @@ JournalEntryAddedEvent, LightEvent, LocationEnteredEvent, + MemberAddedEvent, MonstersSpawnedEvent, NoteRecordedEvent, ObjectiveCompletedEvent, @@ -616,9 +617,10 @@ def __init__( which doesn't validate the rest of a saved adventure. """ self.party = party - """The party, in marching order. The order determines who is in the front rank in a fight, and - [`ReorderParty`][osrlib.crawl.commands.ReorderParty] is the only command that changes - it.""" + """The party, in marching order. The order determines who is in the front rank in a fight. + [`ReorderParty`][osrlib.crawl.commands.ReorderParty] is the only command that rearranges + it, and [`AddMember`][osrlib.crawl.commands.AddMember] is the only command that adds a + member, at the back.""" self.adventure = adventure """The adventure being played: its town, dungeons, quests, and any content it bundles. It's frozen, and a save contains a copy of it, so a saved game needs no other file to @@ -787,7 +789,10 @@ def new(cls, party: Party, adventure: Adventure, *, seed: int, ruleset: Ruleset [`load_game`][osrlib.persistence.load_game] instead. The session keeps the party you pass, not a copy, so from then on your character objects - are the session's live party members. + are the session's live party members. To add a member once play has begun, execute + [`AddMember`][osrlib.crawl.commands.AddMember], which gives the new member the next + `character-NNNN` id from the same allocator. Don't append to `party.members` yourself, + because the command log wouldn't record the change and a replay wouldn't have the member. This method checks the whole adventure before it builds anything, so an unknown monster id or a stair to a level that doesn't exist fails here, not partway through a delve. @@ -2212,7 +2217,31 @@ def _handle_remove_coins(session: GameSession, command: RemoveCoins) -> tuple[li def _handle_add_member(session: GameSession, command: AddMember) -> tuple[list[Rejection], list[Event]]: - raise NotImplementedError("chunk: add-member") + character = command.character + if character.id is not None: + return [Rejection(code="party.add.id_assigned", params={"character": character.id})], [] + if character.conditions or character.stat_modifiers: + return [Rejection(code="party.add.under_effects", params={"name": character.name})], [] + inventory = character.inventory + carried = [ + *(instance.instance_id for instance in inventory.all_instances() if isinstance(instance, MagicItemInstance)), + *(valuable.instance_id for valuable in inventory.valuables), + ] + if carried: + return [Rejection(code="party.add.carries_instances", params={"name": character.name, "item": carried[0]})], [] + # The command keeps the character as it arrived, so the log replays the same addition. + member = character.model_copy(deep=True) + taken = {existing.id for existing in session.party.members} + member.id = session.allocator.allocate("character") + while member.id in taken: + member.id = session.allocator.allocate("character") + session.party.members.append(member) + if session.treasure_snapshot_cp is not None: + # Coins carried in from outside the dungeon aren't treasure the party found. + session.treasure_snapshot_cp += member.inventory.purse.value_cp + return [], [ + MemberAddedEvent(character_id=member.id, name=member.name, class_id=member.class_id, level=member.level) + ] def _handle_award_xp(session: GameSession, command: AwardXP) -> tuple[list[Rejection], list[Event]]: diff --git a/tools/docs/rejection_codes.json b/tools/docs/rejection_codes.json index 3caa125..9a19804 100644 --- a/tools/docs/rejection_codes.json +++ b/tools/docs/rejection_codes.json @@ -122,6 +122,9 @@ "magic.memorize.wrong_list": "A spell selected for memorization belongs to a different spell list than the one the caster's class uses.", "magic.turning.caster_incapacitated": "The would-be turner is dead, petrified, paralysed, asleep, or weakened, any of which prevents it from attempting to turn undead.", "magic.turning.not_a_turner": "The character's class has no turn-undead ability, so it cannot attempt to turn undead.", + "party.add.carries_instances": "A referee tried to add a party member who carries a magic item or a valuable, equipped or not. Those have instance ids from the game that created them, which could match an id this session hands out. Add the character without them and give magic items afterwards with a grant.", + "party.add.id_assigned": "A referee tried to add a party member whose id is already set: a character from another game, or one who is already a member. The session assigns the new member's id, so the character must arrive with no id.", + "party.add.under_effects": "A referee tried to add a party member who has a condition, such as dead or asleep, or a stat modifier. Each of those belongs to an effect in the game that applied it, and this session can't end an effect it doesn't have.", "session.command.encounter_in_progress": "The referee tried to spawn an NPC party or place the party while an encounter or battle was already in progress.", "session.command.member_incapacitated": "The named party member is dead, paralyzed, petrified, or asleep and can't carry out the requested action.", "session.command.no_door": "The map edge at the given position and direction is not a door, so no door state can be set there.", From 9beb01f699d611b90f23ee4710ec535bb3c37ab8 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 30 Oct 2026 14:08:40 -0700 Subject: [PATCH 3/3] Remove the add-member markers and document the new member's experience share The fuzz in test_crawl_properties now draws AddMember too, and the docs gate for rejection codes runs unmarked. Claude-Session: https://claude.ai/code/session_01V7ErtnsmhaP7aLwcfmzohD --- CHANGELOG.md | 2 +- tests/test_add_member.py | 39 +--------------------------------- tests/test_crawl_properties.py | 2 +- tests/test_docs_gates.py | 2 -- 4 files changed, 3 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 365defc..51b1cfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - [`SpawnNpcParty`][osrlib.crawl.commands.SpawnNpcParty] takes an exact band size in the new `count_fixed` field, so a referee can place a band of four adventurers (#167). Before, the size had to be a dice expression, and `"4"` isn't one. Set at most one of `count_fixed` and `count_dice`. With neither, the session rolls the B/X number for the band's kind as before. A saved command without the field still parses. - A game can end an encounter peacefully with the referee command [`EndEncounter`][osrlib.crawl.commands.EndEncounter], when the party pays a toll, talks its way past, or walks away (#167). The command closes the encounter, and the battle if one is open. Set `outcome` to `"parley"` when the party talks or pays its way past, and to `"departed"` when the party walks away from monsters that let it go. The command moves no coins, so to charge a toll, issue a [`RemoveCoins`][osrlib.crawl.commands.RemoveCoins] beside it. The session logs the command, so a saved game and a replay close the encounter too. Before, the only way was to call [`end_encounter`][osrlib.crawl.encounter.end_encounter] directly, and a replay of that game stayed in the encounter. Set `resolve_area` to `True` to mark the keyed area the monsters came from as resolved, so they aren't there the next time the party walks in. An adventure can use the command as a trigger's consequence too. - A game can take items and coins away with logged, replayable referee commands, so it can run a shop that buys back armour, charge a price or a toll, or use up a quest item (#173). [`RemoveItem`][osrlib.crawl.commands.RemoveItem] takes units of a mundane item, or one magic item or valuable by its instance id. An equipped item comes off first, and any effect it attached ends. A revealed curse doesn't stop the removal. [`RemoveCoins`][osrlib.crawl.commands.RemoveCoins] takes exactly the denominations the command lists and makes no change from other coins. Both are legal in every mode, cost no draws and no time, and report an [`ItemsRemovedEvent`][osrlib.crawl.events.ItemsRemovedEvent] with the code `session.items.removed`. When the member in `character_id` doesn't have the item or the coins, the session refuses the command with `items.remove.not_carried` or `items.coins.insufficient`. -- A game can add a character to a running party with the referee command [`AddMember`][osrlib.crawl.commands.AddMember], so it can bring in a replacement for a character who died or an adventurer the party meets (#172). Build the character as you build a starting member and send it in the command. The command is legal in `town` and `exploring`. In every other mode the session refuses it with `session.command.wrong_mode`. The session adds a copy of the character at the back of the marching order and gives it the next `character-NNNN` id. It reports the new member in a player-visible [`MemberAddedEvent`][osrlib.crawl.events.MemberAddedEvent] with the code `session.member.added`. Read the id from the event and use it in later commands. The character must arrive fresh. The session refuses a character who already has an id with `party.add.id_assigned`, one who has a condition or a stat modifier with `party.add.under_effects`, and one who carries a magic item or a valuable with `party.add.carries_instances`. Mundane gear and coins come along. Coins a new member brings into a dungeon earn no experience. The command makes no draws and takes no time, and the session logs it, so a saved game and a replay have the new member too. +- A game can add a character to a running party with the referee command [`AddMember`][osrlib.crawl.commands.AddMember], so it can bring in a replacement for a character who died or an adventurer the party meets (#172). Build the character as you build a starting member and send it in the command. The command is legal in `town` and `exploring`. In every other mode the session refuses it with `session.command.wrong_mode`. The session adds a copy of the character at the back of the marching order and gives it the next `character-NNNN` id. It reports the new member in a player-visible [`MemberAddedEvent`][osrlib.crawl.events.MemberAddedEvent] with the code `session.member.added`. Read the id from the event and use it in later commands. The character must be new to this game. The session refuses a character who already has an id with `party.add.id_assigned`, one who has a condition or a stat modifier with `party.add.under_effects`, and one who carries a magic item or a valuable with `party.add.carries_instances`. The new member keeps their mundane gear and coins. Coins a new member brings into a dungeon earn no experience. A member who joins during a delve gets an equal share of the experience the party earns on that delve, including what it earned before they joined. The command makes no draws and takes no time, and the session logs it, so a saved game and a replay have the new member too. - [`GrantItem`][osrlib.crawl.commands.GrantItem] can grant a magic item by its catalog id, so a game can sell potions or hand out a magic reward (#173). Each copy arrives unidentified, with a separate instance id in the [`ItemAcquiredEvent`][osrlib.crawl.events.ItemAcquiredEvent]. The session rolls whatever the item's entry in the OSE SRD leaves to chance, like a wand's charges, on the treasure stream, as it does for a magic item found in a cache. A mundane grant is unchanged. - An adventure document can use `RemoveItem`, `RemoveCoins`, and a `GrantItem` of a magic item as trigger consequences and quest rewards (#173). A `RemoveItem` can name its member with [`CARRIER_SELECTOR`][osrlib.crawl.triggers.CARRIER_SELECTOR] (`"@carrier"`), which the interpreter replaces with the first living member in marching order who carries the item. When nobody does, the interpreter drops the command with a note. A quest reward written this way uses up the quest item when the quest completes. [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] checks an authored `GrantItem`'s or `RemoveItem`'s item id against the equipment and magic item catalogs, and accepts `"@carrier"` only on a `RemoveItem`. - [`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. diff --git a/tests/test_add_member.py b/tests/test_add_member.py index 3e498f2..d5ff558 100644 --- a/tests/test_add_member.py +++ b/tests/test_add_member.py @@ -25,9 +25,7 @@ are slower, and they declare in battle. The tests in `TestTheContract` check the command and event models, and the two mode tests -in `TestRefusals` check the session's mode gate. All of these exist before the handler -does, so they pass on the stubs and carry no marker. Every other test is marked for the -chunk. +in `TestRefusals` check the session's mode gate. """ import json @@ -89,8 +87,6 @@ from test_crawl_properties import command_strategy from test_terminal_modes import poison_everyone -CHUNK = pytest.mark.xfail(reason="chunk: add-member", strict=True) - SEED = 41 STOCK_IDS = ("character-0001", "character-0002", "character-0003", "character-0004") """The ids `GameSession.new` gives the stock four-member party, in marching order.""" @@ -334,7 +330,6 @@ def test_the_event_round_trips_and_renders(self): class TestAccepted: - @CHUNK @pytest.mark.parametrize("mode", FIELD_MODES) def test_the_member_joins_at_the_back_of_the_marching_order(self, mode): session = session_in(mode) @@ -346,7 +341,6 @@ def test_the_member_joins_at_the_back_of_the_marching_order(self, mode): assert session.mode.value == mode, "adding a member changes no mode" assert session.dungeon_state.location.model_dump(mode="json") == location, "and moves nobody" - @CHUNK @pytest.mark.parametrize("mode", FIELD_MODES) def test_it_draws_nothing_takes_no_time_and_is_logged(self, mode): session = session_in(mode) @@ -358,7 +352,6 @@ def test_it_draws_nothing_takes_no_time_and_is_logged(self, mode): assert session.clock.rounds == rounds assert session.command_log[-1] is command - @CHUNK def test_the_event_is_player_visible_and_logged(self): session = new_session() event = add(session) @@ -366,7 +359,6 @@ def test_the_event_is_player_visible_and_logged(self): assert session.event_log[-1] == event assert format_message(event) == "Ylva, a level 1 fighter, joins the party as character-0005." - @CHUNK def test_the_session_adds_a_copy_and_leaves_the_command_as_it_arrived(self): session = new_session() hire = recruit(gp=30, armour="chainmail") @@ -383,7 +375,6 @@ def test_the_session_adds_a_copy_and_leaves_the_command_as_it_arrived(self): assert command.character.current_hp == 7 assert command.character.inventory.purse.gp == 30 - @CHUNK def test_mundane_gear_and_coins_come_along(self): session = new_session() hire = recruit(gp=25, armour="plate_mail") @@ -396,14 +387,12 @@ def test_mundane_gear_and_coins_come_along(self): class TestIds: - @CHUNK def test_the_new_id_is_the_next_character_id(self): session = new_session() assert session.allocator.counters["character"] == len(STOCK_IDS) assert add(session).character_id == NEW assert session.allocator.counters["character"] == len(STOCK_IDS) + 1 - @CHUNK def test_two_members_take_consecutive_ids(self): session = new_session() assert add(session, recruit("Ylva")).character_id == NEW @@ -411,7 +400,6 @@ def test_two_members_take_consecutive_ids(self): assert member_ids(session) == (*STOCK_IDS, NEW, NEXT) assert [member.name for member in session.party.members[-2:]] == ["Ylva", "Orm"] - @CHUNK def test_the_id_passes_over_ids_members_already_have(self): # `GameSession.new` keeps an id a member already has, such as one from an earlier # session, and allocates nothing for it. So the allocator's next id can be taken. @@ -424,7 +412,6 @@ def test_the_id_passes_over_ids_members_already_have(self): assert event.character_id == "character-0003" assert member_ids(session) == ("character-0001", "character-0002", "character-0003") - @CHUNK def test_no_entity_of_another_kind_takes_an_id(self): session = new_session() counters = dict(session.allocator.counters) @@ -434,7 +421,6 @@ def test_no_entity_of_another_kind_takes_an_id(self): class TestMarchingOrder: - @CHUNK def test_the_front_rank_stays_as_it_was(self): session = new_session() front = [member.id for member in session.party.ranks(2)[0]] @@ -443,7 +429,6 @@ def test_the_front_rank_stays_as_it_was(self): assert [member.id for member in ranks[0]] == front assert ranks[-1][-1].id == NEW - @CHUNK def test_a_dead_member_ahead_stays_in_place(self): from osrlib.core.effects import kill @@ -453,7 +438,6 @@ def test_a_dead_member_ahead_stays_in_place(self): assert member_ids(session) == (*STOCK_IDS, NEW) assert [member.id for member in session.party.living_members()] == [*STOCK_IDS[:3], NEW] - @CHUNK @pytest.mark.parametrize("mode", FIELD_MODES) def test_reorder_party_can_move_the_new_member_to_the_front(self, mode): session = session_in(mode) @@ -476,61 +460,52 @@ def test_every_other_mode_refuses_it(self, mode): session = session_in(mode) assert_refused(session, AddMember(character=recruit()), "session.command.wrong_mode") - @CHUNK def test_a_character_with_an_id_is_refused(self): session = new_session() hire = recruit() hire.id = "character-0099" assert_refused(session, AddMember(character=hire), "party.add.id_assigned") - @CHUNK def test_a_current_member_is_refused(self): session = new_session() assert_refused(session, AddMember(character=session.member(STOCK_IDS[0])), "party.add.id_assigned") - @CHUNK def test_a_dead_character_is_refused(self): session = new_session() hire = recruit() hire.conditions = (ActiveCondition(condition=Condition.DEAD),) assert_refused(session, AddMember(character=hire), "party.add.under_effects") - @CHUNK def test_a_character_under_a_timed_condition_is_refused(self): session = new_session() hire = recruit() hire.conditions = (ActiveCondition(condition=Condition.ASLEEP, effect_id="effect-0001"),) assert_refused(session, AddMember(character=hire), "party.add.under_effects") - @CHUNK def test_a_character_with_a_stat_modifier_is_refused(self): session = new_session() hire = recruit() hire.stat_modifiers = (ActiveModifier(kind="attack_bonus", value=1, effect_id="effect-0001"),) assert_refused(session, AddMember(character=hire), "party.add.under_effects") - @CHUNK def test_a_carried_magic_item_is_refused(self): session = new_session() hire = recruit() hire.inventory.items.append(MagicItemInstance(instance_id="magic-item-0001", template_id="potion_of_healing")) assert_refused(session, AddMember(character=hire), "party.add.carries_instances") - @CHUNK def test_an_equipped_magic_item_is_refused(self): session = new_session() hire = recruit() hire.inventory.rings = [MagicItemInstance(instance_id="magic-item-0001", template_id="ring_of_protection")] assert_refused(session, AddMember(character=hire), "party.add.carries_instances") - @CHUNK def test_a_valuable_is_refused(self): session = new_session() hire = recruit() hire.inventory.valuables = [ValuableInstance(instance_id="valuable-0001", kind="gem", value_gp=50)] assert_refused(session, AddMember(character=hire), "party.add.carries_instances") - @CHUNK def test_the_first_failing_check_is_the_one_reported(self): session = new_session() hire = recruit() @@ -551,7 +526,6 @@ def test_the_mode_is_checked_before_the_character(self): class TestTreasure: - @CHUNK def test_coins_brought_into_a_dungeon_earn_no_experience(self): session = new_session() enter(session) @@ -564,7 +538,6 @@ def test_coins_brought_into_a_dungeon_earn_no_experience(self): assert not [event for event in result.events if isinstance(event, AdventureXpAwardEvent)] assert session.member(NEW).xp == 0 - @CHUNK def test_coins_brought_in_town_leave_the_snapshot_alone(self): session = new_session() assert session.treasure_snapshot_cp is None @@ -578,7 +551,6 @@ def test_coins_brought_in_town_leave_the_snapshot_alone(self): class TestViewsSavesAndReplay: - @CHUNK def test_the_player_view_lists_the_new_member_last(self): session = new_session() add(session) @@ -587,7 +559,6 @@ def test_the_player_view_lists_the_new_member_last(self): sheet = view.party[-1] assert (sheet.name, sheet.class_id, sheet.level, sheet.current_hp, sheet.max_hp) == ("Ylva", "fighter", 1, 7, 7) - @CHUNK def test_the_referee_view_has_the_whole_sheet(self): session = new_session() add(session, recruit(gp=10, armour="leather")) @@ -595,7 +566,6 @@ def test_the_referee_view_has_the_whole_sheet(self): assert view.party.members[-1] == session.member(NEW) assert view.party.members[-1] is not session.member(NEW) - @CHUNK @pytest.mark.parametrize("mode", FIELD_MODES) def test_a_save_round_trips_the_new_member(self, mode): session = session_in(mode) @@ -605,7 +575,6 @@ def test_a_save_round_trips_the_new_member(self, mode): assert restored.member(NEW) == session.member(NEW) assert add(restored, recruit("Orm")).character_id == NEXT, "the restored allocator continues the count" - @CHUNK def test_a_replay_adds_the_same_member_with_the_same_id(self): session = new_session() enter(session) @@ -617,7 +586,6 @@ def test_a_replay_adds_the_same_member_with_the_same_id(self): assert member_ids(again) == (*STOCK_IDS, NEW, NEXT) assert session_state(again) == session_state(session) - @CHUNK def test_a_replay_from_the_live_log_shares_no_character_with_the_original(self): session = new_session() add(session) @@ -633,7 +601,6 @@ def test_a_replay_from_the_live_log_shares_no_character_with_the_original(self): class TestTheNewMemberPlays: - @CHUNK def test_later_referee_commands_address_the_new_member(self): session = new_session() add(session) @@ -649,7 +616,6 @@ def test_later_referee_commands_address_the_new_member(self): assert member.xp == 100 assert any(instance.template.id == "torch" for instance in member.inventory.all_instances()) - @CHUNK def test_the_new_member_moves_with_the_party(self): session = new_session() enter(session) @@ -661,7 +627,6 @@ def test_the_new_member_moves_with_the_party(self): assert session.dungeon_state.location.position == (1, 0) assert member_ids(session) == (*STOCK_IDS, NEW) - @CHUNK def test_the_new_member_declares_in_battle(self): session = new_session(guard_adventure()) enter(session, "crypt") @@ -674,7 +639,6 @@ def test_the_new_member_declares_in_battle(self): holds = tuple(BattleDeclaration(character_id=member_id, action="hold") for member_id in declarers) assert session.execute(ResolveBattleRound(declarations=holds)).accepted - @CHUNK def test_a_wiped_party_is_replaced_in_town_and_plays_on(self): session = session_in("game_over") assert session.execute(PlaceParty(location={"kind": "town"})).accepted @@ -688,7 +652,6 @@ def test_a_wiped_party_is_replaced_in_town_and_plays_on(self): # ---------------------------------------------------------------------- the fuzz contract -@CHUNK @settings(max_examples=40, deadline=None, suppress_health_check=[HealthCheck.too_slow]) @given( seed=st.integers(min_value=0, max_value=2**32), diff --git a/tests/test_crawl_properties.py b/tests/test_crawl_properties.py index 4537d80..47b71ae 100644 --- a/tests/test_crawl_properties.py +++ b/tests/test_crawl_properties.py @@ -116,7 +116,7 @@ def plant_magic_items(session) -> None: ) -PENDING_COMMANDS = frozenset({"AddMember"}) +PENDING_COMMANDS: frozenset[str] = frozenset() """Command classes whose handler belongs to a chunk still in flight. The fuzz leaves them out until the chunk merges, because a stub handler raises. The diff --git a/tests/test_docs_gates.py b/tests/test_docs_gates.py index acca72f..78f5650 100644 --- a/tests/test_docs_gates.py +++ b/tests/test_docs_gates.py @@ -158,8 +158,6 @@ def test_documented_modes_equal_allowed_modes(self): wrong.append(f"{cls.__name__}: documents {sorted(documented)}, allows {sorted(actual)}") assert not wrong, "\n".join(wrong) - # `AddMember` documents the `party.add.*` codes its handler will emit, and the stub emits none yet. - @pytest.mark.xfail(reason="chunk: add-member", strict=True) def test_documented_rejection_codes_exist_in_the_source(self, vocabulary): wrong = [] for cls in self._commands():