diff --git a/src/osrlib/core/events.py b/src/osrlib/core/events.py index 47db7dd..0d018c6 100644 --- a/src/osrlib/core/events.py +++ b/src/osrlib/core/events.py @@ -1139,7 +1139,7 @@ class SpellDisruptedEvent(Event): class DeclarationFizzledEvent(Event): - """A declared wand, staff, or rod use, or a declared turning, failed before it took effect. + """A declared wand, staff, or rod use, a declared turning, or a declared melee attack failed before it took effect. A [`GameSession`][osrlib.crawl.session.GameSession] reports it during a battle round. At the start of each round, every party member declares an action in a @@ -1150,36 +1150,51 @@ class DeclarationFizzledEvent(Event): caused the failure. For example, the group a staff of striking was aimed at moved out of melee reach, or the group a wand was aimed at left the battle. - The event's `actor_id` is the member who declared the action, `item_id` is the device, and `reason` is the - rejection code of the check that failed. + A melee attack fizzles because of where the member stands when the party's melee step comes. The session judges + a melee attack before the round starts, as if every move the round declares had happened. When the attack comes + up, it resolves only if the member stands in the group's front rank within 5' of it. Something earlier in the + round can change that. For example, the monsters win initiative and the group runs before the member's `close`, + or a member the session expected to step back can no longer move. Then the attack fizzles with the code + `battle.melee.fizzled`. The one exception is a group that has started to flee: a member who stood within 5' of + it when the round was accepted still gets the attack. + + The event's `actor_id` is the member who declared the action, `item_id` is the device, `group_id` is the group a + melee attack was aimed at, and `reason` is the rejection code of the check that failed. A fizzled declaration spends nothing. The device wasn't used, so it keeps its charge and isn't identified. Tell - the player the action failed, and that the device still has its charge. When a cast or a scroll read fizzles, + the player the action failed, and that the device still has its charge. A fizzled melee attack makes no attack + roll, so tell the player why the member didn't swing. When a cast or a scroll read fizzles, the session reports a [`SpellDisruptedEvent`][osrlib.core.events.SpellDisruptedEvent] instead, because the spell is lost. """ - allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.device.fizzled", "magic.turning.fizzled"}) - """The two codes: `magic.device.fizzled` for a wand, staff, or rod use, and `magic.turning.fizzled` for a - turning.""" + allowed_codes: ClassVar[frozenset[str]] = frozenset( + {"magic.device.fizzled", "magic.turning.fizzled", "battle.melee.fizzled"} + ) + """The three codes: `magic.device.fizzled` for a wand, staff, or rod use, `magic.turning.fizzled` for a + turning, and `battle.melee.fizzled` for a melee attack.""" event_type: Literal["declaration_fizzled"] = "declaration_fizzled" """The fixed string that identifies this event class in a serialized event.""" code: str - """Which declaration fizzled: `magic.device.fizzled` or `magic.turning.fizzled`.""" + """Which declaration fizzled: `magic.device.fizzled`, `magic.turning.fizzled`, or `battle.melee.fizzled`.""" visibility: Visibility = Visibility.PLAYER """Player visibility: everyone sees the action fail.""" actor_id: str - """The entity id of the member who declared the device use or the turning.""" + """The entity id of the member who declared the device use, the turning, or the melee attack.""" item_id: str | None = None """On a `magic.device.fizzled` event, the device's instance id, as the declaration's `item_id` gave it. The player already used this id, so it reveals nothing about a device that isn't identified. It's `None` on a - `magic.turning.fizzled` event.""" + `magic.turning.fizzled` or a `battle.melee.fizzled` event.""" + + group_id: str | None = None + """On a `battle.melee.fizzled` event, the id of the group the melee attack was aimed at, as the declaration's + `target_group_id` gave it. It's `None` on the other codes.""" reason: str """Why the declaration fizzled: the code of the first check that failed when the session checked it again. @@ -1187,7 +1202,11 @@ class DeclarationFizzledEvent(Event): Any check the declaration passed at the start of the round can fail, so read the code and don't assume a cause. Some codes you may see: - - `combat.attack.out_of_reach`: the group a staff of striking was aimed at moved out of reach. + - `combat.attack.out_of_reach`: the group a staff of striking was aimed at moved out of reach, or the member + who declared a melee attack is farther than 5' from its group when the attack comes up. + - `battle.declaration.not_in_front_rank`: the member who declared a melee attack is within 5' of its group + but not in its front rank when the attack comes up. A melee attack gets this code only when the member is + within 5', and `combat.attack.out_of_reach` otherwise. - `battle.declaration.unknown_group`: the group a wand was aimed at left the battle. - `battle.declaration.no_target`: no living monster is left in the battle for a turning. diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index fad15fb..e878ba8 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -19,21 +19,30 @@ - **Marching order** is the order of the party's members, front first, as [`Party.members`][osrlib.crawl.party.Party.members] lists them. [`ReorderParty`][osrlib.crawl.commands.ReorderParty] changes it outside a fight. -- **Ranks** are the rows the party fights in, filled in marching order. Only the **front rank** - can fight in melee. Under the [`Ruleset`][osrlib.core.ruleset.Ruleset] option - `formation_width_limit`, which is on by default, a rank is as wide as the space the party stands - in, divided by [`FIGHTER_FRONTAGE_FEET`][osrlib.crawl.battle.FIGHTER_FRONTAGE_FEET]. That's two - members side by side in a ten-foot corridor, and in a room, as many as the room's shorter side - allows. With the option off, the whole party is one rank. -- The **formation** is the party moving as one body. A move that every declarer makes is a - formation move. +- The **range track** is how a battle measures distance, in the style of The Bard's Tale. Each + monster group has a distance in feet from the **party line**, the place where the party stood + when the encounter opened. Each member has an **advance**, their place in feet forward of that + line, in [`BattleState.advance_feet`][osrlib.crawl.battle.BattleState.advance_feet]. When every + member the monsters can see has fallen back behind the line, the line moves back to the foremost + of them, as `advance_feet` describes. A member's + distance to a group is the group's distance minus the member's advance, and + [`member_distance_feet`][osrlib.crawl.battle.member_distance_feet] works it out. A member and a + group can fight in melee at [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET] or less. +- **Ranks** are the rows the party fights in. The ranks toward a group are the living members + sorted by their distance to it, nearest first. Members at the same distance stand in the order + they got there, then in marching order. Only the members of the **front rank** toward a group + who stand within 5' of it can fight it in melee, and + [`group_front_rank`][osrlib.crawl.battle.group_front_rank] lists them. Under the + [`Ruleset`][osrlib.core.ruleset.Ruleset] option `formation_width_limit`, which is on by + default, a rank is as wide as the space the party stands in, divided by + [`FIGHTER_FRONTAGE_FEET`][osrlib.crawl.battle.FIGHTER_FRONTAGE_FEET]. That's two members side by + side in a ten-foot corridor, and in a room, as many as the room's shorter side allows. With the + option off, every member at the same distance stands in one rank. +- The **formation** is the party moving as one body. A `fighting_withdrawal` or a `retreat` that + every declarer makes is a formation move. Every other move is the declaring member's alone. - A **declarer** is a party member who has to declare an action this round. Every living member who can act is one, and [`EncounterView.declarers`][osrlib.crawl.views.EncounterView.declarers] lists them. -- The **range track** is how a battle measures distance. Each monster group has a separate - distance from the party, in feet, in the style of The Bard's Tale. A group closes at its - encounter movement rate and fights in melee at - [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET]. - **Surprise** gives one side a free round, a round in which the other side doesn't act. The session rolls for surprise when the encounter opens. - A group's **morale score** is a number from 2 to 12, the `morale` field of the monster's @@ -72,9 +81,12 @@ - [`BattleRoundEvent`][osrlib.crawl.events.BattleRoundEvent] at the start of each round. - [`SpellDeclaredEvent`][osrlib.crawl.events.SpellDeclaredEvent] for each spell declared that round. -- [`GroupMovedEvent`][osrlib.crawl.events.GroupMovedEvent] when a group's distance changes. -- [`MemberWithdrewEvent`][osrlib.crawl.events.MemberWithdrewEvent] when a member who withdraws - alone trades places with a member from a back rank. +- [`MemberMovedEvent`][osrlib.crawl.events.MemberMovedEvent] when a member closes on a group or + falls back by themselves. +- [`MemberWithdrewEvent`][osrlib.crawl.events.MemberWithdrewEvent] when a member who falls back + alone trades places in the marching order with the member they named. +- [`GroupMovedEvent`][osrlib.crawl.events.GroupMovedEvent] when a group's distance from the party + line changes. - [`MonsterFledEvent`][osrlib.crawl.events.MonsterFledEvent] when a group fails its morale check and flees, and when the turned members of a group split off as a new group and flee. - [`MonstersLeftBehindEvent`][osrlib.crawl.events.MonstersLeftBehindEvent] for helpless members a @@ -97,21 +109,28 @@ [Using the rules without a session](https://mmacy.github.io/osrlib-python/guides/rules-without-a-session/) shows. -A front-rank member can step back out of melee alone. They trade places with a member behind them, -whom they name in -[`BattleDeclaration.replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id]. +Each member moves by themselves. A member can close on a group and attack it in the same round, +and can fall back alone with a fighting withdrawal or a retreat, as +[`BattleDeclaration.move`][osrlib.crawl.commands.BattleDeclaration.move] describes. A group closes +until it's within reach of the nearest member it can see, so the members in front keep the +monsters off the members behind them. To check a round's declarations before you send them, call +[`preview_battle_round`][osrlib.crawl.battle.preview_battle_round]. During a round, the session also applies these rules without a command for them: - A caster who declared a spell loses it if an attack hits them, or they fail a saving throw, between initiative and their turn to act. +- A declared melee attack that can't resolve because of where the member stands when it comes up, + out of reach or out of the group's front rank, fizzles with a + [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] at + `battle.melee.fizzled`. - Each monster group checks morale when the OSE SRD calls for it. - When a monster's attack hits, the session applies the effects the attack lists: paralysis, poison, mummy rot, energy drain, petrification, and the spitting cobra's blindness. Each effect gets the saving throw the OSE SRD gives for it, if any. The adaptations register lists the effects the session leaves to the referee. -- A petrifying gaze, like the basilisk's or the medusa's, reaches each member of the party's - front rank once a round while the gazer's group is in melee. The gaze takes no attack roll. +- A petrifying gaze, like the basilisk's or the medusa's, reaches each member in the gazer's + group's front rank who stands within 5' of it, once a round. The gaze takes no attack roll. [`resolve_gaze`][osrlib.core.combat.resolve_gaze] skips a member who is averting their eyes, is blind, or is incapacitated. A member can avert their eyes, or fight by watching a mirror, through @@ -135,9 +154,11 @@ - The session picks the targets of a party member's area spell without a roll. The area fills with the target group's living members in the order they were spawned, as many as the area's size and the rank width allow. Under the `Ruleset` option `aoe_friendly_fire`, which is on by - default, an area cast at a group in melee range also catches the party's front rank while the - area has room. An area aimed at the party, like a dragon's breath, covers the party's ranks - from the front, as many as its size reaches. + default, an area cast at a group also catches the members in that group's front rank who stand + within 5' of it, while the area has room. An area aimed at the party, like a dragon's breath, + covers the ranks toward the breathing group from the front. A rank at the same distance as the + rank in front of it counts as 10' deeper, and a rank farther away starts at its own distance, + so the area reaches as many ranks as its size covers. An action policy chooses what each monster group does each round. [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy] is the default for monsters, and @@ -161,6 +182,12 @@ action. - [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy] and [`NpcPartyPolicy`][osrlib.crawl.battle.NpcPartyPolicy] are the default policies. +- [`member_distance_feet`][osrlib.crawl.battle.member_distance_feet] and + [`group_front_rank`][osrlib.crawl.battle.group_front_rank] report how far a member stands from a + group and who can fight it in melee, for a policy of your own. +- [`preview_battle_round`][osrlib.crawl.battle.preview_battle_round] judges a round's planned + declarations without playing the round, and returns a + [`BattleRoundPreview`][osrlib.crawl.battle.BattleRoundPreview]. - [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET], [`FLEE_EXIT_FEET`][osrlib.crawl.battle.FLEE_EXIT_FEET], [`FIGHTER_FRONTAGE_FEET`][osrlib.crawl.battle.FIGHTER_FRONTAGE_FEET], and @@ -320,6 +347,7 @@ __all__ = [ "ActionPolicy", + "BattleRoundPreview", "BattleState", "FIGHTER_FRONTAGE_FEET", "FLEE_EXIT_FEET", @@ -328,6 +356,9 @@ "NPC_PARTY_MORALE", "NpcPartyPolicy", "ScriptedPolicy", + "group_front_rank", + "member_distance_feet", + "preview_battle_round", "start_battle", ] @@ -338,12 +369,13 @@ def _int_param(params: Mapping[str, Any], key: str, default: int = 0) -> int: MELEE_RANGE_FEET = 5 -"""The distance, in feet, at which a monster group is close enough to fight in melee. +"""The distance, in feet, at which a party member and a monster group are close enough to fight in melee. -A group closing on the party stops at this distance, not at zero. The session rejects a melee -attack declared against a group farther away with `combat.attack.out_of_reach`, and every melee -attack resolves at this distance. When pursuing monsters catch the party, the battle starts with -every group at this distance. +A member who closes on a group stops at this distance from it, not at zero. A group that closes +stops at this distance from the nearest member it can see. The session rejects a melee attack +declared by a member who would be farther from the group with `combat.attack.out_of_reach`, and +every melee attack resolves at this distance. When pursuing monsters catch the party, the battle +starts with every group at this distance and every member on the party line. The range track has no distance between this and zero, so treat the value as fixed: the session uses it for every reach and range check in a battle. A party member attacks a group farther away only @@ -424,6 +456,38 @@ class BattleState(BaseModel): - The caster declares an action other than `cast` or `hold`. A `turn_undead` counts. - The caster's next spell is disrupted or fizzles. - The caster casts another concentration spell.""" + advance_feet: dict[str, int] = {} + """Each party member's place on the range track, in feet forward of the party line, keyed by member id. + + The party line is where the party stood when the encounter opened. Each group's + [`distance_feet`][osrlib.crawl.encounter.EncounterGroup.distance_feet] is measured from it. A member + with no entry stands on the line, at zero. A positive advance is toward the monsters, and a negative + one is behind the line. A member's distance to a group is the group's distance minus the member's + advance, and [`member_distance_feet`][osrlib.crawl.battle.member_distance_feet] works it out for you. + + The session changes it in the party's movement step. A member's `close` raises their advance, and a + `fighting_withdrawal` or a `retreat` the member makes alone lowers it. A formation move leaves every + advance as it is and moves the groups instead. + + The party line moves back when the party has fallen back behind it. At the end of the party's + movement step, when every living member who isn't invisible stands behind the line, the session + moves the line back to the foremost of them. Every member's advance and every group's distance grow + by the same number of feet, so no distance between a member and a group changes, and the session + emits no event for it. The views show the moved values. The foremost members then stand on the line, + at zero, and a group can close on them as usual, because a group's distance never goes below zero. + When every living member is invisible, the line stays where it is. + + Every member starts a battle on the line, and the + advances end with the battle, so the party stands on its cell as one body again once the fight is + over. A save written mid-battle keeps them, and a save from before they existed loads with every + member on the line.""" + arrived_round: dict[str, int] = {} + """The battle round in which each member reached their current advance, keyed by member id. + + A member with no entry has stood where they are since the battle opened, which counts as round + zero. The session uses it to order members who stand at the same distance from a group: the member + who got there in an earlier round stands in front, and marching order settles a tie. So a member + already fighting keeps their place in the front rank when others arrive beside them.""" class MonsterAction(BaseModel): @@ -444,10 +508,17 @@ class MonsterAction(BaseModel): kind: str # close | breath | melee | hold | npc_shoot | npc_cast | npc_drink """What the monster does: - - `"close"` moves the whole group toward the party by one encounter movement rate, stopping at - [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET]. Only the first `"close"` in a - group's actions for a round moves the group. - - `"melee"` attacks `target_id`. + - `"close"` moves the whole group toward the party line by one encounter movement rate. The + group stops as soon as it's within + [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET] of the nearest living member it + can see, so it never moves past a member it can see. A group already within 5' of such a + member doesn't move. Only the first `"close"` in a group's actions for a round moves the + group. + - `"melee"` attacks `target_id`. The session resolves it only if the target stands in the + group's front rank within 5' of it when the action comes up, as + [`group_front_rank`][osrlib.crawl.battle.group_front_rank] reports. So a `"melee"` after the + group's `"close"` strikes when the move has brought the target within reach, and is skipped + otherwise. - `"breath"` uses the monster's breath weapon on the party. - `"hold"` does nothing. - `"npc_shoot"`, `"npc_cast"`, and `"npc_drink"` are for NPC adventurers only. They make a @@ -593,8 +664,11 @@ def choose(self, session, group, stream) -> list[MonsterAction]: session (osrlib.crawl.session.GameSession): The running session. Read the party, the registry, and the effects ledger from it, and don't change it. group (osrlib.crawl.encounter.EncounterGroup): The group to choose for. Its - `distance_feet` is its current distance from the party, and its `monster_ids` are its - members, dead ones included. + `distance_feet` is its current distance from the party line, and its `monster_ids` + are its members, dead ones included. Call + [`member_distance_feet`][osrlib.crawl.battle.member_distance_feet] and + [`group_front_rank`][osrlib.crawl.battle.group_front_rank] to learn which members it + can reach. stream (osrlib.core.rng.RngStream): The [`MONSTER_ACTION_STREAM`][osrlib.crawl.session.MONSTER_ACTION_STREAM] stream. Take every random choice from it. A choice from anywhere else makes the fight differ from a @@ -627,10 +701,15 @@ class ScriptedPolicy: - When the breath weapon works on a chance in six, as the hellhound's does, the policy rolls that chance each round. - Otherwise, a group farther away than [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET] - closes. Once in range, each monster picks a target at random from the party's front rank. It - leaves out invisible members, and members whose *protection from evil* bars that monster. When - no target is left, the monster takes a `hold` action and does nothing that round. + Otherwise, while every member is farther than + [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET] from the group, each monster takes a + `close` action. When the group's move will bring a member within reach, each monster also takes a + `melee` action in the same round, aimed at a member of the front rank the move will produce. Once + the group is within reach, each monster picks a target at random from the group's front rank, the + members [`group_front_rank`][osrlib.crawl.battle.group_front_rank] lists. It leaves out invisible + members, and members whose *protection from evil* bars that monster. When no target is left, the + monster takes a `hold` action and does nothing that round. The members behind the front rank are + out of the group's reach. Among the targets left, the monster prefers members who can still fight: @@ -804,15 +883,20 @@ class NpcPartyPolicy: 3. A caster with a memorized spell that deals damage, like *magic missile*, casts it at the player party. Spells the referee resolves by hand don't count. The caster picks the highest-level spell, with ties broken by spell id. An area spell catches the player party's - ranks from the front, as many ranks as its size covers. For a spell with one target, the - caster picks a member at random from the player party's front rank, leaving out invisible - members. - 4. An NPC with a missile weapon shoots while the NPC group is farther away than - [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET]. - 5. Otherwise, the NPC closes and attacks in melee. Once in range, it picks its target at random - from the player party's front rank, leaving out invisible members. Like a monster, it passes - over a member turned to stone while a target that isn't stone is in reach. Unlike a ghoul or - a thoul, it doesn't pass over a paralysed member. + ranks toward the NPC group from the front, as many ranks as its size covers. For a spell with + one target, the caster picks a member at random from the first row of the player party's + ranks toward the NPC group, leaving out invisible members. + 4. An NPC with a missile weapon shoots while every member of the player party is farther than + [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET] from the NPC group. It picks its + target at random from the first row of the player party's ranks toward the NPC group, + leaving out invisible members. So the members in front screen the members behind them from + arrows as well as from blades. + 5. Otherwise, the NPC closes, and attacks in melee in the same round when the group's move + brings a member within reach. It picks its target at random from the NPC group's front rank, + the members [`group_front_rank`][osrlib.crawl.battle.group_front_rank] lists, leaving out + invisible members. Like a monster, it passes over a member turned to stone while a target + that isn't stone is in reach. Unlike a ghoul or a thoul, it doesn't pass over a paralysed + member. NPCs declare their spells at the start of the round. An attack that hits or a failed saving throw can disrupt an NPC's spell, as it can a player character's, because the OSE SRD's rule @@ -1339,6 +1423,167 @@ def _haste_multiplier(session, entity_id: str, key: str) -> int: return multiplier +# ---------------------------------------------------------------------- positions on the range track + + +def member_distance_feet(session, member_id: str, group_id: str) -> int: + """Return how far one party member stands from one monster group, in feet, in the running battle. + + Each group's distance is measured from the party line, where the party stood when the encounter + opened or where the line has moved back to since, and each member stands at an advance forward of + that line, in + [`BattleState.advance_feet`][osrlib.crawl.battle.BattleState.advance_feet]. This returns the + group's distance minus the member's advance. Every rule that measures how far a member is from a + group uses it: melee reach, a missile's range band, and a spell's range. + + Call it from a policy of your own (see [`ActionPolicy`][osrlib.crawl.battle.ActionPolicy]) or from + server-side code that has the session. A client that has only the player view reads the same + numbers from + [`EncounterGroupView.member_distance_feet`][osrlib.crawl.views.EncounterGroupView.member_distance_feet]. + + The call reads the session and changes nothing. + + Args: + session (osrlib.crawl.session.GameSession): A session in `battle` mode. + member_id: The member's id, as in [`MemberView.id`][osrlib.crawl.views.MemberView.id]. + group_id: The group's id, as in + [`EncounterGroupView.id`][osrlib.crawl.views.EncounterGroupView.id]. + + Returns: + The distance in feet. At [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET] or less, + the member and the group are within reach of each other. + + Raises: + ValueError: If no battle is running, `member_id` names no party member, or `group_id` names no + group in the encounter. Nothing in the session changes. + """ + raise NotImplementedError("chunk: positions") + + +def group_front_rank(session, group_id: str) -> tuple[str, ...]: + """Return the members who can fight one monster group hand to hand this round without moving. + + These are the members in the party's front rank toward the group who stand within + [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET] of it. Only they can declare a melee + attack on the group, or strike it with a staff of striking, without a `close` first. Only they can + be struck by the group's melee, and the group's petrifying gaze reaches only them. + + The ranks toward a group are the party's living members sorted by their distance to it, nearest + first, and cut into rows as wide as the formation allows + ([`EncounterView.formation_width`][osrlib.crawl.views.EncounterView.formation_width]). A row never + mixes distances. Among members at the same distance, the one who got there in an earlier round + ([`BattleState.arrived_round`][osrlib.crawl.battle.BattleState.arrived_round]) stands in front, and + marching order settles a tie. The front rank is the first row. With the ruleset's + `formation_width_limit` flag off, a row holds every member at its distance. + + So two orders apply, one after the other. Arrival order, then marching order, decides who is in the + front row. The returned tuple then lists the members of that row in marching order. A member who got + there first can come after a member who arrived later, and a member who marches ahead of both can be + left out because the row was already full. + + For the same group, the result is always the same value as + [`EncounterGroupView.front_rank`][osrlib.crawl.views.EncounterGroupView.front_rank] in the player + view. + + Call it from a policy of your own: the default policies pick each monster's melee target from these + members, leaving out invisible members and members whose *protection from evil* bars that monster. + A client that has only the player view reads the same ids from + [`EncounterGroupView.front_rank`][osrlib.crawl.views.EncounterGroupView.front_rank]. + + The call reads the session and changes nothing. + + Args: + session (osrlib.crawl.session.GameSession): A session in `battle` mode. + group_id: The group's id, as in + [`EncounterGroupView.id`][osrlib.crawl.views.EncounterGroupView.id]. + + Returns: + Member ids, in marching order. It's empty when no living member stands within 5' of the group, + for example while the nearest row is still farther away. + + Raises: + ValueError: If no battle is running, or `group_id` names no group in the encounter. Nothing in + the session changes. + """ + raise NotImplementedError("chunk: positions") + + +class BattleRoundPreview(BaseModel): + """What a battle round would do with the declarations planned so far, worked out without playing it. + + [`preview_battle_round`][osrlib.crawl.battle.preview_battle_round] returns one. Read `rejections` + to grey out a choice the session would refuse, and read `advance_feet` and `front_rank` to show + where the members would stand once their declared moves are made. The model is frozen. + """ + + model_config = ConfigDict(frozen=True) + + rejections: dict[str, tuple[Rejection, ...]] + """Every reason the session would refuse each planned declaration, keyed by the declaring member's + id. There's one entry for each declaration you passed. An empty tuple means the session would + accept that declaration. + + The codes and params are the ones + [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] returns for the same declarations. + `battle.declaration.roster_mismatch` never appears, because a plan with members still missing + would always get it.""" + advance_feet: dict[str, int] + """Where each living member would stand once the planned moves are made, keyed by member id, in feet + forward of the party line, as in + [`BattleState.advance_feet`][osrlib.crawl.battle.BattleState.advance_feet]. Every living member has + an entry. A member with no planned move keeps their current advance, and a member on the line has + zero. When the planned moves would leave every member the monsters can see behind the line, the + values are the ones after the line moves back, as the round would leave them.""" + front_rank: dict[str, tuple[str, ...]] + """For each group that hasn't fled, keyed by group id, the members who would stand in its front rank + within 5' of it once the planned moves are made, in marching order. It means what + [`EncounterGroupView.front_rank`][osrlib.crawl.views.EncounterGroupView.front_rank] means, after the + moves.""" + + +def preview_battle_round(session, declarations: Sequence[BattleDeclaration]) -> BattleRoundPreview: + """Judge the declarations planned so far for this battle round, and show where they'd leave the party. + + A battle screen builds a round one member at a time. Call this after each choice, with every + declaration made so far, to learn whether the session would accept each one and where each member + would stand. For example, once Hild has planned to close and attack, and her place fills the front + rank toward the goblins, the preview refuses Osric's close and attack with + `battle.declaration.not_in_front_rank`. Your screen can then offer Osric a `close` without the + attack. When the plan is complete, send it in a + [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound]. + + The preview runs the checks `ResolveBattleRound` runs before it plays a round, over the same + projection of the round's declared moves, so the two agree. When `declarations` has one declaration + for each member in [`EncounterView.declarers`][osrlib.crawl.views.EncounterView.declarers] and every + entry in the result's `rejections` is empty, `ResolveBattleRound` accepts the same declarations. + + The projection applies each planned move as the party's movement step would: each member's + `close`, and each `fighting_withdrawal` or `retreat` a member makes alone. A defensive move counts + as a formation move only when `declarations` covers every declarer and all of them make it. The + order of `declarations` doesn't matter. The projection doesn't include anything the monsters do, + because the session decides that after it rolls initiative. So when the monsters act first, a + member's `close` can fall short of where the preview put them. + + The call reads the session and changes nothing. It takes no random number and no game time, so + calling it after every choice costs the game nothing. + + Args: + session (osrlib.crawl.session.GameSession): A session in `battle` mode. + declarations: The declarations planned so far, at most one for each member. Each one names a + member in `EncounterView.declarers`. Any subset of the declarers works, including none. + + Returns: + A [`BattleRoundPreview`][osrlib.crawl.battle.BattleRoundPreview] with each declaration's + rejections, each living member's projected advance, and each group's projected front rank. + + Raises: + ValueError: If no battle is running, a declaration names a member who isn't in + `EncounterView.declarers`, or two declarations name the same member. Fix the call. Nothing in + the session changes. + """ + raise NotImplementedError("chunk: move-and-attack") + + # ---------------------------------------------------------------------- start and end diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 009cf5a..7a954ee 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -2472,36 +2472,55 @@ class BattleDeclaration(BaseModel): You construct these yourself, one for each member who has to declare, and pass them all in one [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound]. [`EncounterView.declarers`][osrlib.crawl.views.EncounterView.declarers] lists the - members who have to declare this round. Three more lists on the same view, `front_rank`, - `immobile`, and `reloading`, show who can attack in melee, who can't move, and who - can't fire a reloading weapon this round. Read all four to offer only choices the - session accepts. + members who have to declare this round. To offer only choices the session accepts, read + these from the same player view: + + - Each group's + [`front_rank`][osrlib.crawl.views.EncounterGroupView.front_rank], the members who can + attack it in melee this round without moving, and its + [`member_distance_feet`][osrlib.crawl.views.EncounterGroupView.member_distance_feet], + each member's distance to it. + - Each member's + [`encounter_rate_feet`][osrlib.crawl.views.MemberView.encounter_rate_feet], how far + they can move this round. A member can close on a group and attack it in the same round + when their distance to it is at most that rate plus 5'. + - `immobile` and `reloading` on the encounter view: who can't move, and who can't fire a + reloading weapon this round. + + To check one member's choice against the choices already made for the round, call + [`preview_battle_round`][osrlib.crawl.battle.preview_battle_round] with them. `action` decides which other fields apply. Leave the others at their defaults. - - `attack` uses `target_group_id` and, optionally, `weapon_id` and `back_stab`. With no - `weapon_id`, the member attacks with the wielded weapon that suits the attack, or with - bare hands when they wield none. Set `back_stab` for a thief's melee attack in the free + - `attack` uses `target_group_id` and, optionally, `weapon_id`, `move`, and `back_stab`. + With no `weapon_id`, the member attacks with the wielded weapon that suits the attack, + or with bare hands when they wield none. Set `move="close"` to close on the group first + and attack it in the same round. Set `back_stab` for a thief's melee attack in the free round the party gets when it surprises the monsters. - `cast` uses `spell_id`, `spell_mode`, `reversed`, and `targets`. `spell_id` is a spell id, like `"sleep"`. `spell_mode` is the key of one of the spell's modes, and `reversed` - picks the spell's reversed form. + picks the spell's reversed form. A caster can't move in the round they cast, so leave + `move` at `None`. - `move` uses the `move` field, which is `close`, `fighting_withdrawal`, or `retreat`. A - `close` moves the party toward the group in `target_group_id`. A `fighting_withdrawal` - backs away slowly, facing the monsters, and a `retreat` turns and runs. When every - member in `declarers` makes the same one, it's a formation move: the whole party moves - as one body, and the distance to the monsters changes. A member who makes one alone - also sets `replacement_id`, and only the party's ranks change. + `close` moves this member toward the group in `target_group_id`, and nobody else. A + `fighting_withdrawal` backs this member away slowly, facing the monsters, and a + `retreat` turns and runs. When every member in `declarers` makes the same + `fighting_withdrawal` or `retreat`, it's a formation move instead: the whole party moves + as one body, and every group's distance changes. - `use_item` uses `item_id`. The member drinks a potion, reads a scroll, uses a wand, staff, or rod, or throws a flask of oil or a similar item at `target_group_id`. A - staff of striking is a melee weapon, so only a member in `front_rank` can strike with - it. + staff of striking is a melee weapon, so only a member in the group's `front_rank` can + strike with it. - `turn_undead` uses nothing else. It reaches every living monster in the groups that haven't fled when it resolves. Turning resolves with the spells in the round. A hit can't disrupt it, because it's a class ability, not a spell. - `hold` does nothing. A member with no legal action declares it, because every member in `declarers` needs a declaration. + Only `attack` and `move` take a `move`. The session refuses a `cast` that sets one with + `battle.declaration.cast_cannot_move`, and a `turn_undead`, `use_item`, or `hold` that sets + one with `battle.declaration.move_not_combinable`. + `gaze_defense` goes with any action. It guards the member against a petrifying gaze, like a basilisk's or a medusa's, for this round only. Its values are `avert_eyes` and `mirror`. @@ -2517,16 +2536,17 @@ class BattleDeclaration(BaseModel): [`EncounterView.declarers`][osrlib.crawl.views.EncounterView.declarers], and each of those members needs a separate declaration in the same round.""" action: Literal["attack", "cast", "turn_undead", "move", "use_item", "hold"] - """What this member does. `attack` strikes or shoots at a group, `cast` casts a spell, - `turn_undead` presents a holy symbol, `move` changes the distance to the monsters, - `use_item` uses a magic item or throws an item, and `hold` does nothing. This field determines - which other fields apply, and `hold` needs none of them.""" + """What this member does. `attack` strikes or shoots at a group, after a `close` when the + declaration sets one, `cast` casts a spell, `turn_undead` presents a holy symbol, `move` moves + the member toward the monsters or away from them, `use_item` uses a magic item or throws an + item, and `hold` does nothing. This field determines which other fields apply, and `hold` + needs none of them.""" target_group_id: str | None = None """Which monster group the action is aimed at, by [`EncounterGroupView.id`][osrlib.crawl.views.EncounterGroupView.id] from [`EncounterView.groups`][osrlib.crawl.views.EncounterView.groups]. - These declarations need it: an `attack`, a `close` move, a thrown item, a wand, staff, or rod + These declarations need it: an `attack`, a `close`, a thrown item, a wand, staff, or rod that strikes or covers an area, and a `cast` of a spell whose area covers creatures. An area spell aimed at a group isn't placed at a point, because the range track has no positions inside a group. It covers the group's living members in the order they were spawned, as many @@ -2551,9 +2571,11 @@ class BattleDeclaration(BaseModel): `battle.declaration.weapon_not_wielded`. Whether the attack is melee or missile depends on the weapon's qualities and the group's distance. - With `None`, the member attacks with the wielded weapon that suits the attack. The group's - distance when the session accepts the round decides the attack: melee at 5' or less, and - missile beyond 5'. The session chooses the weapon this way: + With `None`, the member attacks with the wielded weapon that suits the attack. The member's + distance to the group decides the attack: melee at 5' or less, and missile beyond 5'. It's the + distance when the session accepts the round, after the member's own `close` when the + declaration sets one. So a member who closes into reach with a dagger strikes with it, and a + member who stays 20' away throws it. The session chooses the weapon this way: - It looks through the member's wielded items in the order they were equipped. It takes the first one with the `melee` quality for a melee attack, or the `missile` quality for a @@ -2605,71 +2627,114 @@ class BattleDeclaration(BaseModel): The session refuses an invisible target with `battle.declaration.invisible_target`.""" move: Literal["close", "fighting_withdrawal", "retreat"] | None = None - """Which movement a `move` declaration makes. - - `close` moves the party toward `target_group_id` at the encounter rate of its slowest - living member, and stops at melee range. When members declare `close` on different - groups, the party closes on the group of the first `close` in - [`ResolveBattleRound.declarations`][osrlib.crawl.commands.ResolveBattleRound.declarations]. - - `fighting_withdrawal` and `retreat` take a member out of melee. A `fighting_withdrawal` - backs away slowly, facing the monsters, and a `retreat` turns and runs. Each one is either a - formation move or a move the member makes alone: + """Which movement this member makes this round, with a `move` action or, for `close` only, with an + `attack`. + + A battle measures distance on the range track. Each group stands at a distance from the party + line, where the party stood when the encounter opened. Each member stands at an advance forward + of that line, in [`EncounterView.advance_feet`][osrlib.crawl.views.EncounterView.advance_feet], + and starts the battle on it. A member's distance to a group is the group's distance minus their + advance. A member moves at their own encounter movement rate, + [`MemberView.encounter_rate_feet`][osrlib.crawl.views.MemberView.encounter_rate_feet]: a third of + their movement rate after encumbrance, doubled by *haste* on that member. + + `close` moves this member toward `target_group_id` at their rate, and nobody else. The member + stops 5' from that group, and stops sooner rather than come within 5' of another group that's + still in the fight. A member already within 5' of the group doesn't move. + + - With a `move` action, the member only moves. + - With an `attack`, the member moves in the party's movement step and attacks in the missile + or the melee step of the same round. The session judges the attack as if the move had + already happened. The member's distance after the move decides whether it's a melee or a + missile attack, and a melee attack needs the member to be within 5' of the group and in its + front rank after every move the round declares. A member who can't get that close this round + gets `combat.attack.out_of_reach`, with that distance as `distance_feet`, and a member who + gets close but finds no place left in the front rank gets + `battle.declaration.not_in_front_rank`. + + The monsters can act before the party. A melee attack resolves only if, at the party's melee + step, the member stands in the group's front rank within 5' of it. The one exception is a group + that has started to flee. A member who stood within 5' of it when the round was accepted still + gets the attack, with the bonus against a retreating combatant. Otherwise the member makes no + attack, and the session reports a + [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] at + `battle.melee.fizzled`, with the group's id as `group_id`. Its `reason` is + `combat.attack.out_of_reach` when the member is farther than 5' from the group, and + `battle.declaration.not_in_front_rank` when the member is within 5' but not in its front + rank. + + `fighting_withdrawal` and `retreat` take a member away from the monsters, with a `move` action. + A `fighting_withdrawal` backs away at half the member's encounter rate, facing the monsters, and + a `retreat` turns and runs at the full rate. The member makes no attack that round. Each one is + either a formation move or a move the member makes alone: - When every member in `declarers` declares the same one, it's a formation move: the whole - party moves as one body. That includes a round with only one declarer. A - `fighting_withdrawal` backs the party away from every group at half the slowest member's - encounter rate. A `retreat` backs the party away at the full rate and ends the battle. If - no monster can pursue the party, the party gets away and the encounter closes. Otherwise - the session goes back to `encounter` mode with a pursuit, which - [`PursuitState`][osrlib.crawl.encounter.PursuitState] describes. Send + party moves as one body. That includes a round with only one declarer. The party moves at the + slowest living member's encounter rate, halved for a `fighting_withdrawal`. Every group's + distance grows by that much, and no member's advance changes, so members who stand apart + stay apart. A formation `retreat` ends the battle. If no monster can pursue the party, the + party gets away and the encounter closes. Otherwise the session goes back to `encounter` mode + with a pursuit, which [`PursuitState`][osrlib.crawl.encounter.PursuitState] describes. The + pursuit's starting gap is the smallest distance from any living member to any group that can + follow, after the move. So a member who stood forward of the others starts the chase nearest + the pursuers. Send [`Wait`][osrlib.crawl.commands.Wait] to run each round of it, or [`EngageBattle`][osrlib.crawl.commands.EngageBattle] to turn and fight. - - Otherwise, each `fighting_withdrawal` or `retreat` is a move the member makes alone. It - doesn't move the party, and it doesn't end the battle, even when it's a `retreat`. The - member has to stand in the front rank. They step back and trade places in the marching - order with the back-rank member named in `replacement_id`. - - A member who declares either move makes no attack that round, because a member declares - one action per round. A member who retreats, alone or with the formation, is easier to hit - for the whole round, whichever side acts first. Attackers get +2 against them and ignore - the retreating member's shield. + - Otherwise, the member moves back alone, and their advance goes down. It can go below zero, + behind the line, because the way the party came is clear. When that leaves every member the + monsters can see behind the line, the line moves back to the foremost of them at the end of + the movement step. Every advance and every group's distance grow by the same amount, so no + distance between a member and a group changes, as + [`BattleState.advance_feet`][osrlib.crawl.battle.BattleState.advance_feet] describes. Any + member can fall back alone, wherever they stand, and nobody else moves. It doesn't end the + battle, even when it's a `retreat`. Once the member has moved, the members who are now + nearest each group form its front rank. + + A member who retreats, alone or with the formation, is easier to hit for the whole round, + whichever side acts first. Attackers get +2 against them and ignore the retreating member's + shield. + + The session refuses a `move` action with no `move` with `battle.declaration.missing_move`, a + `close` at a group that isn't in the encounter or has fled with + `battle.declaration.unknown_group`, and any move by a member in + [`EncounterView.immobile`][osrlib.crawl.views.EncounterView.immobile] with + `battle.declaration.cannot_move`. An `attack` takes only `close`, and gets + `battle.declaration.move_not_combinable` with a `fighting_withdrawal` or a `retreat`. A `cast` + that sets any move gets `battle.declaration.cast_cannot_move`, because the OSE SRD says "The + spell caster cannot move and cast a spell in the same round". A `turn_undead`, a `use_item`, or + a `hold` that sets any move gets `battle.declaration.move_not_combinable`. Each of these two + codes has the member's id as its `character` param. The session checks for them after the + back-stab checks and before the action's own checks. [The adaptations register](https://mmacy.github.io/osrlib-python/adaptations/) describes these rules in full.""" replacement_id: str | None = None - """The back-rank member who steps up into this member's place, by - [`MemberView.id`][osrlib.crawl.views.MemberView.id]. - - Set it only for a `fighting_withdrawal` or a `retreat` that this member makes alone. Leave - it `None` for every other declaration, a formation move included. The two members trade - places in the marching order during the party's movement step, and the new order stays - after the battle. - - The replacement has to meet all of these conditions: - - - They're a living member behind the front rank, and they're in - [`EncounterView.declarers`][osrlib.crawl.views.EncounterView.declarers]. - - They aren't in `immobile`, and they aren't charmed or confused. A charmed member acts - for the charmer, and a confused one acts at random. - - They haven't declared a `cast`, because a spell caster can't move and cast in the same - round. They haven't declared a `fighting_withdrawal` or a `retreat` either. - - No other withdrawing member has named them. - - Both members can cover the distance between their ranks this round. Each rank stands 5' - behind the one in front of it, so a replacement two ranks back is 10' away. The - withdrawing member moves at up to half their encounter rate for a fighting withdrawal, or - their full encounter rate for a retreat. The replacement moves at up to their full - encounter rate. *Haste* doubles the limit for the member who has it. - - The replacement can declare a melee attack in the same round, before the swap has happened. - The session checks every melee declaration against the front rank as the round's swaps will - leave it, so the replacement counts as a front-rank member. - - A swap that happens emits a - [`MemberWithdrewEvent`][osrlib.crawl.events.MemberWithdrewEvent]. If the monsters act first, - they can stop the swap. When either member dies, ends up in another rank, or can no longer - move, or the replacement becomes incapacitated, charmed, or confused, the two stay where they - are. A swap that doesn't happen emits no event, and the replacement's melee attack doesn't - resolve.""" + """A member to trade places with in the marching order, for a `fighting_withdrawal` or a + `retreat` this member makes alone, by [`MemberView.id`][osrlib.crawl.views.MemberView.id]. + + Leave it `None` for every other declaration, a formation move included. A member who falls + back alone doesn't need to name anyone. Nobody else moves, and the members who are then nearest + each group form its front rank. + + The trade changes only the marching order. It happens in the party's movement step, right + after the withdrawing member's move, and a + [`MemberWithdrewEvent`][osrlib.crawl.events.MemberWithdrewEvent] reports it. Marching order + decides who stands in front among members at the same distance from a group who got there in + the same round. So when the withdrawing member steps out of a front rank, naming a member who + stands at the same distance brings that member to the front. The trade doesn't move the named + member. A member who stands farther back reaches the front rank only with a `close` of their + own. The new marching order stays after the battle. + + The named member has to be in + [`EncounterView.declarers`][osrlib.crawl.views.EncounterView.declarers], can't be the + withdrawing member, can't be making a `fighting_withdrawal` or a `retreat` of their own, and + can't already be named by a withdrawing member earlier in marching order. Otherwise the session + refuses the declaration with `battle.declaration.replacement_unavailable`, with the withdrawing + member's id as the `character` param and the named id as `replacement`. A declaration in a + formation move that sets this field gets the same code, because every member is moving. + + The trade happens only when the withdrawing member's move happens, and only while both members + are alive. A withdrawing member who can no longer move when the movement step comes stays where + they are, and the two keep their places in the marching order.""" item_id: str | None = None """Which item a `use_item` declaration uses: a magic item's per-instance id for a potion, a scroll, a wand, a staff, or a rod, or a mundane item's catalog id for an item thrown at a @@ -2678,8 +2743,9 @@ class BattleDeclaration(BaseModel): gaze_defense: Literal["avert_eyes", "mirror"] | None = None """How this member guards against a petrifying gaze this round, or `None` for no guard. - A petrifying gaze, like a basilisk's or a medusa's, reaches each member of the party's front - rank once a round while the gazer's group is in melee. The session rolls a saving throw + A petrifying gaze, like a basilisk's or a medusa's, reaches each member who stands in the + gazer's group's [`front_rank`][osrlib.crawl.views.EncounterGroupView.front_rank], within 5' of + it, once a round. The session rolls a saving throw versus paralysis for each member it reaches, with no declaration needed. A member who fails turns to stone. The OSE SRD's basilisk entry says "Unless averting eyes or using a mirror, characters in melee are affected each round". This field picks one of those two @@ -2704,7 +2770,8 @@ class BattleDeclaration(BaseModel): until the round ends. It makes no random roll and emits no event. The command log records it with the declaration. - A member in the back rank doesn't need a guard, because the gaze doesn't reach them. + A member outside the gazer's group's front rank doesn't need a guard, because the gaze + doesn't reach them. Neither does a member who can't act and so doesn't declare, because the gaze passes them by. A basilisk that surprises the party turns its gaze on every member once as the encounter @@ -2726,9 +2793,17 @@ class BattleDeclaration(BaseModel): free round, while [`BattleState.monsters_hold_rounds`][osrlib.crawl.battle.BattleState.monsters_hold_rounds] is above zero. The session reads it when it accepts the round. - - The attack is a melee attack. The weapon and the group's distance decide that, as + - The attack is a melee attack. The weapon and the member's distance to the group after their + `close`, if the declaration sets one, decide that, as [`weapon_id`][osrlib.crawl.commands.BattleDeclaration.weapon_id] describes. + A thief can close on a group and back-stab in the same round: declare `attack` with + `move="close"` and `back_stab=True`. The session judges the back-stab at the distance the move + will reach. So a thief reaches a group up to their encounter rate plus 5' away. That's 35' for a + thief in leather armour under the ruleset's default `basic` encumbrance, and 45' for one in no + armour. *Haste* doubles the rate. A group farther away can't be back-stabbed in the free round, + because the free round is the only round the monsters are unaware. + An accepted back-stab hits the same monster an ordinary melee attack would. It gets +4 to hit, the defender's shield doesn't count, and its damage is doubled. The bonus and the doubling come from the class's `back_stab` ability. Every attack roll the declaration makes @@ -2750,9 +2825,11 @@ class BattleDeclaration(BaseModel): `battle.declaration.weapon_not_wielded`. 5. `battle.declaration.back_stab_not_melee`: the attack the session would resolve is a missile attack. That's a missile weapon like a bow, or a weapon that's both melee and - missile, like a dagger, aimed at a group farther away than 5'. + missile, like a dagger, aimed at a group more than 5' from the member after their + `close`. 6. The rest of an ordinary melee attack's checks, like - `battle.declaration.not_in_front_rank` and `combat.attack.out_of_reach`. + `battle.declaration.not_in_front_rank` and `combat.attack.out_of_reach`, judged after the + round's declared moves. A declaration that leaves this field `False` resolves exactly as it would without it. [The adaptations register](https://mmacy.github.io/osrlib-python/adaptations/) describes the @@ -2773,9 +2850,13 @@ class ResolveBattleRound(Command): into a pursuit, and to `game_over` when every party member is dead or turned to stone. The session accepts or refuses a round as a whole. It checks every declaration before - it resolves any of them. If a single declaration is bad, the session refuses the - command, and the result lists every rejection found. Fix the declarations and send the - round again. + it resolves any of them, against where the members will stand once the moves the round + declares are made. If a single declaration is bad, the session refuses the command, and + the result lists every rejection found. A refused round costs nothing: no random number, + no game time, and no change to the session. Fix the declarations and send the round + again. To check declarations before you send them, call + [`preview_battle_round`][osrlib.crawl.battle.preview_battle_round], which runs the same + checks without playing the round. Modes: `battle` @@ -2797,26 +2878,29 @@ class ResolveBattleRound(Command): id as its `character` param. - Move declarations: `battle.declaration.missing_move`, `battle.declaration.unknown_group`, and `battle.declaration.cannot_move`. - - A `fighting_withdrawal` or a `retreat` that a member makes alone gets only the - first of these that applies: `battle.declaration.not_in_front_rank` when the - member stands behind the front rank, `battle.declaration.no_replacement` when - `replacement_id` is `None`, `battle.declaration.replacement_unavailable` when the - named member can't step up, and `battle.declaration.replacement_too_far` when - either member can't cover the distance between their ranks. Each rejection has the - withdrawing member's id as its `character` param. The last two also have the - replacement's id as `replacement`, and `replacement_too_far` has the distance as - `distance_feet`. - - `battle.declaration.replacement_unavailable` also comes back for a declaration in - a formation move that sets `replacement_id`, because every declarer moves with the - formation. + - A `move` set on an action that can't take it: `battle.declaration.cast_cannot_move` + on a `cast`, and `battle.declaration.move_not_combinable` on a `turn_undead`, a + `use_item`, or a `hold`, and on an `attack` with a `fighting_withdrawal` or a + `retreat`. Each has the member's id as its `character` param. The session checks + for them after the back-stab checks and before the action's own checks. + - `battle.declaration.replacement_unavailable` when a member who makes a + `fighting_withdrawal` or a `retreat` alone names a member in `replacement_id` who + isn't a declarer, is the withdrawing member, is withdrawing too, or was named by a + withdrawing member earlier in marching order. It also comes back for a declaration + in a formation move that sets `replacement_id`, because every declarer moves with + the formation. It has the withdrawing member's id as its `character` param and the + named id as `replacement`. - Attack declarations: `battle.declaration.unknown_group`, `battle.declaration.no_target`, `battle.declaration.weapon_not_wielded`, - `battle.declaration.not_in_front_rank` for a melee attack by a member who won't - be in the front rank after the round's swaps, and the checks of - [`validate_attack`][osrlib.core.combat.validate_attack]: + `battle.declaration.cannot_move` for an attack that sets `move="close"` by a member + who can't move, `battle.declaration.not_in_front_rank` for a melee attack by a + member who won't stand in the group's front rank once the round's moves are made, + and the checks of [`validate_attack`][osrlib.core.combat.validate_attack]: `combat.attack.out_of_reach`, `combat.attack.out_of_range`, `combat.attack.reload`, `combat.attack.attacker_incapacitated`, and - `combat.attack.attacker_blind`. + `combat.attack.attacker_blind`. The session judges an attack at the member's own + distance to the group, after the member's `close` if the declaration sets one, and + `combat.attack.out_of_reach` has that distance as its `distance_feet` param. - A declaration that sets `back_stab`: `battle.declaration.back_stab_not_attack` on any action but `attack`, before any other check. On an attack, `battle.declaration.back_stab_no_ability` and @@ -2847,9 +2931,9 @@ class ResolveBattleRound(Command): `items.scroll.no_such_spell`, `items.scroll.wrong_caster`, `exploration.action.requires_light`, and `combat.attack.out_of_reach`. A staff of striking is a melee weapon, so its use also gets - `battle.declaration.not_in_front_rank` when the member won't be in the front rank - after the round's swaps. A scroll read can also get the cast checks on its spell and targets - (`magic.cast.unknown_mode`, `magic.cast.unknown_target`, + `battle.declaration.not_in_front_rank` when the member won't stand in the group's + front rank once the round's moves are made. A scroll read can also get the cast + checks on its spell and targets (`magic.cast.unknown_mode`, `magic.cast.unknown_target`, `battle.declaration.invisible_target`, `magic.cast.target_count`, `magic.cast.out_of_range`, `magic.cast.caster_incapacitated`, `magic.cast.caster_restrained`, `magic.cast.anti_magic_shell`). A thrown item @@ -2863,10 +2947,13 @@ class ResolveBattleRound(Command): [`InitiativeRolledEvent`][osrlib.core.events.InitiativeRolledEvent], which reports which side acts first. - Movement: a [`GroupMovedEvent`][osrlib.crawl.events.GroupMovedEvent] each time the - distance to a group changes, and a + Movement: a [`MemberMovedEvent`][osrlib.crawl.events.MemberMovedEvent] each time a + member moves by themselves, a [`MemberWithdrewEvent`][osrlib.crawl.events.MemberWithdrewEvent] each time a member - who withdraws alone trades places with a member from a back rank. + who falls back alone trades places in the marching order with the member they named, + and a [`GroupMovedEvent`][osrlib.crawl.events.GroupMovedEvent] each time a group's + distance from the party line changes, when monsters close or run, and when the whole + party moves in a formation move. Missiles and melee: [`AttackRolledEvent`][osrlib.core.events.AttackRolledEvent], @@ -2886,6 +2973,8 @@ class ResolveBattleRound(Command): [`SpellDisruptedEvent`][osrlib.core.events.SpellDisruptedEvent], [`DeclarationFizzledEvent`][osrlib.core.events.DeclarationFizzledEvent] when a device use or a turning fails the check the session makes just before it resolves, + and at `battle.melee.fizzled` when a melee attack can't resolve because of where the + member stands when it comes up, [`MagicDispelledEvent`][osrlib.core.events.MagicDispelledEvent], [`UndeadTurnedEvent`][osrlib.core.events.UndeadTurnedEvent], and the effects a spell leaves behind @@ -2926,9 +3015,11 @@ class ResolveBattleRound(Command): dead or turned to stone. Examples: - A fighter and a cleric meet three goblins 30 feet away. Each round, every member - in `declarers` closes the distance until the nearest group is in melee reach. - Then the front rank attacks with the weapon it wields and the rest hold: + A fighter and a cleric meet three goblins 30 feet away. Each round, each member in + `declarers` plans an attack with a `close`. A member out of reach closes first, and a + member already in reach doesn't move and attacks where they stand. When the preview + refuses that plan, because the member can't reach the goblins this round or the front + rank toward them is full, the member closes without attacking: ```python from osrlib.core.alignment import Alignment @@ -2937,7 +3028,7 @@ class ResolveBattleRound(Command): from osrlib.core.rng import RngStreams from osrlib.core.ruleset import Ruleset from osrlib.crawl.adventure import Adventure, TownSpec - from osrlib.crawl.battle import MELEE_RANGE_FEET + from osrlib.crawl.battle import preview_battle_round from osrlib.crawl.commands import ( BattleDeclaration, EngageBattle, @@ -2972,32 +3063,27 @@ class ResolveBattleRound(Command): if session.mode is SessionMode.ENCOUNTER: # the goblins didn't attack first session.execute(EngageBattle()) - rounds = 0 while session.mode is SessionMode.BATTLE: encounter = session.view(Visibility.PLAYER).encounter - target = min((group for group in encounter.groups if group.count), key=lambda group: group.distance_feet) - declarations = [] + target = next(group for group in encounter.groups if group.count and not group.fled) + planned = [] for member_id in encounter.declarers: - if target.distance_feet > MELEE_RANGE_FEET: + # With no weapon_id, the member attacks with the wielded weapon that suits the attack. + declaration = BattleDeclaration( + character_id=member_id, action="attack", move="close", target_group_id=target.id + ) + if preview_battle_round(session, [*planned, declaration]).rejections[member_id]: declaration = BattleDeclaration( character_id=member_id, action="move", move="close", target_group_id=target.id ) - elif member_id in encounter.front_rank: - # With no weapon_id, the member attacks with the wielded weapon that suits the attack. - declaration = BattleDeclaration(character_id=member_id, action="attack", target_group_id=target.id) - else: - declaration = BattleDeclaration(character_id=member_id, action="hold") - declarations.append(declaration) - result = session.execute(ResolveBattleRound(declarations=tuple(declarations))) + planned.append(declaration) + result = session.execute(ResolveBattleRound(declarations=tuple(planned))) if not result.accepted: # Nothing happened. A front end shows the reasons and asks for new declarations. print([rejection.code for rejection in result.rejections]) break - rounds += 1 - # The fight took 9 rounds, and the session went back to exploring once it was over. - print(rounds, session.mode) - # 9 exploring + assert session.battle is None # the fight is over, won or lost ``` """ diff --git a/src/osrlib/crawl/encounter.py b/src/osrlib/crawl/encounter.py index c8db8a7..082ebca 100644 --- a/src/osrlib/crawl/encounter.py +++ b/src/osrlib/crawl/encounter.py @@ -218,9 +218,14 @@ class EncounterGroup(BaseModel): members stay in the list, so check each monster for the dead condition to find the living ones.""" distance_feet: int = Field(ge=0) - """The distance between the party and this group, in feet. In a battle, monsters close at their - encounter movement rate and stop at - [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET]. A routed group moves away.""" + """The distance between the party line and this group, in feet. The party line is where the party + stood when the encounter opened. In a battle, each member stands at their own advance from that + line, in [`BattleState.advance_feet`][osrlib.crawl.battle.BattleState.advance_feet], and monsters + close at their encounter movement rate until they're within + [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET] of the nearest member they can see. A + routed group moves away. When every member the monsters can see has fallen back behind the line, + the line moves back to the foremost of them, and every group's distance grows by the same amount, + as `BattleState.advance_feet` describes.""" fleeing: bool = False """True once the group has failed a morale check and turned to run. Until it gets away, the party can still attack it.""" @@ -260,9 +265,11 @@ class PursuitState(BaseModel): reaches [`PURSUIT_ROUND_CAP`][osrlib.crawl.encounter.PURSUIT_ROUND_CAP].""" gap_feet: int = Field(ge=0) """The distance between the party and its pursuers, in feet. It starts at the distance of the - nearest pursuing group. Each round, it grows by the party's running rate and shrinks by the - running rate of the slowest pursuing group. At 5 feet or less, the pursuers catch the party, and a - battle starts.""" + nearest pursuing group. When the pursuit follows a retreat from a battle, it starts at the smallest + distance from any living member to any group that can follow, after the retreat's move, because + members can stand at different places on the battle's range track. Each round, it grows by the + party's running rate and shrinks by the running rate of the slowest pursuing group. At 5 feet or + less, the pursuers catch the party, and a battle starts.""" class EncounterState(BaseModel): diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 163d0b4..042ab0e 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -123,6 +123,7 @@ "ListenedEvent", "LocationEnteredEvent", "MemberAddedEvent", + "MemberMovedEvent", "MemberRemovedEvent", "MemberWithdrewEvent", "MonsterDefeatedEvent", @@ -1165,14 +1166,27 @@ class SpellDeclaredEvent(Event): class GroupMovedEvent(Event): - """A monster group's distance from the party changed. + """A monster group's distance from the party line changed. + + The party line is where the party stood when the encounter opened. Each group's distance is + measured from it, and each party member stands at their own place on the same track, which + [`MemberMovedEvent`][osrlib.crawl.events.MemberMovedEvent] reports. When every member the + monsters can see has fallen back behind the line, the line moves back to the foremost of them, + and every group's distance grows by the same amount with no event, because nobody moved toward + or away from anyone. [`BattleState.advance_feet`][osrlib.crawl.battle.BattleState.advance_feet] + describes it. Emitted by [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] - whenever a group's distance changes: the party closing on a group or backing away - from every group, monsters closing to strike, and a broken group running away. + whenever a group's distance changes: monsters closing to strike, a broken group running + away, and the whole party backing away from every group in a formation move. A member who + closes on a group by themselves doesn't move the group, so that emits a `MemberMovedEvent` + and no `GroupMovedEvent`. Distance decides who can reach whom, so redraw a battle screen's ranks from this - event. + event. A member's distance to this group is the group's `distance_feet` minus the member's + entry in [`EncounterView.advance_feet`][osrlib.crawl.views.EncounterView.advance_feet], and + [`EncounterGroupView.member_distance_feet`][osrlib.crawl.views.EncounterGroupView.member_distance_feet] + lists it for you. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.group.moved"}) @@ -1187,40 +1201,96 @@ class GroupMovedEvent(Event): group_id: str """Which group moved, as its encounter group id.""" distance_feet: int - """How far that group now is from the party, in feet, after the move. A group that closes in - stops at [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET], where the two sides fight - hand to hand.""" + """How far that group now is from the party line, in feet, after the move. A group that closes + in stops within [`MELEE_RANGE_FEET`][osrlib.crawl.battle.MELEE_RANGE_FEET] of the nearest + member it can see, where the two fight hand to hand.""" -class MemberWithdrewEvent(Event): - """A party member stepped back out of the front rank, and a member from behind took their place. +class MemberMovedEvent(Event): + """A party member moved by themselves on the range track, closing on a group or falling back. - Emitted by [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] in the party's - movement step. + The range track is how a battle measures distance. Each group stands at a distance from the + party line, the place where the party stood when the encounter opened. Each member stands at + an advance, in feet, forward of that line toward the monsters. Every member starts the battle + on the line, at an advance of zero. - A front-rank member can step back alone by declaring a fighting withdrawal or a retreat. - The member can also name a member from behind in - [`replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id]. Then the two - trade places in the marching order, and the session emits this event: + Emitted by [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] in the party's + movement step, once for each member whose advance changes: + + - A member who declares `move="close"`, with a `move` action or with an `attack`, moves at + their own encounter movement rate toward the group they named. They stop 5' from that group, + and they never come closer than 5' to another group that's still in the fight. + - A member who makes a `fighting_withdrawal` or a `retreat` that not every declarer makes moves + back alone, at half their encounter rate or their full rate. Their advance goes down, and it + can go below zero, behind the line. + + When the moves leave every member the monsters can see behind the line, the line moves back to + the foremost of them at the end of the movement step, as + [`BattleState.advance_feet`][osrlib.crawl.battle.BattleState.advance_feet] describes. The session + emits no event for that, and no distance between a member and a group changes. + + The events come in two runs, each in marching order: the members who fall back first, then + the members who close. A member who closes while already within 5' of the group doesn't move, + and the session emits nothing for them. A formation move, a defensive move that every declarer + makes, moves the groups instead of the members, so it emits + [`GroupMovedEvent`][osrlib.crawl.events.GroupMovedEvent]s and none of these. + + Redraw the member's place on a battle screen from this event, or read + [`EncounterView.advance_feet`][osrlib.crawl.views.EncounterView.advance_feet] and + [`EncounterGroupView.member_distance_feet`][osrlib.crawl.views.EncounterGroupView.member_distance_feet] + from the next player view. + """ - - The withdrawing member now stands where the replacement stood. - - The replacement now stands in the front rank. - - The party hasn't moved, and no group's distance has changed. + allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.member.moved"}) + """The only message code this event uses.""" - The events come in the marching order of the withdrawing members, before the party's - `close`, if the round has one. + event_type: Literal["member_moved"] = "member_moved" + """The event type, `member_moved`, which identifies this class in serialized form.""" + code: str = "battle.member.moved" + """The message code, always `battle.member.moved`.""" + visibility: Visibility = Visibility.PLAYER + """Player visibility: the party sees its own members move.""" + character_id: str + """The member who moved, by party member id.""" + move: str + """The move the member made: `"close"`, `"fighting_withdrawal"`, or `"retreat"`, as they + declared it in [`BattleDeclaration.move`][osrlib.crawl.commands.BattleDeclaration.move].""" + advance_feet: int + """The member's advance right after the move, in feet forward of the party line. Zero is the line, + and a negative value is behind it. When the party line moves back at the end of the movement step, + the member's advance grows by the same amount as everyone's, and the next player view shows it.""" + group_id: str + """The group `distance_feet` is measured to. For a `close`, it's the group the member closed + on. For a `fighting_withdrawal` or a `retreat`, it's the group nearest the member after the + move, among the groups that haven't fled and still have a living member. When two are equally + near, it's the one that comes first in + [`EncounterView.groups`][osrlib.crawl.views.EncounterView.groups].""" + distance_feet: int + """The member's distance to the group in `group_id` after the move, in feet. It's that group's + distance from the party line minus `advance_feet`. At 5' or less, the member and the group can + reach each other in melee.""" - A swap that can't happen by the movement step reports nothing, and the two members stay - where they are. That happens in these cases: - - One of the two has died or can no longer move. - - The replacement is incapacitated, charmed, or confused, so they can no longer act on the - party's behalf. - - The ranks shifted, and one of the two no longer stands where they did. +class MemberWithdrewEvent(Event): + """A member who fell back alone traded places in the marching order with the member they named. - Redraw a battle screen's ranks from this event, or read them again from - [`PlayerView.party`][osrlib.crawl.views.PlayerView.party] and - [`EncounterView.front_rank`][osrlib.crawl.views.EncounterView.front_rank]. + Emitted by [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] in the party's + movement step, right after the withdrawing member's + [`MemberMovedEvent`][osrlib.crawl.events.MemberMovedEvent]. + + A member who makes a fighting withdrawal or a retreat alone can name another member in + [`replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id]. The two then + trade places in the marching order, and the session emits this event. The trade moves + nobody on the range track. It decides who stands in front among members at the same + distance from a group who got there in the same round, and it's the marching order the + party keeps after the battle. + + The trade happens only when the withdrawing member's move happens, and only while both + members are alive. A member who can no longer move when the movement step comes doesn't + fall back, so they don't trade places either, and the session emits neither event. + + Redraw a battle screen's marching order from this event, or read it again from + [`PlayerView.party`][osrlib.crawl.views.PlayerView.party]. """ allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.member.withdrew", "battle.member.retreated"}) @@ -1230,11 +1300,13 @@ class MemberWithdrewEvent(Event): event_type: Literal["member_withdrew"] = "member_withdrew" """The event type, `member_withdrew`, which identifies this class in serialized form.""" visibility: Visibility = Visibility.PLAYER - """Player visibility: the party sees its own ranks change.""" + """Player visibility: the party sees its own marching order change.""" character_id: str - """The member who stepped back, by party member id.""" + """The member who fell back, by party member id. They now have the replacement's place in the + marching order.""" replacement_id: str - """The member who stepped up into the front rank in their place, by party member id.""" + """The member they named, by party member id. They now have the withdrawing member's place in + the marching order.""" class MonsterFledEvent(Event): @@ -2207,6 +2279,7 @@ class MemberRemovedEvent(Event): BattleRoundEvent, SpellDeclaredEvent, GroupMovedEvent, + MemberMovedEvent, MemberWithdrewEvent, MonsterFledEvent, MonstersLeftBehindEvent, diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index afc33c8..4bdeaf4 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -193,6 +193,20 @@ class MemberView(BaseModel): """The prepared spells, one dumped [`MemorizedSpell`][osrlib.core.spells.MemorizedSpell] per copy, in memorization order. Empty for a class that casts nothing.""" + encounter_rate_feet: int = 0 + """How far the member can move in one round of a battle or an encounter, in feet. + + It's a third of the member's movement rate after encumbrance, doubled while *haste* is on them. + In a battle, a member who declares `move="close"` moves up to this far toward the group, and + stops 5' from it. So a member can close on a group and attack it in the same round when their + distance to it, in + [`EncounterGroupView.member_distance_feet`][osrlib.crawl.views.EncounterGroupView.member_distance_feet], + is at most this rate plus 5'. Use it to offer "close in and attack" only to the members who can + reach. + + It doesn't account for conditions. A member who can't move this round, like one held by a web, + still has a rate here, and + [`EncounterView.immobile`][osrlib.crawl.views.EncounterView.immobile] lists them.""" class MemberEffectView(BaseModel): @@ -320,6 +334,59 @@ class EncounterGroupView(BaseModel): session refuses every declaration aimed at it with `battle.declaration.unknown_group`. So don't offer it as a target. A group that's running but hasn't got away yet isn't fled. You can still close on it, shoot at it, or cast at it.""" + member_distance_feet: dict[str, int] = {} + """Each living party member's distance to this group, in feet, keyed by member id. Empty outside a + battle. + + A battle measures each group's `distance_feet` from the party line, where the party stood when + the encounter opened or where the line has moved back to since, and each member stands at their + own advance from that line, in + [`EncounterView.advance_feet`][osrlib.crawl.views.EncounterView.advance_feet]. This is the group's + distance minus the member's advance. At 5' or less, the member and the group can reach each other + in melee. Beyond that, a member attacks the group with a missile weapon, or declares + `move="close"` to move toward it first. A missile's range band and a spell's range are measured + from this distance.""" + front_rank: tuple[str, ...] = () + """The members who can make a melee attack on this group this round without moving, and whom this + group's melee reaches, in marching order. Empty outside a battle, and empty while nobody stands + within 5' of the group. + + These are the members in the party's front rank toward this group who stand within 5' of it. The + ranks toward a group are the living members sorted by their distance to it, nearest first, and cut + into rows of [`EncounterView.formation_width`][osrlib.crawl.views.EncounterView.formation_width]. + A row never mixes distances. Arrival order, then marching order, decides who is in the front row, + so the member who got there in an earlier round stands in front of one who arrives later. This + tuple then lists that row's members in marching order. It's always the same value as + [`group_front_rank`][osrlib.crawl.battle.group_front_rank] for this group. + + The session refuses a melee attack on this group by any other member with + `battle.declaration.not_in_front_rank`, or with `combat.attack.out_of_reach` when the member is + farther than 5' away, unless the same declaration's `move="close"` brings them into it. When this + tuple has fewer members than `formation_width`, a member who can reach the group this round can + still take a place in it. [`preview_battle_round`][osrlib.crawl.battle.preview_battle_round] + answers that for a plan you're building. + + Each of this group's monsters picks its melee target from these members, leaving out invisible + members and members whose *protection from evil* bars it. A monster with a missile attack closes + and uses it at 5' too, so the members behind this rank are out of a monster group's reach. NPC + adventurers with missile weapons shoot from farther away, at the members in the first row of the + party's ranks toward their group.""" + lit: bool | None = None + """Whether a light the party carries reaches this group, or `None` outside a battle. + + The group is lit when a living member with a burning light, like a torch or a lantern, stands + within that light's radius of it, 30' for a torch or a lantern. A darkness effect that puts out + the party's light, as [`GameSession.party_light`][osrlib.crawl.session.GameSession.party_light] + reports, leaves every group unlit. The session refuses no declaration because a group is unlit, so + use this field and `seen_by` to decide what your game offers or how it describes the fight.""" + seen_by: tuple[str, ...] = () + """The living members who can see this group, in marching order. Empty outside a battle. + + A member sees the group when it's `lit`, or when the member has working infravision and the group + is within its range. A dwarf's and an elf's infravision reaches 60', and so does infravision an + effect grants. A darkness effect that stops infravision, as + [`GameSession.party_light`][osrlib.crawl.session.GameSession.party_light] reports, leaves it + working for nobody. A blind member sees nothing.""" class EncounterView(BaseModel): @@ -328,12 +395,18 @@ class EncounterView(BaseModel): You get one from [`PlayerView.encounter`][osrlib.crawl.views.PlayerView.encounter], which is `None` when the party isn't in an encounter. - The id tuples `declarers`, `front_rank`, `immobile`, and `reloading` describe the battle - round as the players see it at the table: who is able to act, who stands close enough to - attack in melee, who is held fast, and who is still reloading a crossbow. Each one matches - a rejection that would refuse the whole round, so if you read them, you offer only the - declarations the session accepts. Without them, you have to guess how many members stand in - the front rank, and a wrong guess costs the party its turn. + In a battle, each party member stands at their own place on the range track, the + one-dimensional measure of distance a battle uses. `advance_feet` gives each member's place, and + each group in `groups` gives every member's distance to it and the members who can reach it in + melee. Read those to draw the fight and to offer each member the moves and attacks they can make. + + The id tuples `declarers`, `immobile`, and `reloading` describe the battle round as the players + see it at the table: who is able to act, who is held fast, and who is still reloading a crossbow. + Each group's [`front_rank`][osrlib.crawl.views.EncounterGroupView.front_rank] says who can attack + that group in melee this round. Each of these matches a rejection that would refuse the whole + round, so if you read them, you offer only the declarations the session accepts. To check a + choice against the choices already made for the round, call + [`preview_battle_round`][osrlib.crawl.battle.preview_battle_round]. """ model_config = ConfigDict(frozen=True) @@ -358,27 +431,62 @@ class EncounterView(BaseModel): other roster, a slept or paralysed member included, is rejected whole with `battle.declaration.roster_mismatch`.""" front_rank: tuple[str, ...] = () - """The living members close enough to attack in melee, in marching order. That is the - party's first rank at the current formation width, or every living member when the - ruleset's `formation_width_limit` flag is off. - - Only these members can make a `fighting_withdrawal` or a `retreat` alone. The session - rejects a melee attack declared for anyone else with - `battle.declaration.not_in_front_rank`. Inside melee reach, a weapon that's both melee and - missile counts as a melee weapon. A staff of striking is a melee weapon, so the session - refuses a `use_item` of one the same way. The one exception is a member that a front-rank - member names in - [`BattleDeclaration.replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id] - to step up this round. That member may declare a melee attack or strike with the staff.""" + """The first row of the party's ranks toward the nearest group, in marching order. + + The nearest group is the group that hasn't fled and has a living member, and that stands closest + to any living member. When two are equally near, it's the one that comes first in `groups`. The + ranks toward a group are the living members sorted by their distance to it and cut into rows of + `formation_width`, and this is the first row, however far away it stands. With the ruleset's + `formation_width_limit` flag off, the row holds every living member at the nearest distance. + Outside a battle, every member stands on the party line, so it's the party's first rank in + marching order. + + In a fight with one group, before any member has moved, it's the members who can attack that + group in melee once it's within 5'. As soon as members stand at different distances, or the party + fights more than one group, read each group's + [`front_rank`][osrlib.crawl.views.EncounterGroupView.front_rank] instead, which says exactly who + can attack that group in melee this round.""" immobile: tuple[str, ...] = () """The declarers who can't move this round. In practice they're the entangled members, because the other conditions that stop a move stop every declaration as well. A `move` declaration of any kind from one of them is rejected with - `battle.declaration.cannot_move`, and none of them can step up as a replacement.""" + `battle.declaration.cannot_move`, and so is an `attack` from one of them that sets + `move="close"`.""" reloading: tuple[str, ...] = () """The members who may not fire a `reload` weapon this round, because they fired one last round (`combat.attack.reload`). Empty when the ruleset's `weapon_reload` flag is off, so you can combine this list with the weapon's qualities and never read the flag.""" + advance_feet: dict[str, int] = {} + """Each living member's place on the range track, keyed by member id, in feet forward of the party + line. Empty outside a battle. + + The party line is where the party stood when the encounter opened. Each group's `distance_feet` + is measured from it. Every member starts a battle on the line, at zero. A member who closes on a + group moves forward, and a member who falls back alone moves back, below zero once they're behind + the line. When every member the monsters can see is behind the line, the line moves back to the + foremost of them. This field and each group's `distance_feet` then show the moved values, and no + distance between a member and a group changes. Use it to draw where each member stands. Each group's + [`member_distance_feet`][osrlib.crawl.views.EncounterGroupView.member_distance_feet] gives the + distance it leaves between the member and that group.""" + formation_width: int | None = None + """How many members can fight side by side where the party stands, or `None` when the ruleset's + `formation_width_limit` flag is off and a rank has no width limit. + + The width is the side of the largest square of open cells that includes the party's cell, times + 10', divided by 5' for each combatant + ([`FIGHTER_FRONTAGE_FEET`][osrlib.crawl.battle.FIGHTER_FRONTAGE_FEET]). A one-cell corridor gives + 2, and so does a crossroads of one-cell corridors. A room gives twice its shorter side in cells: a + 4 by 4 room gives 8, and a 2 by 8 room gives 4. The ranks toward each group are rows this wide, so + a group's [`front_rank`][osrlib.crawl.views.EncounterGroupView.front_rank] never holds more members + than this. Compare the two to show how many places are left in a group's front rank.""" + party_free_round: bool = False + """True in the round the party gets free after it surprised the monsters, while the monsters can't + act. False in every other round, and outside a battle. + + It's the round in which a thief's back-stab is legal: an `attack` that sets + [`BattleDeclaration.back_stab`][osrlib.crawl.commands.BattleDeclaration.back_stab]. It's true while + [`BattleState.monsters_hold_rounds`][osrlib.crawl.battle.BattleState.monsters_hold_rounds] is above + zero.""" class ObjectiveView(BaseModel): diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index 6c78817..e26a6cd 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -229,6 +229,15 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: f"{event.actor_id}'s use of {event.item_id} fizzles. The device keeps its charge." ), "magic.turning.fizzled": lambda event: f"{event.actor_id}'s attempt to turn undead fizzles.", + "battle.melee.fizzled": lambda event: ( + f"{event.actor_id} " + + ( + "can't get into the front rank against" + if event.reason == "battle.declaration.not_in_front_rank" + else "can't reach" + ) + + f" {event.group_id or 'the group'}, and makes no attack." + ), "magic.memory.forgotten": lambda event: f"{event.caster_id} forgets {event.spell_id}.", "magic.book.added": lambda event: f"{event.caster_id} adds {event.spell_id} to their spell book.", "magic.turning.turned": lambda event: _turning(event, "the undead are turned"), @@ -366,11 +375,16 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: f"{event.caster_id} begins casting {event.spell_id}" + (" (reversed)" if event.reversed else "") + "." ), "battle.group.moved": lambda event: f"{event.group_id} is now {event.distance_feet}' away.", + "battle.member.moved": lambda event: ( + f"{event.character_id} " + + {"close": "closes", "fighting_withdrawal": "falls back", "retreat": "retreats"}.get(event.move, "moves") + + f" to {event.distance_feet}' from {event.group_id}." + ), "battle.member.withdrew": lambda event: ( - f"{event.character_id} falls back, and {event.replacement_id} steps up to the front rank." + f"{event.character_id} falls back and trades places in the marching order with {event.replacement_id}." ), "battle.member.retreated": lambda event: ( - f"{event.character_id} turns and retreats, and {event.replacement_id} steps up to the front rank." + f"{event.character_id} turns and retreats, and trades places in the marching order with {event.replacement_id}." ), "battle.side.fled": lambda event: f"{event.group_id} flees the battle!", "battle.group.left_behind": lambda event: ( diff --git a/tests/positions_fixtures.py b/tests/positions_fixtures.py new file mode 100644 index 0000000..9f89027 --- /dev/null +++ b/tests/positions_fixtures.py @@ -0,0 +1,340 @@ +"""Shared setup for the acceptance tests of member positions on the range track (issue #209). + +The battles are built by command from seeded sessions, as the other battle tests build them. The party is the one +from the design's walk-through: two fighters and a cleric in plate (20' a round under basic encumbrance), a thief in +leather (30'), an elf in chain mail (20'), and a magic-user in no armour (40'). Every member has ability scores of +10, so no modifier from a score touches an attack, and 400 hit points, so nobody falls by chance. + +`battle` takes the first seed from `SEEDS` that opens the battle it describes, so two calls with the same arguments +give the same session. `HoldPolicy` keeps the monsters still while a test sets members at the distances it needs, +without depending on the monsters' tactics. The helpers assert their own preconditions. +""" + +from osrlib.core.abilities import AbilityScore +from osrlib.core.character import Character +from osrlib.core.combat import incapacitated +from osrlib.core.effects import EffectDefinition +from osrlib.core.events import AttackRolledEvent, Event, InitiativeRolledEvent, SavingThrowRolledEvent +from osrlib.core.ruleset import Ruleset +from osrlib.core.spells import MemorizedSpell, memorize_spells +from osrlib.crawl.adventure import Adventure, TownSpec +from osrlib.crawl.battle import MonsterAction +from osrlib.crawl.commands import ( + BattleDeclaration, + CommandResult, + EngageBattle, + EnterDungeon, + EquipItem, + GrantItem, + LightSource, + ResolveBattleRound, + SessionMode, + SpawnMonsters, + SpawnNpcParty, +) +from osrlib.crawl.dungeon import AreaSpec, DungeonSpec, Edge, EdgeKind, LevelSpec +from osrlib.crawl.events import GroupMovedEvent, MemberMovedEvent, MemberWithdrewEvent +from osrlib.crawl.party import Party +from osrlib.crawl.session import GameSession +from osrlib.data import load_classes, load_spells +from osrlib.persistence import session_state + +SEEDS = range(0, 300) +"""Seeds `battle` scans for one that opens the battle a test asks for.""" + +HILD = ("Hild", "fighter") +OSRIC = ("Osric", "fighter") +NIM = ("Nim", "thief") +AELIN = ("Aelin", "elf") +WREN = ("Wren", "magic_user") +TAM = ("Tam", "cleric") + +WALK = (HILD, OSRIC, NIM, AELIN, WREN, TAM) +"""The walk-through party, in marching order.""" + +ARMOUR = {"Hild": "plate_mail", "Osric": "plate_mail", "Tam": "plate_mail", "Nim": "leather", "Aelin": "chainmail"} +"""What each member wears. Plate and chain mail give 20' a round, leather 30', and no armour 40'.""" + +WEAPONS = {"Hild": ("sword",), "Osric": ("sword",), "Nim": ("dagger",), "Aelin": ("short_bow",), "Tam": ("mace",)} +"""What each member wields. Wren wields nothing and fights unarmed.""" + +RATE = {"Hild": 20, "Osric": 20, "Nim": 30, "Aelin": 20, "Wren": 40, "Tam": 20} +"""Each member's encounter rate in feet per round, with the armour in `ARMOUR`.""" + +HASTE = EffectDefinition(kind="haste", params={"attacks_multiplier": 2, "movement_multiplier": 2}) + + +def character(name: str, class_id: str, *, level: int = 3, hp: int = 400) -> Character: + """A character with scores of 10 and `hp` hit points.""" + definition = load_classes().get(class_id) + return Character( + name=name, + class_id=class_id, + race=definition.race, + level=level, + xp=definition.row(level).xp, + scores={ability: 10 for ability in AbilityScore}, + alignment="lawful", + max_hp=hp, + current_hp=hp, + ) + + +def corridor() -> Adventure: + """One level: a 10' corridor two cells long, so the formation is two wide.""" + level = LevelSpec(number=1, width=2, height=1, entrance=(0, 0), edges={"1,0:west": Edge(kind=EdgeKind.OPEN)}) + crypt = DungeonSpec(id="crypt", name="Crypt", levels=(level,)) + return Adventure(name="A", town=TownSpec(name="Threshold", travel_turns={"crypt": 1}), dungeons=(crypt,)) + + +def hall() -> Adventure: + """One level: a room two cells square, so the formation is four wide.""" + edges = { + "1,0:west": Edge(kind=EdgeKind.OPEN), + "1,1:west": Edge(kind=EdgeKind.OPEN), + "0,1:north": Edge(kind=EdgeKind.OPEN), + "1,1:north": Edge(kind=EdgeKind.OPEN), + } + room = AreaSpec(id="hall", name="Hall", cells=((0, 0), (1, 0), (0, 1), (1, 1))) + level = LevelSpec(number=1, width=2, height=2, entrance=(0, 0), edges=edges, areas=(room,)) + crypt = DungeonSpec(id="crypt", name="Crypt", levels=(level,)) + return Adventure(name="A", town=TownSpec(name="Threshold", travel_turns={"crypt": 1}), dungeons=(crypt,)) + + +def session( + seed: int, + roster=WALK, + *, + spells: dict[str, tuple[str, ...]] | None = None, + levels: dict[str, int] | None = None, + light: bool = False, + adventure: Adventure | None = None, + ruleset: Ruleset | None = None, +) -> GameSession: + """The roster in the dungeon, armoured and armed by command, with `spells` memorized before the session opens. + + With `light`, the first member lights a torch, so the monsters can't be surprised. + """ + members = [character(name, class_id, level=(levels or {}).get(name, 3)) for name, class_id in roster] + catalog = load_spells() + for member in members: + prepared = (spells or {}).get(member.name, ()) + if prepared: + definition = load_classes().get(member.class_id) + if definition.id in ("magic_user", "elf"): + member.spell_book = tuple(dict.fromkeys(prepared)) + memorize_spells(member, definition, catalog, [MemorizedSpell(spell_id=spell_id) for spell_id in prepared]) + game = GameSession.new(Party(members=members), adventure or corridor(), seed=seed, ruleset=ruleset) + for member in game.party.members: + gear = [*((ARMOUR[member.name],) if member.name in ARMOUR else ()), *WEAPONS.get(member.name, ())] + for item_id in gear: + assert game.execute(GrantItem(character_id=member.id, item_id=item_id)).accepted + assert game.execute(EquipItem(character_id=member.id, item_id=item_id)).accepted + if member.name == "Aelin": + assert game.execute(GrantItem(character_id=member.id, item_id="arrows", quantity=40)).accepted + if light: + first = game.party.members[0].id + assert game.execute(GrantItem(character_id=first, item_id="torch", quantity=6)).accepted + assert game.execute(GrantItem(character_id=first, item_id="tinder_box")).accepted + assert game.execute(EnterDungeon(dungeon_id="crypt")).accepted + if light: + for _ in range(20): + lit = game.execute(LightSource(character_id=game.party.members[0].id, item_id="torch")) + if any(event.code == "exploration.light.lit" for event in lit.events): + break + assert game.party_light()[0], "precondition: the party carries a lit torch" + return game + + +def _opened(game: GameSession, result: CommandResult, *, surprised: bool, engage: bool) -> bool: + """Whether the encounter `result` opened is the one wanted. The party is never surprised.""" + assert result.accepted, [rejection.code for rejection in result.rejections] + sides = {event.side: event.surprised for event in result.events if event.code == "encounter.surprise.rolled"} + if sides.get("party") is not False or sides.get("monsters") is not surprised: + return False + if not engage: + return game.mode is SessionMode.ENCOUNTER + if game.mode is SessionMode.ENCOUNTER: + assert game.execute(EngageBattle()).accepted + if game.mode is not SessionMode.BATTLE or game.battle.round != 0: + return False + assert game.battle.monsters_hold_rounds == (1 if surprised else 0), "precondition: the free round as wanted" + return True + + +def battle( + roster=WALK, + *, + template_id: str = "goblin", + count: int = 2, + distance: int = 30, + surprised: bool = False, + npc_party: bool = False, + engage: bool = True, + first_seed: int = SEEDS.start, + **session_args, +) -> GameSession: + """A battle against one group spawned by command at `distance`, at round 0, with the party unsurprised. + + With `surprised`, the party surprised the monsters, so the first round is the party's free round. Without it, + neither side was surprised. With `npc_party`, the group is a basic party of NPC adventurers. Without `engage`, + the session stays in `encounter` mode. + """ + for seed in range(first_seed, SEEDS.stop): + game = session(seed, roster, **session_args) + if npc_party: + spawn = SpawnNpcParty(party_kind="basic", distance_feet=distance) + else: + spawn = SpawnMonsters(template_id=template_id, count_fixed=count, distance_feet=distance) + if not _opened(game, game.execute(spawn), surprised=surprised, engage=engage): + continue + assert game.encounter.groups[0].distance_feet == distance, "precondition: the group at its distance" + return game + raise AssertionError(f"precondition: no seed opened the battle (surprised={surprised})") + + +def seeds(**battle_args): + """Each seed from which `battle` builds the battle `battle_args` describe, in order.""" + seed = SEEDS.start + while True: + seed = battle(first_seed=seed, **battle_args).master_seed + yield seed + seed += 1 + + +def named(game: GameSession, name: str) -> str: + return next(member.id for member in game.party.members if member.name == name) + + +def group_id(game: GameSession, index: int = 0) -> str: + return game.encounter.groups[index].id + + +def declare(member_id: str, action: str = "hold", **fields) -> BattleDeclaration: + return BattleDeclaration(character_id=member_id, action=action, **fields) + + +def close(game: GameSession, member_id: str, *, group: str | None = None) -> BattleDeclaration: + """A `move` that closes on the first group, or on `group`.""" + return declare(member_id, "move", move="close", target_group_id=group or group_id(game)) + + +def strike(game: GameSession, member_id: str, *, group: str | None = None, **fields) -> BattleDeclaration: + """An `attack` on the first group, or on `group`, with the session's choice of weapon unless `fields` names one.""" + return declare(member_id, "attack", target_group_id=group or group_id(game), **fields) + + +def able(game: GameSession) -> list[str]: + return [member.id for member in game.party.living_members() if not incapacitated(member)] + + +def plan(game: GameSession, *declared: BattleDeclaration) -> tuple[BattleDeclaration, ...]: + """The members in `declared` as given, and a `hold` for every other able member, in marching order.""" + by_id = {declaration.character_id: declaration for declaration in declared} + members = able(game) + assert set(by_id) <= set(members), "precondition: every declared member can act" + return tuple(by_id.get(member_id, declare(member_id)) for member_id in members) + + +def round_(game: GameSession, *declared: BattleDeclaration) -> CommandResult: + return game.execute(ResolveBattleRound(declarations=plan(game, *declared))) + + +def play(game: GameSession, *declared: BattleDeclaration) -> list[Event]: + """Send one round, assert it was accepted, and return its events.""" + result = round_(game, *declared) + assert result.accepted, [(rejection.code, dict(rejection.params)) for rejection in result.rejections] + return list(result.events) + + +def state_without_logs(game: GameSession) -> dict: + """Every state block, the clock and the random number streams among them, without the two logs.""" + state = session_state(game) + state.pop("command_log") + state.pop("event_log") + return state + + +def refused(game: GameSession, *declared: BattleDeclaration) -> list[tuple[str, dict]]: + """Send a round that should be refused, check that it cost nothing, and return its rejections in order.""" + before = state_without_logs(game) + log_size = len(game.command_log) + result = round_(game, *declared) + assert not result.accepted, "the round was accepted" + assert result.events == () + assert state_without_logs(game) == before, "a refused round changed the session" + assert len(game.command_log) == log_size + return [(rejection.code, dict(rejection.params)) for rejection in result.rejections] + + +class HoldPolicy: + """A test policy: every monster of the group holds, so the group never moves or attacks.""" + + def choose(self, session, group, stream): + return [MonsterAction(monster_id=monster_id, kind="hold") for monster_id in group.monster_ids] + + +def hold_monsters(game: GameSession) -> None: + """Keep every group in the encounter still, by registering `HoldPolicy` for it.""" + for group in game.encounter.groups: + game.action_policies[group.id] = HoldPolicy() + + +def release_monsters(game: GameSession) -> None: + """Give every group its default policy back.""" + game.action_policies.clear() + + +def advance(game: GameSession, member_id: str) -> int: + """The member's advance in the running battle. A member with no entry stands on the line.""" + return game.battle.advance_feet.get(member_id, 0) + + +def advances(game: GameSession) -> dict[str, int]: + """Every living member's advance, zero for a member on the line.""" + return {member.id: advance(game, member.id) for member in game.party.living_members()} + + +def attach(game: GameSession, definition: EffectDefinition, target_id: str) -> None: + effect, _ = game.ledger.attach( + definition, target_id, clock=game.clock, allocator=game.allocator, registry=game.registry() + ) + assert effect is not None + + +def moved(events) -> list[MemberMovedEvent]: + return [event for event in events if isinstance(event, MemberMovedEvent)] + + +def group_moves(events) -> list[GroupMovedEvent]: + return [event for event in events if isinstance(event, GroupMovedEvent)] + + +def trades(events) -> list[MemberWithdrewEvent]: + return [event for event in events if isinstance(event, MemberWithdrewEvent)] + + +def attacks_by(events, attacker_id: str) -> list[AttackRolledEvent]: + return [event for event in events if isinstance(event, AttackRolledEvent) and event.attacker_id == attacker_id] + + +def monster_attacks(game: GameSession, events) -> list[AttackRolledEvent]: + """The attack rolls the other side made: monsters' and NPC adventurers'.""" + party = {member.id for member in game.party.members} + return [event for event in events if isinstance(event, AttackRolledEvent) and event.attacker_id not in party] + + +def saves_by_members(game: GameSession, events) -> set[str]: + """The party members who rolled a saving throw in `events`.""" + party = {member.id for member in game.party.members} + return {event.target_id for event in events if isinstance(event, SavingThrowRolledEvent)} & party + + +def monsters_first(game: GameSession, events) -> bool: + initiative = next(event for event in events if isinstance(event, InitiativeRolledEvent)) + return initiative.order[0] not in {member.id for member in game.party.members} + + +def position(events, event) -> int: + """Where `event` sits in `events`, by identity, since two events can be equal.""" + return next(index for index, candidate in enumerate(events) if candidate is event) diff --git a/tests/test_back_stab.py b/tests/test_back_stab.py index 0dfa279..8ed9ddc 100644 --- a/tests/test_back_stab.py +++ b/tests/test_back_stab.py @@ -59,6 +59,7 @@ SpawnNpcParty, ) from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec +from osrlib.crawl.events import MemberMovedEvent from osrlib.crawl.party import Party from osrlib.crawl.session import GameSession from osrlib.data import load_classes, load_equipment, load_monsters @@ -538,6 +539,91 @@ def test_the_melee_checks_come_after_the_back_stab_checks(self): assert rejections == [("battle.declaration.not_in_front_rank", {"character": sable})] +# ---------------------------------------------------------------------- closing and back-stabbing in one round + + +HASTE = EffectDefinition(kind="haste", params={"attacks_multiplier": 2, "movement_multiplier": 2}) + + +def _index(events, event) -> int: + return next(index for index, candidate in enumerate(events) if candidate is event) + + +@pytest.mark.xfail(reason="chunk: move-and-attack", strict=True) +class TestCloseAndBackStab: + """In the free round, a thief can close on the group and back-stab it in the same round (issue #209). + + The session judges the back-stab at the distance the thief's `close` will reach, so the thief reaches a group up to + their encounter rate plus 5' away: 35' in leather under basic encumbrance, 45' in no armour, and double the rate + under *haste*. Beyond that the thief can't get within reach in the free round. + """ + + @pytest.mark.parametrize( + ("armour", "distance_feet", "rate"), + [(("leather",), 30, 30), (("leather",), 35, 30), ((), 45, 40)], + ids=["leather-at-30", "leather-at-35", "no-armour-at-45"], + ) + def test_a_thief_closes_and_back_stabs_in_the_free_round(self, armour, distance_feet, rate): + game = _battle(wield=("dagger", *armour), distance_feet=distance_feet) + sable = _sable(game) + + events = _play(game, _attack(game, back_stab=True, move="close")) + + (move,) = [event for event in events if isinstance(event, MemberMovedEvent)] + (stab,) = _rolls_by(events, sable) + assert (move.character_id, move.advance_feet, move.distance_feet) == (sable, distance_feet - 5, 5) + assert distance_feet - 5 <= rate, "precondition: within the thief's rate" + assert stab.back_stab is True + assert _index(events, move) < _index(events, stab) + + @pytest.mark.parametrize( + ("armour", "distance_feet"), [(("leather",), 40), ((), 50)], ids=["leather-at-40", "no-armour-at-50"] + ) + def test_a_group_beyond_the_thiefs_rate_plus_five_is_out_of_reach(self, armour, distance_feet): + """With a sword, the attack stays melee after the move, so the refusal reports the 10' still to cover.""" + game = _battle(wield=("sword", *armour), distance_feet=distance_feet) + + rejections = _refused(game, _attack(game, back_stab=True, move="close")) + + assert rejections == [("combat.attack.out_of_reach", {"distance_feet": 10})] + + def test_haste_doubles_the_thiefs_reach(self): + game = _battle(wield=("dagger", "leather"), distance_feet=65) + sable = _sable(game) + effect, _ = game.ledger.attach( + HASTE, sable, clock=game.clock, allocator=game.allocator, registry=game.registry() + ) + assert effect is not None + + events = _play(game, _attack(game, back_stab=True, move="close")) + + stabs = _rolls_by(events, sable) + assert len(stabs) == 2, "haste gave two swings" + assert all(stab.back_stab for stab in stabs) + + +class TestCloseAndBackStabRefusals: + """Guards: the back-stab checks keep their place before the move checks, before and after the chunk.""" + + def test_a_close_and_back_stab_outside_the_free_round_is_refused_as_aware(self): + game = _battle(surprised=False, distance_feet=30) + + assert _refused(game, _attack(game, back_stab=True, move="close")) == [ + (TARGET_AWARE, {"character": _sable(game)}) + ] + + @pytest.mark.parametrize("action", ["hold", "cast"]) + def test_a_back_stab_on_another_action_with_a_move_is_refused_as_not_an_attack(self, action): + game = _battle(distance_feet=30) + sable = _sable(game) + + rejections = _refused( + game, _declare(sable, action, back_stab=True, move="close", target_group_id=_group_id(game)) + ) + + assert rejections == [(NOT_ATTACK, {"character": sable})] + + # ---------------------------------------------------------------------- no back-stab is unchanged diff --git a/tests/test_battle.py b/tests/test_battle.py index 1b0dfb3..3fb6734 100644 --- a/tests/test_battle.py +++ b/tests/test_battle.py @@ -210,7 +210,8 @@ def test_declaring_an_item_nobody_carries_rejects_the_same_way(self): class TestRangeTrack: - def test_party_close_stops_at_five_feet(self): + @pytest.mark.xfail(reason="chunk: positions", strict=True) + def test_a_close_stops_the_member_five_feet_from_the_group(self): session = battle_session(distance=40) gid = group_id(session) declarations = hold_all( @@ -219,8 +220,11 @@ def test_party_close_stops_at_five_feet(self): ) result = session.execute(ResolveBattleRound(declarations=declarations)) assert result.accepted - # Party closes 40 (120÷3); the goblins close 20 more: floor at 5. - assert session.encounter.groups[0].distance_feet == 5 + # The fighter closes 40' (120 ÷ 3) and stops 5' from the goblins. Nobody else moves, and the goblins, + # within reach of the fighter, hold their ground. + assert session.battle.advance_feet == {"character-0001": 35} + assert battle_module.member_distance_feet(session, "character-0001", gid) == 5 + assert session.encounter.groups[0].distance_feet == 40 def test_monsters_close_at_encounter_rate(self): session = battle_session(distance=60) @@ -451,10 +455,9 @@ def test_the_enchanted_melee_ban_skips_warded_targets_until_they_engage(self): ) session.execute(ResolveBattleRound(declarations=hold_all(session, extra=(attack,)))) assert "character-0001" in session.battle.melee_engagements + front = [session.member("character-0001"), session.member("character-0002")] pool = battle_module._reachable_targets( - session, - session.monsters[session.encounter.groups[0].monster_ids[0]], - battle_module._party_target_pool(session), + session, session.monsters[session.encounter.groups[0].monster_ids[0]], front ) assert any(member.id == "character-0001" for member in pool) @@ -1158,34 +1161,28 @@ def test_three_targets_are_refused_whole_and_one_is_accepted(self): class TestAFormationMoveNeedsEveryDeclarer: """A `fighting_withdrawal` or a `retreat` that every declarer makes moves the whole formation (the adaptations register, battle section). One that not every declarer makes is the declaring member's - own: a front-rank member trades places with the back-rank member named in `replacement_id`, and - `test_single_withdrawal.py` locks that. A round in which some members make a defensive move with no - replacement named is refused whole, with `battle.declaration.no_replacement` for each front-rank - member and `battle.declaration.not_in_front_rank` for each member behind the front rank. The - refusal costs no draw, no round, no distance, and no event, as every other illegal declaration costs - nothing. `close` needs no agreement: the first `close` in marching order advances the formation for - everyone, so a lone `close` stays legal. + own: that member alone moves back on the range track, at half or full encounter rate, and needs no + replacement. `test_member_positions.py` and `test_single_withdrawal.py` lock that. A `close` is + always the declaring member's own, so a lone `close` moves that member and nobody else. """ - @pytest.mark.parametrize("move", ["fighting_withdrawal", "retreat"]) - def test_a_lone_defensive_move_with_no_replacement_is_refused_whole(self, move): - from osrlib.core.combat import COMBAT_STREAM - + @pytest.mark.xfail(reason="chunk: positions", strict=True) + @pytest.mark.parametrize(("move", "feet"), [("fighting_withdrawal", 20), ("retreat", 40)]) + def test_a_lone_defensive_move_moves_the_member_alone(self, move, feet): session = battle_session(distance=40) - combat_before = session.streams.get(COMBAT_STREAM).export_state() - round_before = session.battle.round distance_before = session.encounter.groups[0].distance_feet lone = BattleDeclaration(character_id="character-0001", action="move", move=move) result = session.execute(ResolveBattleRound(declarations=hold_all(session, extra=(lone,)))) - assert not result.accepted - assert result.events == () - assert [(rejection.code, dict(rejection.params)) for rejection in result.rejections] == [ - ("battle.declaration.no_replacement", {"character": "character-0001"}) - ] - assert session.streams.get(COMBAT_STREAM).export_state() == combat_before - assert session.battle.round == round_before - assert session.encounter.groups[0].distance_feet == distance_before + assert result.accepted, [rejection.code for rejection in result.rejections] + assert session.battle.advance_feet == {"character-0001": -feet} + moved = [event for event in result.events if event.code == "battle.member.moved"] + assert [(event.character_id, event.move) for event in moved] == [("character-0001", move)] + assert all( + event.distance_feet < distance_before for event in result.events if event.code == "battle.group.moved" + ) + assert session.mode.value == "battle" + @pytest.mark.xfail(reason="chunk: positions", strict=True) def test_a_majority_does_not_carry_the_minority(self): session = battle_session(distance=40) retreating = tuple( @@ -1193,25 +1190,18 @@ def test_a_majority_does_not_carry_the_minority(self): for member_id in ("character-0001", "character-0002", "character-0003") ) result = session.execute(ResolveBattleRound(declarations=hold_all(session, extra=retreating))) - assert not result.accepted - assert sorted((rejection.code, rejection.params.get("character")) for rejection in result.rejections) == [ - ("battle.declaration.no_replacement", "character-0001"), - ("battle.declaration.no_replacement", "character-0002"), - ("battle.declaration.not_in_front_rank", "character-0003"), - ] - assert session.battle is not None and session.battle.round == 0 + assert result.accepted, [rejection.code for rejection in result.rejections] + assert session.battle.advance_feet == {"character-0001": -40, "character-0002": -40, "character-0003": -40} + assert "battle.ended.fled" not in [event.code for event in result.events] - def test_two_members_withdrawing_alone_each_need_a_replacement(self): + @pytest.mark.xfail(reason="chunk: positions", strict=True) + def test_two_members_withdrawing_alone_each_move_alone(self): session = battle_session(distance=40) first = BattleDeclaration(character_id="character-0001", action="move", move="retreat") second = BattleDeclaration(character_id="character-0002", action="move", move="fighting_withdrawal") result = session.execute(ResolveBattleRound(declarations=hold_all(session, extra=(first, second)))) - assert not result.accepted - refused = sorted(((rejection.code, dict(rejection.params)) for rejection in result.rejections), key=repr) - assert refused == [ - ("battle.declaration.no_replacement", {"character": "character-0001"}), - ("battle.declaration.no_replacement", {"character": "character-0002"}), - ] + assert result.accepted, [rejection.code for rejection in result.rejections] + assert session.battle.advance_feet == {"character-0001": -40, "character-0002": -20} def test_every_declarer_withdrawing_moves_the_formation(self): session = battle_session(distance=40) @@ -1225,14 +1215,16 @@ def test_every_declarer_withdrawing_moves_the_formation(self): distances = [event.distance_feet for event in result.events if event.code == "battle.group.moved"] assert any(after > before for before, after in zip([40, *distances], distances, strict=False)) - def test_a_lone_close_stays_legal(self): + @pytest.mark.xfail(reason="chunk: positions", strict=True) + def test_a_lone_close_moves_only_that_member(self): session = battle_session(distance=40) close = BattleDeclaration( character_id="character-0001", action="move", move="close", target_group_id=group_id(session) ) result = session.execute(ResolveBattleRound(declarations=hold_all(session, extra=(close,)))) assert result.accepted, [rejection.code for rejection in result.rejections] - assert session.encounter.groups[0].distance_feet < 40 + assert session.battle.advance_feet == {"character-0001": 35} + assert session.encounter.groups[0].distance_feet == 40 def test_the_only_declarer_may_retreat_alone(self): from osrlib.core.combat import DamageSource, deal_damage diff --git a/tests/test_battle_player_view.py b/tests/test_battle_player_view.py index 2f499d2..453cce6 100644 --- a/tests/test_battle_player_view.py +++ b/tests/test_battle_player_view.py @@ -12,15 +12,17 @@ import pytest +import positions_fixtures as pf from crawl_fixtures import build_adventure, build_milestone_party from osrlib.core.combat import TargetingMode, effective_hd, select_targets -from osrlib.core.effects import Condition, has_condition +from osrlib.core.effects import ActiveCondition, Condition, has_condition from osrlib.core.events import Visibility from osrlib.core.items import MagicItemInstance from osrlib.core.rng import RngStreams +from osrlib.core.ruleset import Ruleset from osrlib.core.tables import ReactionResult from osrlib.crawl import encounter as encounter_module -from osrlib.crawl.battle import FLEE_EXIT_FEET, MELEE_RANGE_FEET +from osrlib.crawl.battle import FLEE_EXIT_FEET, MELEE_RANGE_FEET, group_front_rank, preview_battle_round from osrlib.crawl.commands import ( BattleDeclaration, CommandResult, @@ -32,6 +34,7 @@ ReorderParty, ResolveBattleRound, SessionMode, + SpawnMonsters, SpawnNpcParty, ) from osrlib.crawl.party import Party @@ -605,3 +608,348 @@ def test_the_fled_field_is_additive(self): view = _goblins_breaking("fled").view(Visibility.PLAYER) assert PlayerView.model_validate_json(view.model_dump_json()) == view assert _fled_flag(view.encounter.groups[0]) is True + + +# ---------------------------------------------------------------------- issue #209: positions in the player view + +VIEW = pytest.mark.xfail(reason="chunk: view-and-codes", strict=True) + +MISSILE_SPELLS = {"Wren": ("magic_missile", "magic_missile")} + + +def _ids(game: GameSession, *names: str) -> tuple[str, ...]: + return tuple(pf.named(game, name) for name in names) + + +def _held(**battle_args) -> GameSession: + game = pf.battle(**battle_args) + pf.hold_monsters(game) + return game + + +def _living_goblin(game: GameSession) -> str: + return next( + monster_id + for monster_id in game.encounter.groups[0].monster_ids + if not has_condition(game.combatant(monster_id), Condition.DEAD) + ) + + +def _missile(game: GameSession, caster_id: str) -> BattleDeclaration: + return pf.declare( + caster_id, "cast", spell_id="magic_missile", spell_mode="missiles", targets=(_living_goblin(game),) + ) + + +@VIEW +class TestThePositionsInThePlayerView: + """A battle screen draws the fight and offers each member's choices from the player view alone.""" + + def test_each_group_lists_every_living_members_distance(self): + game = _held(distance=30) + nim = pf.named(game, "Nim") + pf.play(game, pf.close(game, nim)) + + group = _encounter(game).groups[0] + + assert group.member_distance_feet == { + member.id: 30 - pf.advance(game, member.id) for member in game.party.members + } + assert group.member_distance_feet[nim] == 5 + + def test_each_group_lists_who_can_fight_it_hand_to_hand(self): + game = _held(distance=30) + nim = pf.named(game, "Nim") + assert _encounter(game).groups[0].front_rank == (), "nobody within reach yet" + + pf.play(game, pf.close(game, nim)) + + assert _encounter(game).groups[0].front_rank == (nim,) == group_front_rank(game, pf.group_id(game)) + + def test_the_encounter_front_rank_is_the_first_row_toward_the_nearest_group(self): + game = _held(distance=30) + hild, osric, nim = _ids(game, "Hild", "Osric", "Nim") + assert _encounter(game).front_rank == (hild, osric), "before anyone moves, the marching order's first rank" + + pf.play(game, pf.close(game, nim)) + + assert _encounter(game).front_rank == (nim,) + + def test_advance_feet_lists_every_living_member(self): + game = _held(distance=30) + nim = pf.named(game, "Nim") + assert _encounter(game).advance_feet == {member.id: 0 for member in game.party.members} + + pf.play(game, pf.close(game, nim)) + + assert _encounter(game).advance_feet == { + member.id: (25 if member.id == nim else 0) for member in game.party.members + } + + @pytest.mark.parametrize("name", ["Hild", "Osric", "Nim", "Wren", "Tam"]) + def test_the_group_front_rank_is_exactly_who_may_strike_it(self, name): + """Nim got there first, then Hild and Osric together: the corridor's two places go to Nim and Hild.""" + game = _held(distance=30) + hild, osric, nim = _ids(game, "Hild", "Osric", "Nim") + pf.play(game, pf.close(game, nim)) + pf.play(game, pf.close(game, hild), pf.close(game, osric)) + pf.play(game, pf.close(game, hild), pf.close(game, osric)) + member_id = pf.named(game, name) + front_rank = _encounter(game).groups[0].front_rank + assert front_rank == (hild, nim), "precondition: the view shows the front rank" + offered = member_id in front_rank + + result = pf.round_(game, pf.strike(game, member_id)) + + assert result.accepted is offered, [(rejection.code, dict(rejection.params)) for rejection in result.rejections] + assert offered is (name in ("Hild", "Nim")) + + def test_the_group_front_rank_and_the_views_are_always_the_same_value(self): + """Two groups and members at many distances, compared after every round.""" + game = pf.battle(distance=30) + assert game.execute(SpawnMonsters(template_id="kobold", count_fixed=2, distance_feet=60)).accepted + pf.hold_monsters(game) + hild, osric, nim, wren, tam = _ids(game, "Hild", "Osric", "Nim", "Wren", "Tam") + goblins, kobolds = pf.group_id(game, 0), pf.group_id(game, 1) + rounds = [ + (pf.close(game, nim),), + (pf.close(game, hild), pf.close(game, osric)), + (pf.close(game, hild), pf.close(game, osric)), + (pf.close(game, wren, group=kobolds),), + (pf.declare(tam, "move", move="fighting_withdrawal"), pf.declare(nim, "move", move="retreat")), + ] + seen = [] + for declared in rounds: + pf.play(game, *declared) + groups = {group.id: group.front_rank for group in _encounter(game).groups} + for group_id in (goblins, kobolds): + assert groups[group_id] == group_front_rank(game, group_id) + seen.append(groups[goblins]) + + assert seen[2] == (hild, nim), "Nim got there first and is listed second, in marching order" + assert seen[4] == (hild, osric), "with Nim gone, Osric steps into the row" + + def test_the_view_shows_the_party_line_after_it_moves_back(self): + game = _held(distance=40) + hild = pf.named(game, "Hild") + pf.play(game, *(pf.declare(m.id, "move", move="retreat") for m in game.party.members if m.id != hild)) + pf.play(game, pf.declare(hild, "move", move="fighting_withdrawal")) + + encounter = _encounter(game) + + assert encounter.advance_feet == pf.advances(game) + assert encounter.advance_feet[hild] == 0 + assert encounter.groups[0].distance_feet == 50 + assert encounter.groups[0].member_distance_feet[hild] == 50 + + def test_formation_width_is_the_space_the_party_stands_in(self): + assert _encounter(pf.battle()).formation_width == 2 + assert _encounter(pf.battle(engage=False)).formation_width == 2 + assert _encounter(pf.battle(adventure=pf.hall())).formation_width == 4 + assert _encounter(pf.battle(ruleset=Ruleset(formation_width_limit=False))).formation_width is None + + def test_the_free_round_flag_marks_the_round_a_back_stab_is_legal(self): + surprised = pf.battle(surprised=True) + assert _encounter(surprised).party_free_round is True + pf.play(surprised) + assert _encounter(surprised).party_free_round is False + assert _encounter(pf.battle(surprised=False)).party_free_round is False + + def test_each_member_reports_how_far_they_can_move_in_a_round(self): + game = pf.battle() + nim = pf.named(game, "Nim") + assert {member.name: member.encounter_rate_feet for member in game.view(Visibility.PLAYER).party} == pf.RATE + + pf.attach(game, pf.HASTE, nim) + + rates = {member.id: member.encounter_rate_feet for member in game.view(Visibility.PLAYER).party} + assert rates[nim] == 60 + + +@VIEW +class TestLightAndSightInThePlayerView: + """Hild carries the torch, 30' of light. Aelin the elf sees 60' by infravision. Nobody else sees in the dark.""" + + def test_a_group_in_the_torchlight_is_lit_and_seen_by_everyone(self): + game = _held(distance=20, light=True) + + group = _encounter(game).groups[0] + + assert group.lit is True + assert group.seen_by == tuple(member.id for member in game.party.members) + + def test_a_group_beyond_the_torch_is_seen_only_by_infravision(self): + game = _held(distance=50, light=True) + + group = _encounter(game).groups[0] + + assert group.lit is False + assert group.seen_by == (pf.named(game, "Aelin"),) + + def test_the_torch_reaches_as_far_as_its_bearer_stands_forward(self): + game = _held(distance=50, light=True) + hild = pf.named(game, "Hild") + pf.play(game, pf.close(game, hild)) + + group = _encounter(game).groups[0] + + assert group.member_distance_feet[hild] == 30 + assert group.lit is True + assert group.seen_by == tuple(member.id for member in game.party.members) + + def test_a_group_beyond_infravision_is_seen_by_nobody(self): + game = _held(distance=70, light=True) + + group = _encounter(game).groups[0] + + assert (group.lit, group.seen_by) == (False, ()) + + def test_a_blind_member_sees_nothing(self): + game = _held(distance=50, light=True) + aelin = pf.named(game, "Aelin") + game.member(aelin).conditions = (ActiveCondition(condition=Condition.BLIND, effect_id="effect-9999"),) + + group = _encounter(game).groups[0] + + assert (group.lit, group.seen_by) == (False, ()) + + +class TestTheBattleFieldsOutsideABattle: + """Guards: the new fields default to empty outside a battle, and an older client's view still parses.""" + + def test_an_encounter_before_the_battle_has_no_positions(self): + game = pf.battle(engage=False) + assert game.mode is SessionMode.ENCOUNTER, "precondition: no battle yet" + + view = _encounter(game) + group = view.groups[0] + + assert (view.advance_feet, view.party_free_round) == ({}, False) + assert (group.member_distance_feet, group.front_rank, group.lit, group.seen_by) == ({}, (), None, ()) + + def test_the_new_fields_are_additive(self): + older_group = EncounterGroupView.model_validate( + {"id": "group-0001", "label": "goblin", "count": 2, "distance_feet": 30, "visible_conditions": []} + ) + assert (older_group.member_distance_feet, older_group.front_rank) == ({}, ()) + assert (older_group.lit, older_group.seen_by) == (None, ()) + older_view = EncounterView.model_validate({"groups": [], "stance": None, "in_battle": False}) + assert (older_view.advance_feet, older_view.formation_width, older_view.party_free_round) == ({}, None, False) + view = pf.battle().view(Visibility.PLAYER) + assert PlayerView.model_validate_json(view.model_dump_json()) == view + + +# ---------------------------------------------------------------------- the walk-through + + +@VIEW +def test_the_walk_through_battle_from_the_player_view(): + """The design's section 8, played as a game's battle screen plays it, from the player view and the dry run. + + Four goblins appear 30' down a 10' corridor, and the party surprises them. In the free round Nim the thief closes + and back-stabs, Aelin the elf shoots from where she stands, Wren the magic-user casts *magic missile* from the back, + and Hild and Osric, the fighters in plate, close in without reaching. In round 2 Hild closes and attacks beside Nim, + the dry run turns Osric's close and attack into a close, and Tam closes up. From then on the front rank fights, + Aelin keeps shooting, and no goblin ever reaches Aelin, Wren, Osric, or Tam. When the battle ends, the party is back + on its cell as one body. + """ + battle_args = {"distance": 30, "count": 4, "surprised": True, "spells": MISSILE_SPELLS} + for seed in pf.seeds(**battle_args): + game = pf.battle(first_seed=seed, **battle_args) + hild, osric, nim, aelin, wren, tam = _ids(game, "Hild", "Osric", "Nim", "Aelin", "Wren", "Tam") + view = game.view(Visibility.PLAYER) + group = view.encounter.groups[0] + rates = {member.id: member.encounter_rate_feet for member in view.party} + + # Round 1, the free round: the screen offers "close in and attack" to whoever can reach. + assert view.encounter.party_free_round is True + assert view.encounter.formation_width == 2 + reach = { + member_id + for member_id in view.encounter.declarers + if group.member_distance_feet[member_id] <= rates[member_id] + 5 + } + assert nim in reach and hild not in reach and osric not in reach + hild_swings = pf.strike(game, hild, move="close", weapon_id="sword") + assert [ + (rejection.code, dict(rejection.params)) + for rejection in preview_battle_round(game, [hild_swings]).rejections[hild] + ] == [("combat.attack.out_of_reach", {"distance_feet": 10})] + first = ( + pf.strike(game, nim, move="close", back_stab=True), + pf.close(game, hild), + pf.close(game, osric), + pf.strike(game, aelin, weapon_id="short_bow"), + _missile(game, wren), + pf.declare(tam), + ) + assert all(found == () for found in preview_battle_round(game, first).rejections.values()) + + events = pf.play(game, *first) + + assert pf.advances(game) == {hild: 20, osric: 20, nim: 25, aelin: 0, wren: 0, tam: 0} + assert [(event.character_id, event.distance_feet) for event in pf.moved(events)] == [ + (hild, 10), + (osric, 10), + (nim, 5), + ] + (shot,) = pf.attacks_by(events, aelin) + (stab,) = pf.attacks_by(events, nim) + cast = next( + event + for event in events + if getattr(event, "caster_id", None) == wren and event.code.startswith("magic.cast.") + ) + assert stab.back_stab is True + assert pf.position(events, shot) < pf.position(events, cast) < pf.position(events, stab) + assert pf.monster_attacks(game, events) == [] + assert _encounter(game).party_free_round is False + + # Round 2: Hild takes the last place in the front rank, and the dry run turns Osric's swing into a close. + group = _encounter(game).groups[0] + assert group.front_rank == (nim,) + planned = [pf.strike(game, nim), pf.strike(game, hild, move="close", weapon_id="sword")] + osric_swings = pf.strike(game, osric, move="close", weapon_id="sword") + assert [ + rejection.code for rejection in preview_battle_round(game, [*planned, osric_swings]).rejections[osric] + ] == ["battle.declaration.not_in_front_rank"] + planned += [ + pf.close(game, osric), + pf.strike(game, aelin, weapon_id="short_bow"), + _missile(game, wren), + pf.close(game, tam), + ] + + events = pf.play(game, *planned) + if game.battle is None or game.encounter.groups[0].fleeing: + continue # The walk-through supposes the goblins stand and fight. + + assert pf.advances(game) == {hild: 25, osric: 25, nim: 25, aelin: 0, wren: 0, tam: 20} + assert pf.group_moves(events) == [], "within reach of Nim, the goblins hold their ground" + struck = {attack.defender_id for attack in pf.monster_attacks(game, events)} + assert struck <= ({nim} if pf.monsters_first(game, events) else {nim, hild}) + assert _encounter(game).groups[0].front_rank == (hild, nim) + + # Round 3 on: the front rank fights, Aelin shoots while the goblins are in range, and the rest hold. + for _ in range(40): + if game.mode is not SessionMode.BATTLE: + break + group = _encounter(game).groups[0] + declared = [pf.strike(game, member_id) for member_id in group.front_rank] + if not group.fled and group.member_distance_feet[aelin] <= 150: + declared.append(pf.strike(game, aelin, weapon_id="short_bow")) + fleeing = game.encounter.groups[0].fleeing + + events = pf.play(game, *declared) + + assert {attack.defender_id for attack in pf.monster_attacks(game, events)} <= {nim, hild} + if game.battle is not None: + assert (pf.advance(game, aelin), pf.advance(game, wren)) == (0, 0) + if not fleeing and not game.encounter.groups[0].fleeing: + assert game.encounter.groups[0].distance_feet == 30 + + # The end: the battle is over, and the positions went with it. + assert game.mode is SessionMode.EXPLORING + assert game.battle is None + assert game.view(Visibility.PLAYER).encounter is None + return diff --git a/tests/test_docs_example_output.py b/tests/test_docs_example_output.py index 48e821a..b15cf04 100644 --- a/tests/test_docs_example_output.py +++ b/tests/test_docs_example_output.py @@ -82,7 +82,10 @@ def check_block(source: str, filename: str) -> list[str]: # Blocks the gate fails on today. The lead removes an entry when its chunk merges; a block's id # carries its line range, so editing the block retires the mark by itself. -_EXPECTED_TO_FAIL: dict[str, str] = {} +_EXPECTED_TO_FAIL: dict[str, str] = { + # The `ResolveBattleRound` example plans each round with `preview_battle_round`, which is a stub until its chunk. + "src/osrlib/crawl/commands.py:3024-3087": "chunk: move-and-attack", +} def _cases() -> list: diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py index b908c70..9c90c4a 100644 --- a/tests/test_docs_examples.py +++ b/tests/test_docs_examples.py @@ -37,7 +37,10 @@ # Blocks that fail today because their chunk isn't built. The lead removes an entry when its chunk merges. -_EXPECTED_TO_FAIL: dict[str, str] = {} +_EXPECTED_TO_FAIL: dict[str, str] = { + # The `ResolveBattleRound` example plans each round with `preview_battle_round`, which is a stub until its chunk. + "src/osrlib/crawl/commands.py:3024-3087": "chunk: move-and-attack", +} def _cases() -> list: diff --git a/tests/test_docs_gates.py b/tests/test_docs_gates.py index 78f5650..8ab9145 100644 --- a/tests/test_docs_gates.py +++ b/tests/test_docs_gates.py @@ -158,6 +158,9 @@ 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) + # `ResolveBattleRound` documents `battle.declaration.cast_cannot_move` and `battle.declaration.move_not_combinable`, + # which its handler will emit, and the stub emits neither yet. + @pytest.mark.xfail(reason="chunk: move-and-attack", strict=True) def test_documented_rejection_codes_exist_in_the_source(self, vocabulary): wrong = [] for cls in self._commands(): diff --git a/tests/test_events_kernel.py b/tests/test_events_kernel.py index aa5eecd..5f45a84 100644 --- a/tests/test_events_kernel.py +++ b/tests/test_events_kernel.py @@ -118,6 +118,9 @@ def sample_event(event_class, code): "BattleRoundEvent": dict(round=2), "SpellDeclaredEvent": dict(caster_id="pc-1", spell_id="sleep"), "GroupMovedEvent": dict(group_id="group-1", distance_feet=30), + "MemberMovedEvent": dict( + character_id="pc-1", move="close", advance_feet=25, group_id="group-1", distance_feet=5 + ), "MemberWithdrewEvent": dict(character_id="pc-1", replacement_id="pc-3"), "MonsterFledEvent": dict(group_id="group-1"), "MonstersLeftBehindEvent": dict(group_id="group-2", source_group_id="group-1", count=2), diff --git a/tests/test_example_crawler.py b/tests/test_example_crawler.py index 6b5f1b7..3e97998 100644 --- a/tests/test_example_crawler.py +++ b/tests/test_example_crawler.py @@ -15,10 +15,17 @@ import sys from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parent.parent SCRIPT = REPO_ROOT / "examples" / "tui_crawler" / "scripts" / "milestone.txt" MILESTONE_SEED = 21 +# The crawler closes while a group is beyond 5' and then attacks with the front rank. Under the rules for member +# positions on the range track (issue #209), a group's distance no longer shrinks when members close, so the crawler +# and the milestone golden change together once those chunks are built. +pytestmark = pytest.mark.xfail(reason="chunk: goldens", strict=False) + def run_crawler(*arguments: str) -> subprocess.CompletedProcess: return subprocess.run( diff --git a/tests/test_fastapi_crawler.py b/tests/test_fastapi_crawler.py index edf30db..1cbca02 100644 --- a/tests/test_fastapi_crawler.py +++ b/tests/test_fastapi_crawler.py @@ -93,11 +93,12 @@ def first_weapon_id(member_view: dict) -> str | None: def fight(client, session_id: str, *, capture: list | None = None) -> list[dict]: - """The TUI's auto-declared battle loop, driven purely from the wire. + """An auto-declared battle loop, driven purely from the wire. - Every input a declaration needs — member ids, the group id, the distance, the - wielded weapon — comes from the player view: the example's proof that the - projection carries a fighting client's whole vocabulary. + Every input a declaration needs comes from the player view: member ids, the group id, and the wielded weapon. + That's the example's proof that the projection carries a fighting client's whole vocabulary. Each round, the + first two members attack. When the server refuses that round, because they can't reach the group yet, everyone + closes instead. A refused round costs nothing, so the refusal is how the client learns who can reach. """ results = [run(client, session_id, {"command_type": "engage_battle"}, capture=capture)] for _ in range(40): @@ -109,19 +110,10 @@ def fight(client, session_id: str, *, capture: list | None = None) -> list[dict] if group is None: break living = [member for member in view["party"] if "dead" not in member["conditions"]] - declarations = [] + attacks = [] for index, member in enumerate(living): - if group["distance_feet"] > 5: - declarations.append( - { - "character_id": member["id"], - "action": "move", - "move": "close", - "target_group_id": group["id"], - } - ) - elif index < 2: - declarations.append( + if index < 2: + attacks.append( { "character_id": member["id"], "action": "attack", @@ -130,15 +122,19 @@ def fight(client, session_id: str, *, capture: list | None = None) -> list[dict] } ) else: - declarations.append({"character_id": member["id"], "action": "hold"}) - results.append( - run( - client, - session_id, - {"command_type": "resolve_battle_round", "declarations": declarations}, - capture=capture, - ) + attacks.append({"character_id": member["id"], "action": "hold"}) + result = run( + client, session_id, {"command_type": "resolve_battle_round", "declarations": attacks}, capture=capture ) + if not result["accepted"]: + closes = [ + {"character_id": member["id"], "action": "move", "move": "close", "target_group_id": group["id"]} + for member in living + ] + result = run( + client, session_id, {"command_type": "resolve_battle_round", "declarations": closes}, capture=capture + ) + results.append(result) return results diff --git a/tests/test_member_positions.py b/tests/test_member_positions.py new file mode 100644 index 0000000..c1c418f --- /dev/null +++ b/tests/test_member_positions.py @@ -0,0 +1,731 @@ +"""Acceptance tests for each party member's own place on the range track (issue #209, chunk: positions). + +The range track is how a battle measures distance. Each monster group keeps a distance from the party line, where the +party stood when the encounter opened. Each member now has an advance, in feet forward of that line, in +`BattleState.advance_feet`, and a member's distance to a group is the group's distance minus the member's advance. +The contract these tests check: + +- A `close` moves only the member who declares it, at that member's encounter rate, doubled by *haste* on them. It + stops 5' from the group it names, and short of 5' from every other group still in the fight. A member already within + 5' doesn't move. Each move a member makes alone reports a `MemberMovedEvent`, and moves nobody else. +- A `fighting_withdrawal` or a `retreat` that not every declarer makes moves that member alone, back at half or full + rate, below the line if need be. Any member can make one, with no replacement. A retreat alone doesn't end the battle. +- `replacement_id` is a trade of marching-order places, reported by a `MemberWithdrewEvent` right after the move. +- A formation move, every declarer making the same defensive move, moves the groups and no member, as before. A + formation retreat's pursuit starts at the smallest distance between a living member and a group that can follow. +- `BattleState.arrived_round` records the round each member reached their advance. The ranks toward a group are the + living members sorted by distance and cut into rows of the formation width, never mixing distances, with the earlier + arrival in front and marching order settling a tie. `group_front_rank` is the front row's members within 5'. +- Melee declarations in a round with no moves are judged against those ranks and distances. +- Positions end with the battle, are saved with the session, and replay from the command log. + +Most tests keep the monsters still with a hold policy, so they check the party's movement alone. Each test asserts its +own preconditions. The classes marked `chunk: positions` fail until that chunk is built. The unmarked tests are guards +that pass before and after it. +""" + +import sys +from pathlib import Path + +import pytest +from pydantic import TypeAdapter + +from osrlib.core.effects import ActiveCondition, Condition, EffectDefinition +from osrlib.core.events import Visibility +from osrlib.core.ruleset import Ruleset +from osrlib.crawl import battle as battle_module +from osrlib.crawl.battle import BattleState, group_front_rank, member_distance_feet +from osrlib.crawl.commands import AnyCommand, ResolveBattleRound, SessionMode, SpawnMonsters +from osrlib.crawl.events import MemberMovedEvent, parse_any_event +from osrlib.messages import format_message +from osrlib.persistence import load_game, replay_game, save_game, session_state +from positions_fixtures import ( + HASTE, + RATE, + WALK, + advance, + advances, + attach, + battle, + character, + close, + declare, + group_id, + group_moves, + hold_monsters, + moved, + named, + play, + refused, + release_monsters, + round_, + strike, + trades, +) + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from tools.docs.rejection_scan import scan_rejection_codes # noqa: E402 + +POSITIONS = pytest.mark.xfail(reason="chunk: positions", strict=True) + + +def _held(**battle_args): + """A battle whose monsters hold still, so only the party moves.""" + game = battle(**battle_args) + hold_monsters(game) + return game + + +def _ids(game, *names: str) -> tuple[str, ...]: + return tuple(named(game, name) for name in names) + + +def _withdraw(member_id: str, move: str = "fighting_withdrawal", replacement_id: str | None = None): + return declare(member_id, "move", move=move, replacement_id=replacement_id) + + +def _order(game) -> list[str]: + return [member.name for member in game.party.members] + + +# ---------------------------------------------------------------------- the fields + + +class TestTheFields: + """The new fields, event, and names. These pass before and after the chunk, because the stub ships them.""" + + def test_a_battle_opens_with_everyone_on_the_line(self): + game = battle() + assert game.battle.advance_feet == {} + assert game.battle.arrived_round == {} + + def test_a_battle_state_saved_before_the_fields_existed_loads_with_everyone_on_the_line(self): + state = BattleState.model_validate({"started_round": 3, "round": 2}) + assert state.advance_feet == {} + assert state.arrived_round == {} + + def test_the_fields_round_trip_through_json(self): + state = BattleState(started_round=0, advance_feet={"character-0003": 25}, arrived_round={"character-0003": 1}) + assert BattleState.model_validate(state.model_dump(mode="json")) == state + + @pytest.mark.parametrize( + ("move", "verb"), [("close", "closes"), ("fighting_withdrawal", "falls back"), ("retreat", "retreats")] + ) + def test_the_member_moved_event_round_trips_and_formats(self, move, verb): + event = MemberMovedEvent( + character_id="character-0003", move=move, advance_feet=25, group_id="group-0001", distance_feet=5 + ) + assert event.code == "battle.member.moved" + assert event.visibility is Visibility.PLAYER + assert parse_any_event(event.model_dump(mode="json")) == event + line = format_message(event) + assert "character-0003" in line and verb in line and "5'" in line and "group-0001" in line + + def test_an_attack_that_closes_round_trips_through_its_json(self): + command = ResolveBattleRound( + declarations=( + declare("character-0003", "attack", move="close", target_group_id="group-0001", back_stab=True), + ) + ) + restored = TypeAdapter(AnyCommand).validate_python(command.model_dump(mode="json")) + assert restored == command + + def test_the_new_names_are_public(self): + for name in ("member_distance_feet", "group_front_rank", "preview_battle_round", "BattleRoundPreview"): + assert name in battle_module.__all__ + + +# ---------------------------------------------------------------------- a close is the member's own + + +@POSITIONS +class TestACloseMovesOnlyTheMember: + def test_a_close_moves_only_the_declarer_at_their_own_rate(self): + game = _held(distance=40) + hild = named(game, "Hild") + + events = play(game, close(game, hild)) + + assert advances(game) == {member_id: (20 if member_id == hild else 0) for member_id in advances(game)} + assert game.encounter.groups[0].distance_feet == 40, "the group stays where it was" + assert group_moves(events) == [] + (event,) = moved(events) + assert (event.character_id, event.move, event.advance_feet) == (hild, "close", 20) + assert (event.group_id, event.distance_feet) == (group_id(game), 20) + + def test_each_member_closes_at_their_own_rate(self): + game = _held(distance=60) + members = [member.id for member in game.party.members] + + events = play(game, *(close(game, member_id) for member_id in members)) + + expected = {member.id: RATE[member.name] for member in game.party.members} + assert advances(game) == expected + assert [event.character_id for event in moved(events)] == members, "in marching order" + assert [event.distance_feet for event in moved(events)] == [60 - expected[member_id] for member_id in members] + assert game.encounter.groups[0].distance_feet == 60 + + def test_a_close_stops_five_feet_from_the_group(self): + game = _held(distance=30) + wren = named(game, "Wren") + + (event,) = moved(play(game, close(game, wren))) + + assert advance(game, wren) == 25 + assert (event.advance_feet, event.distance_feet) == (25, 5) + + def test_a_member_already_within_five_feet_neither_moves_nor_reports(self): + game = _held(distance=5) + hild = named(game, "Hild") + + events = play(game, close(game, hild)) + + assert moved(events) == [] + assert group_moves(events) == [] + assert advance(game, hild) == 0 + assert game.battle.arrived_round == {} + + def test_haste_doubles_the_members_own_close(self): + game = _held(distance=80) + nim, hild = _ids(game, "Nim", "Hild") + attach(game, HASTE, nim) + + play(game, close(game, nim), close(game, hild)) + + assert advance(game, nim) == 60 + assert advance(game, hild) == 20, "haste on one member speeds nobody else" + + def test_a_close_stops_short_of_every_other_group(self): + game = battle(distance=60) + assert game.execute(SpawnMonsters(template_id="kobold", count_fixed=2, distance_feet=30)).accepted + assert [group.distance_feet for group in game.encounter.groups] == [60, 30], "precondition: two groups" + hold_monsters(game) + goblins, kobolds = group_id(game, 0), group_id(game, 1) + wren = named(game, "Wren") + + (event,) = moved(play(game, close(game, wren, group=goblins))) + + assert advance(game, wren) == 25 + assert (event.group_id, event.distance_feet) == (goblins, 35) + assert member_distance_feet(game, wren, kobolds) == 5 + + def test_the_round_a_member_arrives_is_recorded(self): + game = _held(distance=60) + nim, hild = _ids(game, "Nim", "Hild") + + play(game, close(game, nim)) + play(game, close(game, hild)) + + assert game.battle.arrived_round == {nim: 1, hild: 2} + + def test_a_member_who_cannot_move_further_keeps_their_arrival_round(self): + game = _held(distance=30) + nim = named(game, "Nim") + play(game, close(game, nim)) + assert advance(game, nim) == 25, "precondition: Nim stands 5' from the goblins" + + events = play(game, close(game, nim)) + + assert moved(events) == [] + assert game.battle.arrived_round == {nim: 1} + + def test_the_distance_helper_measures_each_member(self): + game = _held(distance=50) + nim, wren = _ids(game, "Nim", "Wren") + play(game, close(game, nim), close(game, wren)) + + distances = {member.id: member_distance_feet(game, member.id, group_id(game)) for member in game.party.members} + + assert distances == {member_id: 50 - feet for member_id, feet in advances(game).items()} + assert (distances[nim], distances[wren]) == (20, 10) + + def test_the_helpers_refuse_a_name_they_do_not_know(self): + game = _held(distance=30) + hild = named(game, "Hild") + with pytest.raises(ValueError): + member_distance_feet(game, "character-9999", group_id(game)) + with pytest.raises(ValueError): + member_distance_feet(game, hild, "group-9999") + with pytest.raises(ValueError): + group_front_rank(game, "group-9999") + + def test_the_helpers_refuse_a_session_with_no_battle(self): + game = battle(engage=False) + assert game.mode is SessionMode.ENCOUNTER, "precondition: no battle yet" + with pytest.raises(ValueError): + member_distance_feet(game, named(game, "Hild"), group_id(game)) + with pytest.raises(ValueError): + group_front_rank(game, group_id(game)) + + +# ---------------------------------------------------------------------- falling back alone + + +@POSITIONS +class TestFallingBackAlone: + def test_a_fighting_withdrawal_alone_moves_the_member_back_at_half_rate(self): + game = _held(distance=40) + wren = named(game, "Wren") + + events = play(game, _withdraw(wren)) + + assert advance(game, wren) == -20 + (event,) = moved(events) + assert (event.character_id, event.move, event.advance_feet) == (wren, "fighting_withdrawal", -20) + assert (event.group_id, event.distance_feet) == (group_id(game), 60) + assert trades(events) == [] + assert game.encounter.groups[0].distance_feet == 40, "the party didn't back away as one body" + + def test_a_retreat_alone_moves_the_member_back_at_full_rate_and_the_battle_goes_on(self): + game = _held(distance=40) + wren = named(game, "Wren") + + events = play(game, _withdraw(wren, "retreat")) + + assert advance(game, wren) == -40 + assert [event.move for event in moved(events)] == ["retreat"] + assert "battle.ended.fled" not in [event.code for event in events] + assert game.mode is SessionMode.BATTLE + assert game.encounter.pursuit is None + + def test_any_member_may_fall_back_alone(self): + game = _held(distance=5) + tam = named(game, "Tam") + + play(game, _withdraw(tam)) + + assert advance(game, tam) == -10 + + def test_haste_doubles_a_withdrawal(self): + game = _held(distance=5) + hild = named(game, "Hild") + attach(game, HASTE, hild) + + play(game, _withdraw(hild)) + + assert advance(game, hild) == -20 + + def test_a_majority_falling_back_does_not_carry_the_rest(self): + game = _held(distance=40) + tam = named(game, "Tam") + retreating = [member for member in game.party.members if member.id != tam] + + events = play(game, *(_withdraw(member.id, "retreat") for member in retreating)) + + assert advances(game) == {**{member.id: -RATE[member.name] for member in retreating}, tam: 0} + assert group_moves(events) == [] + assert game.mode is SessionMode.BATTLE + + def test_the_moves_back_come_before_the_closes(self): + game = _held(distance=40) + hild, tam = _ids(game, "Hild", "Tam") + + events = play(game, close(game, hild), _withdraw(tam)) + + assert [(event.character_id, event.move) for event in moved(events)] == [ + (tam, "fighting_withdrawal"), + (hild, "close"), + ] + + def test_a_move_back_reports_the_nearest_group(self): + game = battle(distance=60) + assert game.execute(SpawnMonsters(template_id="kobold", count_fixed=2, distance_feet=30)).accepted + hold_monsters(game) + wren = named(game, "Wren") + + (event,) = moved(play(game, _withdraw(wren))) + + assert (event.group_id, event.distance_feet) == (group_id(game, 1), 50) + + +# ---------------------------------------------------------------------- the marching-order trade + + +@POSITIONS +class TestTheTrade: + def test_the_named_member_trades_places_in_the_marching_order(self): + game = _held(distance=5) + hild, aelin, osric = _ids(game, "Hild", "Aelin", "Osric") + + events = play(game, _withdraw(hild, replacement_id=aelin)) + + (move,) = moved(events) + (trade,) = trades(events) + assert (move.character_id, move.advance_feet, move.distance_feet) == (hild, -10, 15) + assert (trade.code, trade.character_id, trade.replacement_id) == ("battle.member.withdrew", hild, aelin) + assert events.index(trade) == events.index(move) + 1, "the trade comes right after the move" + assert _order(game) == ["Aelin", "Osric", "Nim", "Hild", "Wren", "Tam"] + assert advance(game, aelin) == 0, "the trade moves nobody" + assert group_front_rank(game, group_id(game)) == (aelin, osric) + + def test_without_a_trade_the_next_nearest_members_fill_the_front(self): + game = _held(distance=5) + hild, osric, nim = _ids(game, "Hild", "Osric", "Nim") + + events = play(game, _withdraw(hild)) + + assert trades(events) == [] + assert _order(game) == [name for name, _ in WALK] + assert group_front_rank(game, group_id(game)) == (osric, nim) + + def test_a_retreat_alone_trades_with_its_own_code(self): + game = _held(distance=5) + hild, tam = _ids(game, "Hild", "Tam") + + (trade,) = trades(play(game, _withdraw(hild, "retreat", replacement_id=tam))) + + assert trade.code == "battle.member.retreated" + assert advance(game, hild) == -20 + + def test_a_member_in_front_may_be_named(self): + game = _held(distance=5) + hild, osric = _ids(game, "Hild", "Osric") + + play(game, _withdraw(hild, replacement_id=osric)) + + assert _order(game)[:2] == ["Osric", "Hild"] + + def test_a_member_who_casts_may_be_named(self): + game = _held(distance=5, spells={"Wren": ("magic_missile",)}) + hild, wren = _ids(game, "Hild", "Wren") + goblin = game.encounter.groups[0].monster_ids[0] + missile = declare(wren, "cast", spell_id="magic_missile", spell_mode="missiles", targets=(goblin,)) + + play(game, _withdraw(hild, replacement_id=wren), missile) + + assert _order(game)[0] == "Wren" + + def test_a_member_who_cannot_move_may_be_named(self): + game = _held(distance=5) + hild, aelin = _ids(game, "Hild", "Aelin") + game.member(aelin).conditions = (ActiveCondition(condition=Condition.ENTANGLED, effect_id="effect-9999"),) + assert aelin in game.view(Visibility.PLAYER).encounter.immobile, "precondition: Aelin is held fast" + + play(game, _withdraw(hild, replacement_id=aelin)) + + assert _order(game)[0] == "Aelin" + + def test_a_member_who_falls_back_too_cannot_be_named(self): + game = _held(distance=5) + hild, osric = _ids(game, "Hild", "Osric") + + rejections = refused(game, _withdraw(hild, replacement_id=osric), _withdraw(osric)) + + assert rejections == [ + ("battle.declaration.replacement_unavailable", {"character": hild, "replacement": osric}), + ] + + def test_without_the_width_limit_a_trade_is_still_a_trade(self): + game = _held(distance=5, ruleset=Ruleset(formation_width_limit=False)) + hild, wren = _ids(game, "Hild", "Wren") + + play(game, _withdraw(hild, replacement_id=wren)) + + assert _order(game)[0] == "Wren" + assert advance(game, hild) == -10 + + +class TestTheTradeRefusals: + """Guards: a trade refused under the old swap rules is refused under the trade rules too, and costs nothing.""" + + @pytest.mark.parametrize("who", ["themselves", "a stranger", "a sleeper"]) + def test_the_named_member_has_to_be_another_declarer(self, who): + game = _held(distance=5) + hild, tam = _ids(game, "Hild", "Tam") + if who == "a sleeper": + game.member(tam).conditions = (ActiveCondition(condition=Condition.ASLEEP, effect_id="effect-9999"),) + name = {"themselves": hild, "a stranger": "character-9999", "a sleeper": tam}[who] + + rejections = refused(game, _withdraw(hild, replacement_id=name)) + + assert rejections == [ + ("battle.declaration.replacement_unavailable", {"character": hild, "replacement": name}), + ] + + def test_two_members_cannot_name_one_member(self): + game = _held(distance=5) + hild, osric, tam = _ids(game, "Hild", "Osric", "Tam") + + rejections = refused(game, _withdraw(hild, replacement_id=tam), _withdraw(osric, replacement_id=tam)) + + assert rejections == [ + ("battle.declaration.replacement_unavailable", {"character": osric, "replacement": tam}), + ] + + +# ---------------------------------------------------------------------- the ranks toward a group + + +@POSITIONS +class TestTheRanksTowardAGroup: + def test_the_front_rank_is_the_members_within_five_feet(self): + game = _held(distance=30) + nim = named(game, "Nim") + assert group_front_rank(game, group_id(game)) == (), "precondition: nobody within reach" + + play(game, close(game, nim)) + + assert group_front_rank(game, group_id(game)) == (nim,) + + def test_a_front_rank_still_out_of_reach_reaches_nobody(self): + game = _held(distance=30) + hild, osric = _ids(game, "Hild", "Osric") + + play(game, close(game, hild), close(game, osric)) + + assert member_distance_feet(game, hild, group_id(game)) == 10 + assert group_front_rank(game, group_id(game)) == () + + def test_the_first_to_arrive_keeps_their_place(self): + game = _held(distance=30) + hild, osric, nim = _ids(game, "Hild", "Osric", "Nim") + play(game, close(game, nim)) + play(game, close(game, hild), close(game, osric)) + play(game, close(game, hild), close(game, osric)) + assert {member_distance_feet(game, member_id, group_id(game)) for member_id in (hild, osric, nim)} == {5} + + assert group_front_rank(game, group_id(game)) == (hild, nim), "Nim got there first, then Hild by marching" + + def test_without_the_width_limit_every_member_within_reach_is_in_front(self): + game = _held(distance=30, ruleset=Ruleset(formation_width_limit=False)) + hild, nim, wren = _ids(game, "Hild", "Nim", "Wren") + + play(game, close(game, hild), close(game, nim), close(game, wren)) + + assert group_front_rank(game, group_id(game)) == (nim, wren) + + def test_a_second_row_member_within_reach_is_refused_melee(self): + game = _held(distance=30) + hild, osric, nim = _ids(game, "Hild", "Osric", "Nim") + play(game, close(game, nim)) + play(game, close(game, hild), close(game, osric)) + play(game, close(game, hild), close(game, osric)) + assert member_distance_feet(game, osric, group_id(game)) == 5, "precondition: Osric stands within reach" + + rejections = refused(game, strike(game, osric)) + + assert rejections == [("battle.declaration.not_in_front_rank", {"character": osric})] + + def test_a_front_row_member_out_of_reach_is_refused_at_their_own_distance(self): + """Wren alone stands nearest the goblins, so Wren is their front row, still 10' short of reach.""" + game = _held(distance=50) + wren = named(game, "Wren") + play(game, close(game, wren)) + + rejections = refused(game, strike(game, wren)) + + assert rejections == [("combat.attack.out_of_reach", {"distance_feet": 10})] + + def test_the_member_alone_in_front_strikes_and_the_row_behind_waits(self): + """A row never mixes distances: Nim at 5' is the whole front row, and Hild at 30' is in the next one.""" + game = _held(distance=30) + nim, hild = _ids(game, "Nim", "Hild") + play(game, close(game, nim)) + + assert refused(game, strike(game, hild)) == [("battle.declaration.not_in_front_rank", {"character": hild})] + events = play(game, strike(game, nim)) + + assert any(getattr(event, "attacker_id", None) == nim for event in events) + + +# ---------------------------------------------------------------------- the party line moves back + + +@POSITIONS +class TestThePartyLineMovesBack: + """At the end of the party's movement step, when every living member who isn't invisible stands behind the party + line, the line moves back to the foremost of them. Every advance and every group's distance grow by the same + number of feet, so no distance between a member and a group changes, and no event reports it. A group can then + close on the party as usual. + """ + + def test_a_lone_withdrawal_that_takes_the_whole_party_behind_the_line_moves_the_line_back(self): + game = _held(distance=40) + hild, osric, nim, aelin, wren, tam = _ids(game, "Hild", "Osric", "Nim", "Aelin", "Wren", "Tam") + play(game, *(_withdraw(member_id, "retreat") for member_id in (osric, nim, aelin, wren, tam))) + assert advances(game) == {hild: 0, osric: -20, nim: -30, aelin: -20, wren: -40, tam: -20}, ( + "precondition: Hild alone holds the line" + ) + assert game.encounter.groups[0].distance_feet == 40 + + events = play(game, _withdraw(hild)) + + (move,) = moved(events) + assert (move.character_id, move.advance_feet, move.distance_feet) == (hild, -10, 50) + assert group_moves(events) == [], "nobody moved toward or away from anyone" + assert advances(game) == {hild: 0, osric: -10, nim: -20, aelin: -10, wren: -30, tam: -10} + assert game.encounter.groups[0].distance_feet == 50 + distances = {member_id: member_distance_feet(game, member_id, group_id(game)) for member_id in advances(game)} + assert distances == {hild: 50, osric: 60, nim: 70, aelin: 60, wren: 80, tam: 60} + + def test_a_group_closes_and_reaches_a_party_that_fell_back_one_member_at_a_time(self): + game = _held(distance=40) + hild, osric = _ids(game, "Hild", "Osric") + for member in list(game.party.members): + play(game, _withdraw(member.id)) + assert max(advances(game).values()) == 0, "the line moved back to the foremost members" + assert game.encounter.groups[0].distance_feet == 50 + release_monsters(game) + + for _ in range(3): + play(game) + + assert game.encounter.groups[0].distance_feet == 5 + assert group_front_rank(game, group_id(game)) == (hild, osric) + + @pytest.mark.parametrize("holder", ["at the line", "forward of it"]) + def test_a_member_at_or_forward_of_the_line_keeps_it_where_it_is(self, holder): + game = _held(distance=40) + nim, tam = _ids(game, "Nim", "Tam") + stays = tam if holder == "at the line" else nim + if holder == "forward of it": + play(game, close(game, nim)) + assert advance(game, nim) == 30, "precondition: Nim stands forward" + others = [member.id for member in game.party.members if member.id != stays] + + play(game, *(_withdraw(member_id, "retreat") for member_id in others)) + + assert advance(game, stays) == (0 if holder == "at the line" else 30) + assert all(advance(game, member_id) < 0 for member_id in others) + assert game.encounter.groups[0].distance_feet == 40 + + def test_an_invisible_member_on_the_line_does_not_hold_it(self): + game = _held(distance=40) + hild, osric, nim, aelin, wren, tam = _ids(game, "Hild", "Osric", "Nim", "Aelin", "Wren", "Tam") + attach(game, EffectDefinition(kind="invisibility", condition=Condition.INVISIBLE), tam) + + play(game, *(_withdraw(member_id, "retreat") for member_id in (hild, osric, nim, aelin, wren))) + + assert advances(game) == {hild: 0, osric: 0, nim: -10, aelin: 0, wren: -20, tam: 20} + assert game.encounter.groups[0].distance_feet == 60 + + +# ---------------------------------------------------------------------- the formation, the pursuit, and the end + + +@POSITIONS +class TestTheFormationAndTheEnd: + def test_a_formation_move_moves_the_groups_and_leaves_every_advance_alone(self): + game = _held(distance=30) + nim = named(game, "Nim") + play(game, close(game, nim)) + + events = play(game, *(_withdraw(member.id) for member in game.party.members)) + + assert [event.distance_feet for event in group_moves(events)] == [40], "half the slowest rate of 20'" + assert moved(events) == [] + assert advance(game, nim) == 25 + assert member_distance_feet(game, nim, group_id(game)) == 15 + + def test_a_formation_retreat_starts_the_pursuit_from_the_nearest_member(self): + game = _held(distance=30) + nim = named(game, "Nim") + play(game, close(game, nim)) + + events = play(game, *(_withdraw(member.id, "retreat") for member in game.party.members)) + + assert "battle.ended.fled" in [event.code for event in events] + assert game.mode is SessionMode.ENCOUNTER, "precondition: the goblins give chase" + assert game.encounter.groups[0].distance_feet == 50 + assert game.encounter.pursuit.gap_feet == 25, "Nim stood 25' ahead of the line" + + def test_a_spread_out_party_retreats_as_one_body_and_the_chase_starts_from_the_nearest_member(self): + """Nim stands 25' forward and Wren 40' back. The goblins sleep, so only the kobolds can follow.""" + game = battle(distance=30) + assert game.execute(SpawnMonsters(template_id="kobold", count_fixed=2, distance_feet=60)).accepted + hold_monsters(game) + nim, wren = _ids(game, "Nim", "Wren") + play(game, close(game, nim), _withdraw(wren, "retreat")) + assert (advance(game, nim), advance(game, wren)) == (25, -40), "precondition: the party stands apart" + for monster_id in game.encounter.groups[0].monster_ids: + game.monsters[monster_id].conditions = ( + ActiveCondition(condition=Condition.ASLEEP, effect_id="effect-9999"), + ) + + events = play(game, *(_withdraw(member.id, "retreat") for member in game.party.members)) + + assert "battle.ended.fled" in [event.code for event in events] + assert moved(events) == [], "a formation move moves the groups, not the members" + assert [group.distance_feet for group in game.encounter.groups] == [50, 80], "the slowest rate of 20'" + assert game.mode is SessionMode.ENCOUNTER, "precondition: the kobolds give chase" + assert game.encounter.pursuit.gap_feet == 55, "Nim, 25' forward, is 55' from the kobolds" + + def test_positions_end_with_the_battle(self): + from osrlib.core.combat import DamageSource, deal_damage + + game = _held(distance=30) + nim = named(game, "Nim") + play(game, close(game, nim)) + assert advance(game, nim) == 25, "precondition: Nim stood forward" + for monster_id in game.encounter.groups[0].monster_ids: + deal_damage(game.combatant(monster_id), 999, source=DamageSource()) + play(game) + assert game.mode is SessionMode.EXPLORING, "precondition: the battle is won" + + assert game.execute(SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=30)).accepted + if game.mode is SessionMode.ENCOUNTER: + from osrlib.crawl.commands import EngageBattle + + assert game.execute(EngageBattle()).accepted + + assert game.battle is not None + assert game.battle.advance_feet == {} + assert game.battle.arrived_round == {} + + +# ---------------------------------------------------------------------- determinism + + +@POSITIONS +class TestDeterminism: + def test_a_refused_round_with_a_close_in_it_costs_nothing(self): + game = _held(distance=30) + hild, osric, nim = _ids(game, "Hild", "Osric", "Nim") + play(game, close(game, nim)) + assert advance(game, nim) == 25, "precondition: Nim stands forward" + + rejections = refused(game, close(game, hild), strike(game, osric)) + + assert rejections == [("combat.attack.out_of_reach", {"distance_feet": 30})] + assert game.battle.advance_feet == {nim: 25} + + def test_a_save_mid_battle_keeps_the_positions_and_plays_on_the_same(self): + game = battle(surprised=True) + nim, hild = _ids(game, "Nim", "Hild") + play(game, close(game, nim), close(game, hild)) + assert advance(game, nim) == 25, "precondition: Nim stands forward" + + restored = load_game(save_game(game)) + + assert restored.battle.advance_feet == game.battle.advance_feet + assert restored.battle.arrived_round == game.battle.arrived_round + original, again = round_(game), round_(restored) + assert original.accepted and again.accepted + assert [event.model_dump() for event in again.events] == [event.model_dump() for event in original.events] + + def test_a_replay_rebuilds_the_positions(self): + from osrlib.core.character import party_to_document + + game = battle(surprised=True) + nim, hild = _ids(game, "Nim", "Hild") + play(game, close(game, nim), close(game, hild)) + assert advance(game, nim) == 25, "precondition: Nim stands forward" + for _ in range(2): + if game.mode is SessionMode.BATTLE: + play(game) + members = [character(name, class_id) for name, class_id in WALK] + + replayed = replay_game( + game.master_seed, party_to_document(members), game.adventure, Ruleset(), game.command_log + ) + + assert session_state(replayed) == session_state(game) + + +@POSITIONS +def test_the_old_swap_codes_are_no_longer_raised(): + """A withdrawal alone needs no replacement and covers no rank distance, so neither code can come back.""" + scanned = scan_rejection_codes() + assert "battle.declaration.no_replacement" not in scanned + assert "battle.declaration.replacement_too_far" not in scanned diff --git a/tests/test_monster_reach.py b/tests/test_monster_reach.py new file mode 100644 index 0000000..f090a71 --- /dev/null +++ b/tests/test_monster_reach.py @@ -0,0 +1,248 @@ +"""Acceptance tests for how far the monsters reach once party members stand apart (issue #209, chunk: monster-reach). + +The contract these tests check: + +- A group closes toward the party line at its rate, and stops as soon as it's within 5' of the nearest living member + it can see. It never moves past a member it can see. An invisible member doesn't stop it, and a member turned to stone + does. A group already within 5' of a member doesn't close. +- Melee from a group reaches only its front rank within 5', the members `group_front_rank` lists. The members behind + are out of its reach, so the members in front screen them. +- A monster can close and attack in the same round. The default policies follow a `close` that brings a member within + reach with a `melee` for each monster, aimed at the front rank the move produces. The session resolves a `melee` + only on a target in the group's front rank within 5' when the action comes up, whatever a policy chose. +- NPC adventurers shoot at the first row of the party's ranks toward their group, not at whoever marches first. +- A petrifying gaze reaches the gazer's group's front rank within 5'. +- A breath aimed at the party covers the ranks toward the breathing group from the front, by distance. A row at a + greater distance starts at that distance, so a cone covers each member nearer than its length. +- Friendly fire from the party's area spell catches the members within 5' of the group in its front rank. + +Each test sets members at their distances with the monsters held by a test policy, then gives the monsters their +default policy back for the round under test. Each test asserts its own preconditions. +""" + +import pytest + +from osrlib.core.effects import ActiveCondition, Condition, EffectDefinition +from osrlib.core.events import SpellCastEvent +from osrlib.crawl.battle import MonsterAction, ScriptedPolicy, group_front_rank, member_distance_feet +from osrlib.crawl.session import MONSTER_ACTION_STREAM +from positions_fixtures import ( + advance, + attach, + battle, + close, + declare, + group_id, + group_moves, + hold_monsters, + monster_attacks, + named, + play, + position, + release_monsters, + saves_by_members, +) + +pytestmark = pytest.mark.xfail(reason="chunk: monster-reach", strict=True) + + +def _ids(game, *names: str) -> tuple[str, ...]: + return tuple(named(game, name) for name in names) + + +def _placed(*closers: tuple[str, int], **battle_args): + """A battle in which each named member closed the given number of rounds while the monsters held still.""" + game = battle(**battle_args) + hold_monsters(game) + for rounds in range(max((count for _, count in closers), default=0)): + movers = [named(game, name) for name, count in closers if count > rounds] + play(game, *(close(game, member_id) for member_id in movers)) + release_monsters(game) + return game + + +class _Breathe: + """A test policy: every monster of the group uses its breath weapon.""" + + def choose(self, session, group, stream): + return [MonsterAction(monster_id=monster_id, kind="breath") for monster_id in group.monster_ids] + + +class _StrikeAt: + """A test policy: every monster of the group closes, then strikes one named member, wherever they stand.""" + + def __init__(self, target_id: str) -> None: + self.target_id = target_id + + def choose(self, session, group, stream): + closes = [MonsterAction(monster_id=monster_id, kind="close") for monster_id in group.monster_ids] + strikes = [ + MonsterAction(monster_id=monster_id, kind="melee", target_id=self.target_id) + for monster_id in group.monster_ids + ] + return [*closes, *strikes] + + +# ---------------------------------------------------------------------- closing + + +class TestAGroupCloses: + def test_a_group_stops_five_feet_from_the_nearest_member(self): + game = _placed(("Nim", 1), distance=50) + nim = named(game, "Nim") + assert advance(game, nim) == 30, "precondition: Nim stands 20' from the goblins" + + events = play(game) + + assert [event.distance_feet for event in group_moves(events)] == [35] + assert member_distance_feet(game, nim, group_id(game)) == 5 + + def test_a_group_never_walks_past_a_member(self): + game = _placed(("Hild", 1), distance=40) + hild = named(game, "Hild") + + play(game) + + assert game.encounter.groups[0].distance_feet == 25 + assert all(member_distance_feet(game, member.id, group_id(game)) >= 5 for member in game.party.members) + assert group_front_rank(game, group_id(game)) == (hild,) + + def test_an_invisible_member_does_not_stop_a_group(self): + """Nim is invisible 30' ahead, so the goblins pass Nim and stop 5' from Hild, 20' ahead.""" + game = _placed(("Nim", 1), ("Hild", 1), distance=40) + nim, hild = _ids(game, "Nim", "Hild") + assert (advance(game, nim), advance(game, hild)) == (30, 20), "precondition: positions" + attach(game, EffectDefinition(kind="invisibility", condition=Condition.INVISIBLE), nim) + + events = play(game) + + assert [event.distance_feet for event in group_moves(events)] == [25] + assert {attack.defender_id for attack in monster_attacks(game, events)} <= {hild} + + def test_a_statue_stops_a_group_and_is_struck(self): + game = _placed(("Nim", 1), distance=50) + nim = named(game, "Nim") + game.member(nim).conditions = (ActiveCondition(condition=Condition.PETRIFIED, effect_id="effect-9999"),) + + events = play(game) + + assert game.encounter.groups[0].distance_feet == 35 + attacks = monster_attacks(game, events) + assert attacks, "precondition: the goblins struck" + assert {attack.defender_id for attack in attacks} == {nim} + + +# ---------------------------------------------------------------------- who the group reaches + + +class TestWhoTheGroupReaches: + def test_a_group_within_reach_of_a_member_stays_and_strikes_only_that_member(self): + game = _placed(("Nim", 1), distance=30) + nim = named(game, "Nim") + assert member_distance_feet(game, nim, group_id(game)) == 5, "precondition: Nim stands within reach" + + struck: set[str] = set() + for _ in range(4): + events = play(game) + assert group_moves(events) == [], "the goblins hold their ground" + struck |= {attack.defender_id for attack in monster_attacks(game, events)} + + assert struck == {nim} + + def test_a_group_closes_and_strikes_in_the_same_round(self): + game = battle(distance=25) + hild, osric = _ids(game, "Hild", "Osric") + + events = play(game) + + (move,) = group_moves(events) + attacks = monster_attacks(game, events) + assert move.distance_feet == 5 + assert attacks, "the goblins struck in the round they arrived" + assert all(position(events, attack) > position(events, move) for attack in attacks) + assert {attack.defender_id for attack in attacks} <= {hild, osric} + + def test_the_default_policy_follows_a_close_that_reaches_with_melee(self): + game = battle(distance=25) + front = set(_ids(game, "Hild", "Osric")) + group = game.encounter.groups[0] + + actions = ScriptedPolicy().choose(game, group, game.streams.get(MONSTER_ACTION_STREAM)) + + assert "close" in [action.kind for action in actions] + strikes = [action for action in actions if action.kind == "melee"] + assert len(strikes) == len(group.monster_ids) + assert {action.target_id for action in strikes} <= front + + def test_a_melee_on_a_member_out_of_reach_is_skipped(self): + game = _placed(("Nim", 1), distance=30) + wren = named(game, "Wren") + game.action_policies[group_id(game)] = _StrikeAt(wren) + + events = play(game) + + assert [attack for attack in monster_attacks(game, events) if attack.defender_id == wren] == [] + + def test_a_melee_after_a_close_that_falls_short_is_skipped(self): + game = battle(distance=60) + hild = named(game, "Hild") + game.action_policies[group_id(game)] = _StrikeAt(hild) + + events = play(game) + + assert game.encounter.groups[0].distance_feet == 40, "precondition: the goblins closed 20'" + assert monster_attacks(game, events) == [] + + def test_npc_archers_shoot_at_the_first_row_toward_them(self): + """Wren alone closed toward the NPCs, so Wren is the whole first row they see.""" + for seed in range(0, 60): + game = _placed(("Wren", 1), distance=60, npc_party=True, first_seed=seed) + wren = named(game, "Wren") + assert member_distance_feet(game, wren, group_id(game)) == 20, "precondition: Wren stands forward" + + attacks = monster_attacks(game, play(game)) + if not attacks: + continue + + assert {attack.defender_id for attack in attacks} == {wren} + return + pytest.fail("no seed gave the NPCs an attack") + + +# ---------------------------------------------------------------------- gaze, breath, and friendly fire + + +class TestAreasAndGazes: + def test_a_gaze_reaches_only_the_front_rank_within_reach(self): + game = _placed(("Nim", 1), template_id="basilisk", count=1, distance=30) + nim = named(game, "Nim") + assert member_distance_feet(game, nim, group_id(game)) == 5, "precondition: Nim stands within reach" + + events = play(game) + + assert saves_by_members(game, events) == {nim} + + def test_a_breath_covers_the_members_nearer_than_its_length(self): + """The chimera's cone is 50' long. Nim at 5' and Hild at 20' are inside it. The line, 60' off, isn't.""" + game = _placed(("Nim", 2), ("Hild", 2), template_id="chimera", count=1, distance=60) + nim, hild = _ids(game, "Nim", "Hild") + assert member_distance_feet(game, nim, group_id(game)) == 5, "precondition: Nim at 5'" + assert member_distance_feet(game, hild, group_id(game)) == 20, "precondition: Hild at 20'" + game.action_policies[group_id(game)] = _Breathe() + + events = play(game) + + assert saves_by_members(game, events) == {nim, hild} + + def test_friendly_fire_catches_the_member_within_reach_of_the_group(self): + game = _placed(("Nim", 1), distance=30, count=4, levels={"Wren": 5}, spells={"Wren": ("fire_ball",)}) + hold_monsters(game) + nim, wren = _ids(game, "Nim", "Wren") + assert member_distance_feet(game, nim, group_id(game)) == 5, "precondition: Nim stands within reach" + fire_ball = declare(wren, "cast", spell_id="fire_ball", spell_mode="damage", target_group_id=group_id(game)) + + events = play(game, fire_ball) + + (cast,) = [event for event in events if isinstance(event, SpellCastEvent) and event.caster_id == wren] + party = {member.id for member in game.party.members} + assert set(cast.target_ids) & party == {nim} diff --git a/tests/test_move_and_attack.py b/tests/test_move_and_attack.py new file mode 100644 index 0000000..67e3a0f --- /dev/null +++ b/tests/test_move_and_attack.py @@ -0,0 +1,512 @@ +"""Acceptance tests for closing and attacking in one round, and the dry run of a round (issue #209). + +The OSE SRD's combat page says "Movement and attacking may be combined in the same round", and under spell casting, +"The spell caster cannot move and cast a spell in the same round." The contract these tests check (chunk: +move-and-attack): + +- An `attack` may set `move="close"`. The member closes in the party's movement step and attacks in the missile or + melee step of the same round. The session judges the attack before the round starts, as if every declared move had + happened: the member's distance after the move decides melee or missile, the default weapon, and reach, and a melee + attack needs the member in the group's front rank toward the group after every declared move. Width, arrival order, + and marching order decide the front rank. `combat.attack.out_of_reach` reports the distance the move would leave. +- A `cast` that sets a `move` is refused with `battle.declaration.cast_cannot_move`. A `turn_undead`, `use_item`, or + `hold` that sets one, and an `attack` with a `fighting_withdrawal` or a `retreat`, get + `battle.declaration.move_not_combinable`. Both come after the back-stab checks and before the action's own. An attack + that closes, by a member who can't move, gets `battle.declaration.cannot_move`. +- A melee attack resolves only if the member stands in the group's front rank within 5' at the melee step, or the group + is fleeing and was within the member's reach when the round was accepted. A member whose close fell short because the + group ran first makes no attack. +- A spell's range is measured from the caster's own distance to the group. +- `preview_battle_round` runs the same checks over the same projection, for any subset of the declarers, and changes + nothing. A complete plan it finds no fault with is accepted, and leaves the members where it said. + +Most tests keep the monsters still with a hold policy. Each test asserts its own preconditions, and every refused +round is checked to cost nothing. The unmarked tests are guards that pass before and after the chunk. +""" + +import pytest + +from osrlib.core.effects import ActiveCondition, Condition +from osrlib.core.events import DeclarationFizzledEvent, SpellCastEvent, Visibility +from osrlib.crawl import battle as battle_module +from osrlib.crawl.battle import BattleRoundPreview, group_front_rank, preview_battle_round +from osrlib.crawl.commands import GrantItem, ResolveBattleRound +from osrlib.crawl.events import parse_any_event +from osrlib.messages import format_message +from positions_fixtures import ( + advance, + advances, + attacks_by, + battle, + close, + declare, + group_id, + hold_monsters, + monsters_first, + moved, + named, + plan, + play, + position, + refused, + seeds, + state_without_logs, + strike, +) + +MOVE_AND_ATTACK = pytest.mark.xfail(reason="chunk: move-and-attack", strict=True) + +NOT_IN_FRONT_RANK = "battle.declaration.not_in_front_rank" +OUT_OF_REACH = "combat.attack.out_of_reach" +CAST_CANNOT_MOVE = "battle.declaration.cast_cannot_move" +MOVE_NOT_COMBINABLE = "battle.declaration.move_not_combinable" + +MISSILE_SPELLS = {"Wren": ("magic_missile", "magic_missile")} + + +def _held(**battle_args): + game = battle(**battle_args) + hold_monsters(game) + return game + + +def _ids(game, *names: str) -> tuple[str, ...]: + return tuple(named(game, name) for name in names) + + +def _missile(game, caster_id: str, **fields): + goblin = game.encounter.groups[0].monster_ids[0] + return declare(caster_id, "cast", spell_id="magic_missile", spell_mode="missiles", targets=(goblin,), **fields) + + +def _cast_at(events, caster_id: str) -> int: + return position(events, next(e for e in events if isinstance(e, SpellCastEvent) and e.caster_id == caster_id)) + + +def _set_up_the_front(game): + """Nim closes to 5' of the goblins at 30', and Hild and Osric to 10', in one round.""" + hild, osric, nim = _ids(game, "Hild", "Osric", "Nim") + play(game, close(game, nim), close(game, hild), close(game, osric)) + assert (advance(game, nim), advance(game, hild), advance(game, osric)) == (25, 20, 20), "precondition: positions" + + +# ---------------------------------------------------------------------- close and attack + + +@MOVE_AND_ATTACK +class TestCloseAndAttack: + def test_a_member_closes_and_strikes_in_one_round(self): + game = _held(distance=30) + nim = named(game, "Nim") + + events = play(game, strike(game, nim, move="close")) + + (move,) = moved(events) + (swing,) = attacks_by(events, nim) + assert (move.character_id, move.move, move.advance_feet, move.distance_feet) == (nim, "close", 25, 5) + assert position(events, move) < position(events, swing) + assert swing.attack_name == "Dagger" + + @pytest.mark.parametrize(("distance", "reaches"), [(25, True), (30, False)]) + def test_a_member_reaches_a_group_up_to_their_rate_plus_five(self, distance, reaches): + game = _held(distance=distance) + hild = named(game, "Hild") + declaration = strike(game, hild, move="close", weapon_id="sword") + + if reaches: + events = play(game, declaration) + assert advance(game, hild) == distance - 5 + assert attacks_by(events, hild), "Hild closed and swung" + else: + assert refused(game, declaration) == [(OUT_OF_REACH, {"distance_feet": 10})] + + def test_a_member_can_close_and_shoot(self): + game = _held(distance=60) + aelin = named(game, "Aelin") + + events = play(game, strike(game, aelin, move="close", weapon_id="short_bow")) + + (move,) = moved(events) + (shot,) = attacks_by(events, aelin) + assert (move.advance_feet, move.distance_feet) == (20, 40) + assert position(events, move) < position(events, shot) + + def test_a_dagger_is_swung_after_a_close_into_reach(self): + """The melee step comes after the magic step, and the thrown dagger's missile step before it.""" + game = _held(distance=30, spells=MISSILE_SPELLS) + nim, wren = _ids(game, "Nim", "Wren") + + events = play(game, strike(game, nim, move="close"), _missile(game, wren)) + + (swing,) = attacks_by(events, nim) + assert position(events, swing) > _cast_at(events, wren), "swung in the melee step" + + def test_a_spell_reaches_from_the_casters_own_place(self): + """Magic missile reaches 150'. Wren closed 40' alone, so Wren stands 150' from goblins 190' off the line.""" + game = _held(distance=190, spells=MISSILE_SPELLS) + wren = named(game, "Wren") + play(game, close(game, wren)) + assert advance(game, wren) == 40, "precondition: Wren closed alone" + + events = play(game, _missile(game, wren)) + + assert any(isinstance(event, SpellCastEvent) and event.caster_id == wren for event in events) + + +class TestTheDefaultWeaponWithoutAMove: + """A guard: a dagger aimed at a group 30' away, with no close, is thrown, before and after the chunk.""" + + def test_a_dagger_is_thrown_without_a_close(self): + game = _held(distance=30, spells=MISSILE_SPELLS) + nim, wren = _ids(game, "Nim", "Wren") + + events = play(game, strike(game, nim), _missile(game, wren)) + + (throw,) = attacks_by(events, nim) + assert position(events, throw) < _cast_at(events, wren), "thrown in the missile step" + + +# ---------------------------------------------------------------------- the front rank after the moves + + +@MOVE_AND_ATTACK +class TestTheFrontRankAfterTheMoves: + def test_the_last_place_goes_to_the_first_in_marching_order(self): + """Section 8, round 2: Nim got there first, so Hild takes the last place and Osric waits behind.""" + game = _held(distance=30) + hild, osric, nim = _ids(game, "Hild", "Osric", "Nim") + _set_up_the_front(game) + + rejections = refused(game, strike(game, hild, move="close"), strike(game, osric, move="close")) + assert rejections == [(NOT_IN_FRONT_RANK, {"character": osric})] + + events = play(game, strike(game, hild, move="close"), close(game, osric)) + + assert attacks_by(events, hild), "Hild closed and swung" + assert (advance(game, hild), advance(game, osric)) == (25, 25) + assert group_front_rank(game, group_id(game)) == (hild, nim) + + def test_two_members_closing_together_fill_an_empty_front_rank(self): + game = _held(distance=30) + hild, osric = _ids(game, "Hild", "Osric") + play(game, close(game, hild), close(game, osric)) + + events = play(game, strike(game, hild, move="close"), strike(game, osric, move="close")) + + assert attacks_by(events, hild) and attacks_by(events, osric) + + def test_a_member_falling_back_alone_opens_a_place_the_same_round(self): + game = _held(distance=5) + hild, nim = _ids(game, "Hild", "Nim") + + events = play(game, declare(hild, "move", move="fighting_withdrawal"), strike(game, nim)) + + assert attacks_by(events, nim), "Nim stepped into the front rank Hild left" + + +# ---------------------------------------------------------------------- what may combine with a move + + +@MOVE_AND_ATTACK +class TestWhatCombinesWithAMove: + @pytest.mark.parametrize("move", ["close", "fighting_withdrawal", "retreat"]) + def test_a_caster_cannot_move(self, move): + game = _held(distance=30, spells=MISSILE_SPELLS) + wren = named(game, "Wren") + + rejections = refused(game, _missile(game, wren, move=move, target_group_id=group_id(game))) + + assert rejections == [(CAST_CANNOT_MOVE, {"character": wren})] + + @pytest.mark.parametrize( + ("member", "action", "move"), + [ + ("Tam", "turn_undead", "close"), + ("Nim", "use_item", "close"), + ("Hild", "hold", "close"), + ("Hild", "hold", "retreat"), + ("Hild", "attack", "fighting_withdrawal"), + ("Hild", "attack", "retreat"), + ], + ) + def test_only_an_attack_takes_a_close(self, member, action, move): + game = _held(distance=30) + member_id = named(game, member) + assert game.execute(GrantItem(character_id=named(game, "Nim"), item_id="oil_flask")).accepted + fields = {"item_id": "oil_flask"} if action == "use_item" else {} + + rejections = refused(game, declare(member_id, action, move=move, target_group_id=group_id(game), **fields)) + + assert rejections == [(MOVE_NOT_COMBINABLE, {"character": member_id})] + + def test_the_combination_is_checked_before_the_actions_own_checks(self): + game = _held(distance=30) + wren = named(game, "Wren") + + rejections = refused(game, declare(wren, "cast", move="close", target_group_id=group_id(game))) + + assert rejections == [(CAST_CANNOT_MOVE, {"character": wren})], "not battle.declaration.missing_spell" + + def test_an_attack_that_closes_needs_a_member_who_can_move(self): + game = _held(distance=20) + hild = named(game, "Hild") + game.member(hild).conditions = (ActiveCondition(condition=Condition.ENTANGLED, effect_id="effect-9999"),) + + rejections = refused(game, strike(game, hild, move="close", weapon_id="sword")) + + assert rejections == [("battle.declaration.cannot_move", {"character": hild})] + + +# ---------------------------------------------------------------------- the melee step + + +MELEE_FIZZLED = "battle.melee.fizzled" + + +def _melee_fizzles(events, actor_id: str) -> list[DeclarationFizzledEvent]: + return [ + event + for event in events + if isinstance(event, DeclarationFizzledEvent) and event.code == MELEE_FIZZLED and event.actor_id == actor_id + ] + + +@MOVE_AND_ATTACK +class TestTheMeleeStep: + """A melee attack resolves only if the member stands in the group's front rank within 5' when it comes up. + + Otherwise it fizzles with a player-visible `DeclarationFizzledEvent` at `battle.melee.fizzled`, whose `reason` is + `combat.attack.out_of_reach` for a member farther than 5' and `battle.declaration.not_in_front_rank` for a member + within 5' but not in the front rank, and whose `group_id` is the group. A fleeing group is the exception. + """ + + def test_the_monsters_win_initiative_and_run_out_of_reach_so_the_attack_fizzles(self): + """The goblins break and run 60' before the party acts, so Nim's close ends 60' short.""" + for seed in seeds(distance=30): + game = battle(distance=30, first_seed=seed) + game.encounter.groups[0].fleeing = True + nim = named(game, "Nim") + + events = play(game, strike(game, nim, move="close")) + if not monsters_first(game, events): + continue + + (move,) = moved(events) + assert (move.advance_feet, move.distance_feet) == (30, 60) + assert attacks_by(events, nim) == [] + (fizzle,) = _melee_fizzles(events, nim) + assert (fizzle.reason, fizzle.group_id, fizzle.item_id) == (OUT_OF_REACH, group_id(game), None) + assert fizzle.visibility is Visibility.PLAYER + assert position(events, fizzle) > position(events, move) + return + + def test_a_member_left_out_of_the_front_rank_fizzles(self, monkeypatch): + """Nim's attack was judged with Hild stepping back. Hild is paralysed as initiative is rolled, so Hild stays + in front with Osric, and Nim, 5' from the goblins, stands in the second row.""" + game = _held(distance=5) + hild, nim = _ids(game, "Hild", "Nim") + original = battle_module.roll_initiative + + def paralysis_then_initiative(*args, **kwargs): + member = game.member(hild) + member.conditions = (*member.conditions, ActiveCondition(condition=Condition.PARALYSED, effect_id=None)) + return original(*args, **kwargs) + + monkeypatch.setattr(battle_module, "roll_initiative", paralysis_then_initiative) + + events = play(game, declare(hild, "move", move="fighting_withdrawal"), strike(game, nim)) + + assert moved(events) == [], "precondition: Hild couldn't move" + assert attacks_by(events, nim) == [] + (fizzle,) = _melee_fizzles(events, nim) + assert (fizzle.reason, fizzle.group_id) == (NOT_IN_FRONT_RANK, group_id(game)) + + +def test_a_member_within_reach_still_strikes_a_group_that_runs_first(): + """A guard: the SRD's retreating combatant gives its opponent the attack, before and after the chunk.""" + for seed in seeds(distance=5): + game = battle(distance=5, first_seed=seed) + game.encounter.groups[0].fleeing = True + hild = named(game, "Hild") + + events = play(game, strike(game, hild, weapon_id="sword")) + if not monsters_first(game, events): + continue + + assert game.encounter.groups[0].distance_feet > 5, "precondition: the goblins ran before Hild's turn" + assert attacks_by(events, hild), "Hild still got the swing" + assert _melee_fizzles(events, hild) == [] + return + + +class TestTheMeleeFizzleEvent: + """Guards on the event's shape, which the stub ships.""" + + def test_the_event_takes_the_melee_code_and_a_group(self): + event = DeclarationFizzledEvent( + code=MELEE_FIZZLED, actor_id="character-0001", reason=OUT_OF_REACH, group_id="group-0001" + ) + assert event.visibility is Visibility.PLAYER + assert parse_any_event(event.model_dump(mode="json")) == event + assert DeclarationFizzledEvent(code="magic.turning.fizzled", actor_id="a", reason="r").group_id is None + + @pytest.mark.parametrize(("reason", "words"), [(OUT_OF_REACH, "can't reach"), (NOT_IN_FRONT_RANK, "front rank")]) + def test_the_message_says_why_the_member_didnt_swing(self, reason, words): + event = DeclarationFizzledEvent( + code=MELEE_FIZZLED, actor_id="character-0001", reason=reason, group_id="group-0001" + ) + line = format_message(event) + assert "character-0001" in line and "group-0001" in line and words in line + + +# ---------------------------------------------------------------------- the dry run + + +def _flat(rejections) -> list[str]: + return sorted(repr((rejection.code, sorted(rejection.params.items()))) for rejection in rejections) + + +@MOVE_AND_ATTACK +class TestTheDryRun: + def test_a_preview_changes_nothing(self): + game = _held(distance=30, spells=MISSILE_SPELLS) + hild, nim, wren = _ids(game, "Hild", "Nim", "Wren") + before = state_without_logs(game) + log_size = len(game.command_log), len(game.event_log) + + preview = preview_battle_round( + game, [strike(game, nim, move="close"), close(game, hild), _missile(game, wren, move="close")] + ) + + assert isinstance(preview, BattleRoundPreview) + assert state_without_logs(game) == before + assert (len(game.command_log), len(game.event_log)) == log_size + + def test_an_empty_plan_shows_where_everyone_stands(self): + game = _held(distance=30) + nim = named(game, "Nim") + play(game, close(game, nim)) + + preview = preview_battle_round(game, []) + + assert preview.rejections == {} + assert preview.advance_feet == advances(game) + assert preview.front_rank == {group_id(game): (nim,)} + + def test_a_partial_plan_gets_no_roster_rejection(self): + game = _held(distance=30) + hild = named(game, "Hild") + + preview = preview_battle_round(game, [close(game, hild)]) + + assert preview.rejections == {hild: ()} + assert preview.advance_feet == {**advances(game), hild: 20} + + def test_the_preview_refuses_what_the_round_refuses(self): + game = _held(distance=30, spells=MISSILE_SPELLS) + hild, osric, wren, tam = _ids(game, "Hild", "Osric", "Wren", "Tam") + declarations = plan( + game, + strike(game, hild, move="close", weapon_id="sword"), + strike(game, osric, weapon_id="sword"), + _missile(game, wren, move="close", target_group_id=group_id(game)), + declare(tam, "hold", move="close", target_group_id=group_id(game)), + ) + + preview = preview_battle_round(game, declarations) + rejections = refused(game, *declarations) + + assert set(preview.rejections) == {declaration.character_id for declaration in declarations} + assert all(preview.rejections[member_id] for member_id in (hild, osric, wren, tam)) + flattened = [rejection for found in preview.rejections.values() for rejection in found] + assert _flat(flattened) == sorted(repr((code, sorted(params.items()))) for code, params in rejections) + + def test_a_complete_plan_it_finds_no_fault_with_is_accepted_and_leaves_the_members_where_it_said(self): + game = battle(distance=30, count=4, surprised=True, spells=MISSILE_SPELLS) + hild, osric, nim, aelin, wren = _ids(game, "Hild", "Osric", "Nim", "Aelin", "Wren") + declarations = plan( + game, + close(game, hild), + close(game, osric), + strike(game, nim, move="close", back_stab=True), + strike(game, aelin, weapon_id="short_bow"), + _missile(game, wren), + ) + + preview = preview_battle_round(game, declarations) + assert all(found == () for found in preview.rejections.values()), preview.rejections + result = game.execute(ResolveBattleRound(declarations=declarations)) + + assert result.accepted, [rejection.code for rejection in result.rejections] + assert advances(game) == preview.advance_feet + assert {group_id(game): group_front_rank(game, group_id(game))} == preview.front_rank + assert preview.advance_feet[nim] == 25 and preview.front_rank[group_id(game)] == (nim,) + + def test_the_preview_greys_out_the_close_and_attack_the_last_place_takes(self): + game = _held(distance=30) + hild, osric, nim = _ids(game, "Hild", "Osric", "Nim") + _set_up_the_front(game) + + both_attack = preview_battle_round(game, [strike(game, hild, move="close"), strike(game, osric, move="close")]) + osric_closes = preview_battle_round(game, [strike(game, hild, move="close"), close(game, osric)]) + + assert both_attack.rejections[hild] == () + assert [(r.code, dict(r.params)) for r in both_attack.rejections[osric]] == [ + (NOT_IN_FRONT_RANK, {"character": osric}) + ] + assert osric_closes.rejections == {hild: (), osric: ()} + assert osric_closes.front_rank == {group_id(game): (hild, nim)} + + def test_the_order_of_the_plan_does_not_matter(self): + game = _held(distance=30) + hild, osric = _ids(game, "Hild", "Osric") + _set_up_the_front(game) + first, second = strike(game, hild, move="close"), strike(game, osric, move="close") + + assert preview_battle_round(game, [second, first]) == preview_battle_round(game, [first, second]) + + def test_a_defensive_move_is_the_formations_only_when_the_whole_plan_makes_it(self): + game = _held(distance=30) + hild = named(game, "Hild") + everyone = [declare(member.id, "move", move="fighting_withdrawal") for member in game.party.members] + + alone = preview_battle_round(game, everyone[:1]) + together = preview_battle_round(game, everyone) + + assert alone.advance_feet[hild] == -10 + assert set(together.advance_feet.values()) == {0}, "a formation move moves the groups, not the members" + + def test_the_preview_moves_the_party_line_back_as_the_round_does(self): + game = _held(distance=40) + hild = named(game, "Hild") + others = [member.id for member in game.party.members if member.id != hild] + play(game, *(declare(member_id, "move", move="retreat") for member_id in others)) + withdrawal = declare(hild, "move", move="fighting_withdrawal") + + preview = preview_battle_round(game, [withdrawal]) + play(game, withdrawal) + + assert preview.advance_feet[hild] == 0, "Hild's 10' back became the new line" + assert preview.advance_feet == advances(game) + + def test_a_bad_call_raises_and_changes_nothing(self): + game = _held(distance=30) + hild = named(game, "Hild") + before = state_without_logs(game) + + with pytest.raises(ValueError): + preview_battle_round(game, [declare("character-9999")]) + with pytest.raises(ValueError): + preview_battle_round(game, [declare(hild), close(game, hild)]) + assert state_without_logs(game) == before + + def test_a_session_with_no_battle_is_a_bad_call(self): + game = battle(engage=False) + with pytest.raises(ValueError): + preview_battle_round(game, []) + + +def test_the_preview_result_has_the_fields_a_battle_screen_reads(): + """A guard on the stub's shape, which passes before and after the chunk.""" + assert set(BattleRoundPreview.model_fields) == {"rejections", "advance_feet", "front_rank"} + assert BattleRoundPreview.model_config.get("frozen") is True diff --git a/tests/test_npc.py b/tests/test_npc.py index 32dc814..e579797 100644 --- a/tests/test_npc.py +++ b/tests/test_npc.py @@ -146,25 +146,27 @@ def test_defeated_npcs_record_level_as_hd_under_npc_ids(self): rounds = 0 while session.battle is not None and rounds < 30: group = session.encounter.groups[0] - declarations = [] living = session.party.living_members() + # The first two members attack. When the session refuses that round, because they can't reach the + # NPCs yet, everyone closes instead. A refused round costs nothing, so the fight is the same. + attacks = [] for index, member in enumerate(living): - if group.distance_feet > 5: - declarations.append( - BattleDeclaration( - character_id=member.id, action="move", move="close", target_group_id=group.id - ) - ) - elif index < 2: + if index < 2: weapon = member.inventory.wielded[0].template.id if member.inventory.wielded else None - declarations.append( + attacks.append( BattleDeclaration( character_id=member.id, action="attack", target_group_id=group.id, weapon_id=weapon ) ) else: - declarations.append(BattleDeclaration(character_id=member.id, action="hold")) - outcome = session.execute(ResolveBattleRound(declarations=tuple(declarations))) + attacks.append(BattleDeclaration(character_id=member.id, action="hold")) + outcome = session.execute(ResolveBattleRound(declarations=tuple(attacks))) + if not outcome.accepted: + closes = tuple( + BattleDeclaration(character_id=member.id, action="move", move="close", target_group_id=group.id) + for member in living + ) + outcome = session.execute(ResolveBattleRound(declarations=closes)) if not outcome.accepted: break rounds += 1 diff --git a/tests/test_phase4_goldens.py b/tests/test_phase4_goldens.py index 202e63d..6303ff5 100644 --- a/tests/test_phase4_goldens.py +++ b/tests/test_phase4_goldens.py @@ -32,6 +32,10 @@ GOLDEN_PATH = Path(__file__).parent / "goldens" / "phase4_delve.json" +# The rules for member positions on the range track (issue #209) change every battle in this golden that closes on a +# group, so the golden is regenerated once those chunks are built. Until then a mismatch is expected. +pytestmark = pytest.mark.xfail(reason="chunk: goldens", strict=False) + REGENERATE_HINT = ( "golden mismatch; if the change is intentional, regenerate with " "`uv run python tests/generate_phase4_goldens.py` and explain why in the commit message" diff --git a/tests/test_phase5_goldens.py b/tests/test_phase5_goldens.py index db8b700..a810b35 100644 --- a/tests/test_phase5_goldens.py +++ b/tests/test_phase5_goldens.py @@ -37,6 +37,10 @@ GOLDEN_DIR = Path(__file__).parent / "goldens" +# The rules for member positions on the range track (issue #209) change every battle in the milestone golden that +# closes on a group, so the golden is regenerated once those chunks are built. Until then a mismatch is expected. +GOLDENS = pytest.mark.xfail(reason="chunk: goldens", strict=False) + REGENERATE_HINT = ( "golden mismatch; if the change is intentional, regenerate with " "`uv run python tests/generate_phase5_goldens.py` and explain why in the commit message" @@ -95,6 +99,7 @@ def replayed(golden): return replay_milestone(golden["master_seed"], golden["command_log"]) +@GOLDENS class TestMilestoneScriptedRun: def test_party_document_matches(self, golden, scripted): _, party_document, _ = scripted @@ -146,6 +151,7 @@ def test_save_load_round_trips_the_listener_run(self, scripted): assert session_state(restored) == session_state(session) +@GOLDENS class TestMilestoneReplay: def test_event_stream_matches_byte_for_byte(self, golden, replayed): events = [entry if isinstance(entry, dict) else entry.model_dump(mode="json") for entry in replayed.event_log] @@ -238,6 +244,7 @@ def test_the_command_log_round_trips(self, golden): assert parse_command(entry) is not None +@GOLDENS class TestCheckpoints: @pytest.mark.parametrize("name", ("mid_delve", "first_return", "post_award")) def test_load_equals_replay_and_continues_identically(self, golden, replayed, name): diff --git a/tests/test_single_withdrawal.py b/tests/test_single_withdrawal.py index 657ad19..ebcf17f 100644 --- a/tests/test_single_withdrawal.py +++ b/tests/test_single_withdrawal.py @@ -1,4 +1,4 @@ -"""A fighting withdrawal or a retreat that one party member makes alone (issue #171). +"""A fighting withdrawal or a retreat that one party member makes alone (issues #171 and #209). The OSE SRD describes both defensive moves per combatant: "The combatant moves backwards at up to half their encounter movement rate. There must be clear space behind the combatant." and "The @@ -9,22 +9,26 @@ every other defensive move the declaring member's own. The contract, which the adaptations register states under "The battle round": -- A front-rank member who withdraws alone names a back-rank member in - `BattleDeclaration.replacement_id`. In the party's movement step the two trade places in the - marching order, and a `MemberWithdrewEvent` reports it. The party doesn't move, and the order - stays changed. -- Each rank stands 5' behind the one in front of it. The withdrawing member covers the distance at - up to half their encounter rate for a fighting withdrawal or their full encounter rate for a - retreat, the replacement at up to their full encounter rate, each doubled by their own *haste*. -- The refusals: `battle.declaration.not_in_front_rank` for a back-rank member, - `battle.declaration.no_replacement`, `battle.declaration.replacement_unavailable`, and - `battle.declaration.replacement_too_far`. +- A member who withdraws alone moves back on the range track by themselves, at half their + encounter rate for a fighting withdrawal or their full rate for a retreat, doubled by their own + *haste*. Their advance goes down, below the party line if need be, and a `MemberMovedEvent` + reports it. Any member can do it, wherever they stand, and needs nobody to step up. +- The member can name another member in `BattleDeclaration.replacement_id`. The two trade places + in the marching order right after the move, a `MemberWithdrewEvent` reports it, and the order + stays changed. The trade moves nobody. Marching order decides who stands in front among members + at the same distance who got there in the same round, so naming a member beside the others + brings that member to the front once the withdrawing member steps out. +- The named member has to be another declarer who isn't withdrawing too and isn't named twice, + or the round is refused with `battle.declaration.replacement_unavailable`. Their rank, their + rate, their cast, and whether they can move don't matter, because the trade isn't a move. - A retreat's penalties fall on each member who retreats, alone or with the formation, and last the whole round, whichever side acts first. -- The replacement may strike in melee the round they step up. A withdrawing member makes no attack. -- A swap that can't happen when the movement step comes doesn't happen and reports nothing. +- A member who can no longer move when the movement step comes doesn't fall back and doesn't trade. +- A withdrawing member makes no attack. -The classes at the end check that the formation moves stay as they were. +The classes marked `chunk: positions` fail until that chunk is built. The unmarked classes are +guards that pass before and after it, among them the classes at the end, which check that the +formation moves stay as they were. """ import pytest @@ -52,7 +56,7 @@ SessionMode, SpawnMonsters, ) -from osrlib.crawl.events import MemberWithdrewEvent +from osrlib.crawl.events import MemberMovedEvent, MemberWithdrewEvent from osrlib.crawl.session import GameSession from osrlib.data import load_classes, load_equipment, load_spells from osrlib.persistence import load_game, replay_game, save_game, session_state @@ -256,22 +260,41 @@ def choose(self, session, group, stream): ] -# ---------------------------------------------------------------------- the swap +# ---------------------------------------------------------------------- the move and the trade +POSITIONS = pytest.mark.xfail(reason="chunk: positions", strict=True) + +def _advance(session, member_id: str) -> int: + return session.battle.advance_feet.get(member_id, 0) + + +def _front(session) -> tuple[str, ...]: + """The members who can fight the first group hand to hand, as the session reports them.""" + return battle_module.group_front_rank(session, _group(session)) + + +def _moves(result) -> list[MemberMovedEvent]: + return [event for event in result.events if isinstance(event, MemberMovedEvent)] + + +@POSITIONS class TestAFightingWithdrawalAlone: - """One front-rank member falls back, a named back-rank member steps up, and the party stays put.""" + """One member falls back at half rate, the named member takes their marching place, and the party stays put.""" - def test_the_member_trades_places_with_the_named_replacement(self): + def test_the_member_falls_back_and_trades_places_with_the_named_member(self): session = _battle(distance=5) - assert _view(session).front_rank == (BRAKK, SABLE) - assert _ranks(session) == [[BRAKK, SABLE], [WYNN, ELARA]] + assert _front(session) == (BRAKK, SABLE) result = _round(session, _withdraw(BRAKK, WYNN)) assert result.accepted, _rejections(result) + assert _advance(session, BRAKK) == -20 assert _order(session) == [WYNN, SABLE, BRAKK, ELARA] - assert _view(session).front_rank == (WYNN, SABLE) + assert _front(session) == (WYNN, SABLE) + [move] = _moves(result) [swap] = _swaps(result) + assert (move.character_id, move.move, move.distance_feet) == (BRAKK, "fighting_withdrawal", 25) assert (swap.code, swap.character_id, swap.replacement_id) == ("battle.member.withdrew", BRAKK, WYNN) + assert _position(result, swap) == _position(result, move) + 1 assert swap.visibility is Visibility.PLAYER assert session.encounter.groups[0].distance_feet == 5 assert "battle.group.moved" not in [event.code for event in result.events] @@ -283,47 +306,48 @@ def test_a_withdrawal_needs_no_group_in_melee(self): result = _round(session, _withdraw(BRAKK, WYNN)) assert result.accepted, _rejections(result) assert _order(session) == [WYNN, SABLE, BRAKK, ELARA] - # The party never backs away: every distance the round reports is the goblins closing. - assert all(event.distance_feet < 40 for event in result.events if event.code == "battle.group.moved") + assert _advance(session, BRAKK) == -20 - def test_a_replacement_two_ranks_back_takes_the_front_place(self): + def test_a_member_two_ranks_back_takes_the_marching_place(self): session = _battle(roster=SIX, distance=5) - assert _ranks(session) == [[BRAKK, SABLE], [WYNN, ELARA], [GARR, HALE]] result = _round(session, _withdraw(BRAKK, GARR)) assert result.accepted, _rejections(result) assert _order(session) == [GARR, SABLE, WYNN, ELARA, BRAKK, HALE] - assert _view(session).front_rank == (GARR, SABLE) + assert _front(session) == (GARR, SABLE) + assert _advance(session, GARR) == 0, "the trade moves nobody" - def test_the_whole_front_rank_can_step_back_behind_the_second(self): + def test_the_whole_front_rank_can_fall_back_together(self): session = _battle(distance=5) result = _round(session, _withdraw(SABLE, ELARA), _withdraw(BRAKK, WYNN)) assert result.accepted, _rejections(result) assert _order(session) == [WYNN, ELARA, BRAKK, SABLE] - # One event per swap, in the marching order of the members who withdrew. + # One trade per withdrawal, in the marching order of the members who withdrew. assert [(swap.character_id, swap.replacement_id) for swap in _swaps(result)] == [(BRAKK, WYNN), (SABLE, ELARA)] + assert _front(session) == (WYNN, ELARA) - def test_a_withdrawal_shares_a_round_with_a_close_and_resolves_first(self): + def test_a_withdrawal_shares_a_round_with_a_close_and_comes_first(self): session = _battle(distance=40) close = BattleDeclaration(character_id=SABLE, action="move", move="close", target_group_id=_group(session)) result = _round(session, _withdraw(BRAKK, WYNN), close) assert result.accepted, _rejections(result) assert _order(session) == [WYNN, SABLE, BRAKK, ELARA] + assert [(move.character_id, move.move) for move in _moves(result)] == [ + (BRAKK, "fighting_withdrawal"), + (SABLE, "close"), + ] [swap] = _swaps(result) - at = _position(result, swap) - closing = result.events[at + 1] - assert closing.code == "battle.group.moved" - before = [event.distance_feet for event in result.events[:at] if event.code == "battle.group.moved"] - assert closing.distance_feet < (before[-1] if before else 40) + assert _position(result, swap) < _position(result, _moves(result)[1]) + assert (_advance(session, BRAKK), _advance(session, SABLE)) == (-20, 35) +@POSITIONS class TestARetreatAlone: - """A retreat alone trades places as a withdrawal does, and its penalties are the retreater's alone.""" - - def test_the_member_trades_places_and_the_battle_goes_on(self): + def test_the_member_retreats_alone_and_the_battle_goes_on(self): session = _battle(distance=5) result = _round(session, _withdraw(BRAKK, WYNN, move="retreat")) assert result.accepted, _rejections(result) assert _order(session) == [WYNN, SABLE, BRAKK, ELARA] + assert _advance(session, BRAKK) == -40 [swap] = _swaps(result) assert (swap.code, swap.character_id, swap.replacement_id) == ("battle.member.retreated", BRAKK, WYNN) codes = [event.code for event in result.events] @@ -332,6 +356,10 @@ def test_the_member_trades_places_and_the_battle_goes_on(self): assert session.encounter.pursuit is None assert session.encounter.groups[0].distance_feet == 5 + +class TestARetreatAloneIsExposed: + """Guards: the retreat's penalties and the melee it escapes, before and after the chunk.""" + def test_attackers_get_the_bonus_against_the_retreater_alone_when_they_strike_first(self): retreating = _battle(distance=5, count=4, seed=MONSTERS_FIRST_SEED, shield=True) holding = _battle(distance=5, count=4, seed=MONSTERS_FIRST_SEED, shield=True) @@ -378,83 +406,112 @@ def test_once_the_party_has_moved_melee_no_longer_reaches_the_member(self, move) assert {attack.modifier for attack in attacks} == held_modifiers -class TestTheMovementLimit: - """Each rank stands 5' behind the one in front, and both members have to cover the distance.""" - - def test_a_laden_member_withdraws_one_rank(self): - session = _battle(roster=SIX, distance=5, laden=(BRAKK,)) - result = _round(session, _withdraw(BRAKK, WYNN)) - assert result.accepted, _rejections(result) - assert _order(session)[:3] == [WYNN, SABLE, BRAKK] - - def test_a_laden_member_cannot_withdraw_two_ranks(self): - session = _battle(roster=SIX, distance=5, laden=(BRAKK,)) - before = _snapshot(session) - result = _round(session, _withdraw(BRAKK, GARR)) - assert _rejections(result) == [ - ( - "battle.declaration.replacement_too_far", - {"character": BRAKK, "replacement": GARR, "distance_feet": 10}, - ) - ] - _assert_refused_cleanly(session, result, before) +@POSITIONS +class TestHowFarAMemberFallsBack: + """A member falls back at half their encounter rate, or their full rate for a retreat, and names anyone.""" - def test_a_laden_member_retreats_two_ranks_at_the_full_rate(self): - session = _battle(roster=SIX, distance=5, laden=(BRAKK,)) - result = _round(session, _withdraw(BRAKK, GARR, move="retreat")) + @pytest.mark.parametrize( + ("move", "laden", "feet"), + [ + ("fighting_withdrawal", False, 20), + ("retreat", False, 40), + ("fighting_withdrawal", True, 5), + ("retreat", True, 10), + ], + ) + def test_the_member_falls_back_at_half_or_full_rate(self, move, laden, feet): + session = _battle(roster=SIX, distance=5, laden=(BRAKK,) if laden else ()) + result = _round(session, _withdraw(BRAKK, move=move)) assert result.accepted, _rejections(result) - assert _order(session)[0] == GARR - assert _order(session)[4] == BRAKK + assert _advance(session, BRAKK) == -feet - def test_haste_doubles_the_withdrawal_limit(self): + def test_haste_doubles_the_withdrawal(self): session = _battle(roster=SIX, distance=5, laden=(BRAKK,)) _attach( session, EffectDefinition(kind="haste", params={"attacks_multiplier": 2, "movement_multiplier": 2}), BRAKK ) - result = _round(session, _withdraw(BRAKK, GARR)) + result = _round(session, _withdraw(BRAKK)) assert result.accepted, _rejections(result) - assert _order(session)[0] == GARR + assert _advance(session, BRAKK) == -10 - def test_an_unladen_member_withdraws_three_ranks(self): - session = _battle(roster=EIGHT, distance=5) - assert _ranks(session)[3] == [IVER, JORY] + def test_a_laden_member_names_a_member_three_ranks_back(self): + session = _battle(roster=EIGHT, distance=5, laden=(BRAKK, IVER)) result = _round(session, _withdraw(BRAKK, IVER)) assert result.accepted, _rejections(result) assert _order(session)[0] == IVER assert _order(session)[6] == BRAKK - def test_a_laden_replacement_cannot_step_up_three_ranks(self): - session = _battle(roster=EIGHT, distance=5, laden=(IVER,)) - before = _snapshot(session) - result = _round(session, _withdraw(BRAKK, IVER)) - assert _rejections(result) == [ - ( - "battle.declaration.replacement_too_far", - {"character": BRAKK, "replacement": IVER, "distance_feet": 15}, - ) - ] - _assert_refused_cleanly(session, result, before) + +# ---------------------------------------------------------------------- the named member -class TestWhenNoOneCanStepUp: - """A member who withdraws alone needs a replacement who can take the front place this round.""" +@POSITIONS +class TestWhoCanBeNamed: + """Any other declarer who isn't falling back too, because the trade isn't a move.""" @pytest.mark.parametrize("move", ["fighting_withdrawal", "retreat"]) - def test_a_member_who_names_no_replacement_is_refused(self, move): + def test_a_member_who_names_nobody_falls_back_alone(self, move): session = _battle(distance=5) - before = _snapshot(session) result = _round(session, _withdraw(BRAKK, move=move)) - assert _rejections(result) == [("battle.declaration.no_replacement", {"character": BRAKK})] - _assert_refused_cleanly(session, result, before) + assert result.accepted, _rejections(result) + assert _swaps(result) == [] + assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] + assert _front(session) == (SABLE, WYNN) - def test_a_back_rank_member_cannot_withdraw_alone(self): + def test_a_member_behind_the_front_rank_can_fall_back_alone(self): session = _battle(distance=5) - assert WYNN not in _view(session).front_rank result = _round(session, _withdraw(WYNN, ELARA)) - assert _rejections(result) == [("battle.declaration.not_in_front_rank", {"character": WYNN})] + assert result.accepted, _rejections(result) + assert _order(session) == [BRAKK, SABLE, ELARA, WYNN] - @pytest.mark.parametrize("named", [SABLE, BRAKK, "character-9999"]) - def test_the_replacement_has_to_be_a_member_behind_the_front_rank(self, named): + def test_a_member_in_the_front_rank_can_be_named(self): + session = _battle(distance=5) + result = _round(session, _withdraw(BRAKK, SABLE)) + assert result.accepted, _rejections(result) + assert _order(session)[:2] == [SABLE, BRAKK] + + def test_a_member_who_cannot_move_can_be_named(self): + session = _battle(distance=5) + session.member(WYNN).conditions = (ActiveCondition(condition=Condition.ENTANGLED, effect_id="effect-9999"),) + assert WYNN in _view(session).immobile + result = _round(session, _withdraw(BRAKK, WYNN)) + assert result.accepted, _rejections(result) + assert _order(session)[0] == WYNN + + def test_a_member_who_casts_can_be_named(self): + session = _battle(distance=5) + elara = session.member(ELARA) + memorize_spells(elara, load_classes().get("magic_user"), load_spells(), [MemorizedSpell(spell_id="sleep")]) + cast = BattleDeclaration( + character_id=ELARA, action="cast", spell_id="sleep", spell_mode="hd_budget", target_group_id=_group(session) + ) + result = _round(session, _withdraw(BRAKK, ELARA), cast) + assert result.accepted, _rejections(result) + assert _order(session)[0] == ELARA + + def test_a_member_who_withdraws_too_cannot_be_named(self): + session = _battle(distance=5) + before = _snapshot(session) + result = _round(session, _withdraw(BRAKK, WYNN), _withdraw(WYNN, ELARA)) + assert _rejections(result) == [ + ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": WYNN}), + ] + _assert_refused_cleanly(session, result, before) + + def test_without_the_width_limit_a_member_falls_back_and_trades_the_same(self): + session = _battle(distance=5, ruleset=Ruleset(formation_width_limit=False)) + named = _round(session, _withdraw(BRAKK, WYNN)) + assert named.accepted, _rejections(named) + assert _order(session)[0] == WYNN + unnamed = _round(session, _withdraw(SABLE)) + assert unnamed.accepted, _rejections(unnamed) + + +class TestWhoCannotBeNamed: + """Guards: refusals that stand before and after the chunk, each costing nothing.""" + + @pytest.mark.parametrize("named", [BRAKK, "character-9999"]) + def test_the_named_member_has_to_be_another_member(self, named): session = _battle(distance=5) before = _snapshot(session) result = _round(session, _withdraw(BRAKK, named)) @@ -463,7 +520,7 @@ def test_the_replacement_has_to_be_a_member_behind_the_front_rank(self, named): ] _assert_refused_cleanly(session, result, before) - def test_a_dead_replacement_cannot_step_up(self): + def test_a_dead_member_cannot_be_named(self): session = _battle(distance=5) deal_damage(session.member(WYNN), 999, source=DamageSource()) assert has_condition(session.member(WYNN), Condition.DEAD) @@ -472,60 +529,24 @@ def test_a_dead_replacement_cannot_step_up(self): ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": WYNN}) ] - @pytest.mark.parametrize("condition", [Condition.ASLEEP, Condition.ENTANGLED]) - def test_a_replacement_who_cannot_act_or_move_cannot_step_up(self, condition): + def test_a_sleeping_member_cannot_be_named(self): session = _battle(distance=5) - session.member(WYNN).conditions = (ActiveCondition(condition=condition, effect_id="effect-9999"),) - view = _view(session) - assert (WYNN in view.declarers) is (condition is Condition.ENTANGLED) - assert (WYNN in view.immobile) is (condition is Condition.ENTANGLED) + session.member(WYNN).conditions = (ActiveCondition(condition=Condition.ASLEEP, effect_id="effect-9999"),) + assert WYNN not in _view(session).declarers result = _round(session, _withdraw(BRAKK, WYNN)) assert _rejections(result) == [ ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": WYNN}) ] - def test_a_replacement_who_casts_cannot_step_up(self): - session = _battle(distance=5) - elara = session.member(ELARA) - memorize_spells(elara, load_classes().get("magic_user"), load_spells(), [MemorizedSpell(spell_id="sleep")]) - cast = BattleDeclaration( - character_id=ELARA, action="cast", spell_id="sleep", spell_mode="hd_budget", target_group_id=_group(session) - ) - result = _round(session, _withdraw(BRAKK, ELARA), cast) - assert _rejections(result) == [ - ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": ELARA}) - ] - # The same cast with nobody naming Elara is a legal round, so the refusal is the step up's. - legal = _round(session, cast) - assert legal.accepted, _rejections(legal) - - def test_a_replacement_who_withdraws_too_cannot_step_up(self): - session = _battle(distance=5) - result = _round(session, _withdraw(BRAKK, WYNN), _withdraw(WYNN, ELARA)) - assert _rejections(result) == [ - ("battle.declaration.not_in_front_rank", {"character": WYNN}), - ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": WYNN}), - ] - - def test_two_members_cannot_name_one_replacement(self): + def test_two_members_cannot_name_one_member(self): session = _battle(distance=5) result = _round(session, _withdraw(BRAKK, WYNN), _withdraw(SABLE, WYNN)) - # The member earlier in marching order keeps the replacement. + # The member earlier in marching order keeps the named member. assert _rejections(result) == [ ("battle.declaration.replacement_unavailable", {"character": SABLE, "replacement": WYNN}) ] - def test_without_the_width_limit_nobody_stands_behind(self): - session = _battle(distance=5, ruleset=Ruleset(formation_width_limit=False)) - assert _view(session).front_rank == (BRAKK, SABLE, WYNN, ELARA) - named = _round(session, _withdraw(BRAKK, WYNN)) - assert _rejections(named) == [ - ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": WYNN}) - ] - unnamed = _round(session, _withdraw(BRAKK)) - assert _rejections(unnamed) == [("battle.declaration.no_replacement", {"character": BRAKK})] - - def test_a_formation_move_has_no_replacement_to_name(self): + def test_a_formation_move_has_nobody_to_name(self): session = _battle(distance=5) declarations = [_withdraw(member_id) for member_id in (SABLE, WYNN, ELARA)] result = _round(session, _withdraw(BRAKK, WYNN), *declarations) @@ -535,13 +556,13 @@ def test_a_formation_move_has_no_replacement_to_name(self): ] -# ---------------------------------------------------------------------- melee around the swap +# ---------------------------------------------------------------------- melee around the trade class TestSteppingUpIntoMelee: - """From the movement step on the replacement stands in the front rank, and the withdrawn member doesn't.""" + """Guards: from the movement step on, the named member stands in front, and the withdrawn member doesn't.""" - def test_the_replacement_may_strike_the_round_they_step_up(self): + def test_the_named_member_may_strike_the_round_they_step_up(self): session = _battle(distance=5) assert WYNN not in _view(session).front_rank strike = BattleDeclaration(character_id=WYNN, action="attack", target_group_id=_group(session)) @@ -552,7 +573,7 @@ def test_the_replacement_may_strike_the_round_they_step_up(self): assert wynn_attacks assert all(_position(result, attack) > _position(result, swap) for attack in wynn_attacks) - def test_a_back_rank_member_who_does_not_step_up_still_cannot_strike(self): + def test_a_member_left_behind_the_front_rank_still_cannot_strike(self): session = _battle(distance=5) strike = BattleDeclaration(character_id=ELARA, action="attack", target_group_id=_group(session)) result = _round(session, _withdraw(BRAKK, WYNN), strike) @@ -567,9 +588,10 @@ def test_a_member_paralysed_before_the_movement_step_stays_in_front(self): assert _monsters_acted_first(session, result) assert has_condition(session.member(BRAKK), Condition.PARALYSED) assert not has_condition(session.member(WYNN), Condition.PARALYSED) + # Brakk can't fall back, so there's no trade either, and Wynn stays behind the front rank. assert _swaps(result) == [] assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] - # Wynn never stepped up, so the melee attack Wynn declared doesn't resolve. + # Wynn never reached the front rank, so the melee attack Wynn declared doesn't resolve. assert WYNN not in {attack.attacker_id for attack in _attacks(result)} def test_a_strike_chosen_against_a_withdrawn_member_is_skipped(self): @@ -586,60 +608,28 @@ def test_a_strike_chosen_against_a_withdrawn_member_is_skipped(self): assert BRAKK not in {attack.defender_id for attack in _attacks(result)} -class TestAReplacementWhoCannotActWhenTheMomentComes: - """The swap is judged again in the movement step. A replacement who can no longer act on the party's - behalf by then doesn't step up: one who is incapacitated, charmed, or confused. The two stay where - they are, nothing reports the swap, and the replacement's melee attack doesn't resolve, because the - replacement never reached the front rank. - - The condition lands between acceptance and the movement step through a wrapper around the battle - module's `_swap_ranks`, which the party block calls to start its movement step. The confusion is a - bare condition with no ledger effect behind it, so the behavior roll that would override a confused - member's declaration never runs, and the outcome takes no draw. +@POSITIONS +class TestTheTradeIsNotAMove: + """A named member who becomes paralysed after the round was accepted still trades places, because the trade + moves nobody. The paralysis lands as initiative is rolled, through a wrapper around the battle module's + `roll_initiative`, which the round calls once, after it accepts the declarations and before either side acts. """ - @staticmethod - def _afflict_before_the_move(monkeypatch, member_id: str, condition: Condition) -> list[bool]: - original = battle_module._swap_ranks - ran: list[bool] = [] - - def afflicted(session, swaps): - member = session.member(member_id) - member.conditions = (*member.conditions, ActiveCondition(condition=condition, effect_id="effect-9999")) - ran.append(True) - return original(session, swaps) + def test_a_named_member_paralysed_after_the_round_was_accepted_still_trades(self, monkeypatch): + original = battle_module.roll_initiative - monkeypatch.setattr(battle_module, "_swap_ranks", afflicted) - return ran + def paralysis_then_initiative(*args, **kwargs): + wynn = session.member(WYNN) + wynn.conditions = (*wynn.conditions, ActiveCondition(condition=Condition.PARALYSED, effect_id=None)) + return original(*args, **kwargs) - def _round_with_a_late_condition(self, monkeypatch, condition: Condition): - session = _battle(distance=5) - # Preconditions: Wynn stands behind the front rank, free to act, when the round is declared. - assert WYNN in _view(session).declarers - assert WYNN not in _view(session).front_rank - assert not has_condition(session.member(WYNN), condition) - ran = self._afflict_before_the_move(monkeypatch, WYNN, condition) - strike = BattleDeclaration(character_id=WYNN, action="attack", target_group_id=_group(session)) - result = _round(session, _withdraw(BRAKK, WYNN), strike) + session = _battle(distance=40) + monkeypatch.setattr(battle_module, "roll_initiative", paralysis_then_initiative) + result = _round(session, _withdraw(BRAKK, WYNN)) assert result.accepted, _rejections(result) - # Preconditions: the condition landed in the movement step, and Wynn still bears it. - assert ran == [True] - assert has_condition(session.member(WYNN), condition) - return session, result - - @pytest.mark.parametrize("condition", [Condition.CHARMED, Condition.CONFUSED]) - def test_a_charmed_or_confused_replacement_does_not_step_up(self, monkeypatch, condition): - session, result = self._round_with_a_late_condition(monkeypatch, condition) - assert _swaps(result) == [] - assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] - assert WYNN not in {attack.attacker_id for attack in _attacks(result)} - - def test_a_paralysed_replacement_does_not_step_up(self, monkeypatch): - """The re-check also holds for a replacement who can't move.""" - session, result = self._round_with_a_late_condition(monkeypatch, Condition.PARALYSED) - assert _swaps(result) == [] - assert _order(session) == [BRAKK, SABLE, WYNN, ELARA] - assert WYNN not in {attack.attacker_id for attack in _attacks(result)} + assert has_condition(session.member(WYNN), Condition.PARALYSED), "precondition: Wynn was paralysed mid-round" + assert [(swap.character_id, swap.replacement_id) for swap in _swaps(result)] == [(BRAKK, WYNN)] + assert _order(session) == [WYNN, SABLE, BRAKK, ELARA] class TestNoAttackWhileWithdrawing: @@ -667,7 +657,7 @@ def test_a_member_who_withdraws_alone_does_not_shoot(self, move): # ---------------------------------------------------------------------- views -class TestTheViewsAfterTheSwap: +class TestTheViewsAfterTheTrade: def test_the_player_view_shows_the_new_ranks(self): session = _battle(distance=5) result = _round(session, _withdraw(BRAKK, WYNN)) @@ -720,9 +710,9 @@ def test_a_refused_withdrawal_costs_nothing(self): session = _battle(distance=5) before = _snapshot(session) distance = session.encounter.groups[0].distance_feet - result = _round(session, _withdraw(BRAKK, SABLE)) + result = _round(session, _withdraw(BRAKK, BRAKK)) assert _rejections(result) == [ - ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": SABLE}) + ("battle.declaration.replacement_unavailable", {"character": BRAKK, "replacement": BRAKK}) ] _assert_refused_cleanly(session, result, before) assert session.encounter.groups[0].distance_feet == distance diff --git a/tests/test_turning_and_staff.py b/tests/test_turning_and_staff.py index 5542866..fd51ffd 100644 --- a/tests/test_turning_and_staff.py +++ b/tests/test_turning_and_staff.py @@ -465,22 +465,23 @@ def strikes_by(events, member_id: str) -> list[AttackRolledEvent]: return [event for event in events if isinstance(event, AttackRolledEvent) and event.attacker_id == member_id] -def paralyse_the_fighter_before_the_swap(monkeypatch) -> None: - """Paralyse the fighter at the start of the party's movement step, just before the swaps run. +def paralyse_the_fighter_before_the_move(monkeypatch, playing: list[GameSession]) -> None: + """Paralyse the fighter as initiative is rolled, after the round was accepted and before either side acts. - A paralysed member can't move, so a withdrawal the fighter declared doesn't happen, and the replacement it - named stays in the back rank. A ghoul's touch does this when the monsters act first. The test forces it so the - round doesn't depend on the ghoul's dice. + A paralysed member can't move, so a withdrawal the fighter declared doesn't happen, and neither does the trade + with the member it named, who stays in the back rank. A ghoul's touch does this when the monsters act first. The + test forces it through a wrapper around the battle module's `roll_initiative`, which the round calls once, so the + round doesn't depend on the ghoul's dice. The wrapper paralyses the fighter of the last session in `playing`. """ - original = battle_module._swap_ranks + original = battle_module.roll_initiative - def paralysis_then_swaps(session, swaps): - fighter = session.member(FIGHTER) + def paralysis_then_initiative(*args, **kwargs): + fighter = playing[-1].member(FIGHTER) if not has_condition(fighter, Condition.PARALYSED): fighter.conditions = (*fighter.conditions, ActiveCondition(condition=Condition.PARALYSED, effect_id=None)) - return original(session, swaps) + return original(*args, **kwargs) - monkeypatch.setattr(battle_module, "_swap_ranks", paralysis_then_swaps) + monkeypatch.setattr(battle_module, "roll_initiative", paralysis_then_initiative) class TestTheStaffOfStrikingIsAMeleeWeapon: @@ -510,11 +511,13 @@ def test_a_member_behind_the_front_rank_is_refused(self, member_id): assert stream_states(session) == streams assert session.battle.round == 0 - def test_a_replacement_whose_swap_did_not_happen_fizzles_in_the_magic_phase(self, monkeypatch): - paralyse_the_fighter_before_the_swap(monkeypatch) + def test_a_named_member_whose_trade_did_not_happen_fizzles_in_the_magic_phase(self, monkeypatch): + playing: list[GameSession] = [] + paralyse_the_fighter_before_the_move(monkeypatch, playing) checked = [] for seed in SCAN_SEEDS: session, group, staff = staff_battle(seed) + playing.append(session) assert CLERIC not in front_rank(session), "precondition: the cleric starts in the back rank" withdraw = BattleDeclaration( character_id=FIGHTER, action="move", move="fighting_withdrawal", replacement_id=CLERIC @@ -523,7 +526,7 @@ def test_a_replacement_whose_swap_did_not_happen_fizzles_in_the_magic_phase(self ResolveBattleRound(declarations=declarations(session, withdraw, strike(CLERIC, group, staff))) ) assert result.accepted, [rejection.code for rejection in result.rejections] - assert not any(isinstance(event, MemberWithdrewEvent) for event in result.events), "the swap happened" + assert not any(isinstance(event, MemberWithdrewEvent) for event in result.events), "the trade happened" party_ids = {member.id for member in session.party.members} if any(isinstance(event, DeathEvent) and event.target_id in party_ids for event in result.events): continue # a member's death moves the ranks up, so this round can't show the rank the staff met diff --git a/tests/test_weapon_default_and_device_recheck.py b/tests/test_weapon_default_and_device_recheck.py index 7598a4c..34bbdb1 100644 --- a/tests/test_weapon_default_and_device_recheck.py +++ b/tests/test_weapon_default_and_device_recheck.py @@ -107,6 +107,25 @@ def _light_and_enter(session: GameSession) -> None: pytest.fail("the torch never lit") +def armed_battle_or_none( + wielded: tuple[str, ...], *, distance: int, seed: int, carried: tuple[str, ...] = () +) -> GameSession | None: + """The battle `armed_battle` builds, or None when the seed gives the goblins a free round that moves them.""" + session = GameSession.new(build_party(), build_adventure(wandering_chance=0), seed=seed) + for item_id in wielded: + assert session.execute(GrantItem(character_id=FIGHTER, item_id=item_id)).accepted + assert session.execute(EquipItem(character_id=FIGHTER, item_id=item_id)).accepted + for item_id in carried: + assert session.execute(GrantItem(character_id=FIGHTER, item_id=item_id)).accepted + _light_and_enter(session) + session.execute(SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=distance)) + if session.mode is SessionMode.ENCOUNTER: + session.execute(EngageBattle()) + if session.mode is not SessionMode.BATTLE or session.encounter.groups[0].distance_feet != distance: + return None + return session + + def armed_battle( wielded: tuple[str, ...], *, distance: int, seed: int = WEAPON_SEED, carried: tuple[str, ...] = () ) -> GameSession: @@ -251,33 +270,38 @@ def test_leaving_the_weapon_out_is_the_round_that_names_the_suitable_weapon( assert outcome(default, default_result) == outcome(named, named_result) def test_the_weapon_is_chosen_when_the_round_is_accepted(self): - """At 20 feet the bow suits the attack. The thief's `close` brings the party to 5 feet before the missile - phase, where the sword would suit, but the bow chosen at acceptance is the one that fires.""" - - def play(weapon_id): - session = armed_battle(("short_bow", "sword"), distance=20) - close = BattleDeclaration( - character_id=THIEF, action="move", move="close", target_group_id=session.encounter.groups[0].id - ) - result = session.execute( - ResolveBattleRound(declarations=declarations(session, attack(session, weapon_id), close)) - ) - return session, result - - named, named_result = play("short_bow") - assert named_result.accepted, [rejection.code for rejection in named_result.rejections] - moves = [event for event in named_result.events if event.code == "battle.group.moved"] - first_shot = next( - index - for index, event in enumerate(named_result.events) - if isinstance(event, AttackRolledEvent) and event.attacker_id == FIGHTER - ) - assert moves and named_result.events.index(moves[0]) < first_shot - assert moves[0].distance_feet == MELEE_RANGE_FEET, "the party closed to melee range before the shot" - assert set(attack_names(named_result.events)) == {"Short bow"} + """At 20 feet the bow suits the attack. When the goblins win initiative, they close to 5 feet before the + party's missile phase, where the sword would suit, but the bow chosen at acceptance is the one that fires.""" + + def play(seed, weapon_id): + session = armed_battle_or_none(("short_bow", "sword"), distance=20, seed=seed) + if session is None: + return None, None, None + group_id = session.encounter.groups[0].id + result = session.execute(ResolveBattleRound(declarations=declarations(session, attack(session, weapon_id)))) + return session, result, group_id - default, default_result = play(None) - assert outcome(default, default_result) == outcome(named, named_result) + for seed in SCAN_SEEDS: + named, named_result, group_id = play(seed, "short_bow") + if named is None: + continue + assert named_result.accepted, [rejection.code for rejection in named_result.rejections] + shots = [ + index + for index, event in enumerate(named_result.events) + if isinstance(event, AttackRolledEvent) and event.attacker_id == FIGHTER + ] + if not monsters_first(named_result, group_id) or not shots: + continue + moves = [event for event in named_result.events if event.code == "battle.group.moved"] + assert moves and named_result.events.index(moves[0]) < shots[0] + assert moves[0].distance_feet == MELEE_RANGE_FEET, "the goblins closed to melee range before the shot" + assert set(attack_names(named_result.events)) == {"Short bow"} + + default, default_result, _ = play(seed, None) + assert outcome(default, default_result) == outcome(named, named_result) + return + pytest.fail("no seed let the goblins close before the fighter shot") def test_a_wand_held_before_the_sword_is_passed_over(self): def play(weapon_id): @@ -619,23 +643,22 @@ def play(seed): def weaken_the_cleric_after_party_movement(monkeypatch) -> None: - """Weaken the cleric partway through the round, after the round was accepted and before the magic phase. + """Weaken the cleric partway through the round, after the round was accepted and before the turning resolves. Nothing in the rules weakens a cleric in the middle of a battle round today, so the test forces it. It wraps - the party block's movement step, which runs before the party's missile and magic phases. Weakened is one of - the conditions under which `validate_turn_undead` refuses a turning, and it isn't one of the conditions that - make the magic phase skip a member outright. + the party block's magic phase, which runs after the party's movement and missile steps and re-checks the turning + before it resolves. Weakened is one of the conditions under which `validate_turn_undead` refuses a turning, and + it isn't one of the conditions that make the magic phase skip a member outright. """ - original = battle_module._party_movement + original = battle_module._party_magic - def movement_then_weakness(session, by_member): - events = original(session, by_member) + def weakness_then_magic(session, *args, **kwargs): cleric = session.member(CLERIC) if not has_condition(cleric, Condition.WEAKENED): cleric.conditions = (*cleric.conditions, ActiveCondition(condition=Condition.WEAKENED, effect_id=None)) - return events + return original(session, *args, **kwargs) - monkeypatch.setattr(battle_module, "_party_movement", movement_then_weakness) + monkeypatch.setattr(battle_module, "_party_magic", weakness_then_magic) def skeleton_battle(seed: int) -> GameSession: