Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
563 changes: 414 additions & 149 deletions src/osrlib/crawl/battle.py

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions src/osrlib/crawl/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -3096,9 +3096,8 @@ class ResolveBattleRound(Command):
anyone else. The session refuses any other set of members
with `battle.declaration.roster_mismatch`. If a single declaration is bad, the session
refuses the whole command, not only that member's part of the round. The order of the declarations doesn't change
who acts first, because initiative and the order of the round's phases do. When members
declare `close` on different groups, though, the party closes on the group of the first
`close` in this tuple."""
who acts first, because initiative and the order of the round's phases do. It doesn't change
where anyone moves either, because each member's move is their own."""


class GrantItem(Command):
Expand Down
1 change: 0 additions & 1 deletion tests/test_back_stab.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,6 @@ 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).

Expand Down
5 changes: 1 addition & 4 deletions tests/test_docs_example_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,7 @@ 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] = {
# 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",
}
_EXPECTED_TO_FAIL: dict[str, str] = {}


def _cases() -> list:
Expand Down
5 changes: 1 addition & 4 deletions tests/test_docs_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,7 @@


# 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] = {
# 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",
}
_EXPECTED_TO_FAIL: dict[str, str] = {}


def _cases() -> list:
Expand Down
3 changes: 0 additions & 3 deletions tests/test_docs_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,6 @@ def test_documented_modes_equal_allowed_modes(self):
wrong.append(f"{cls.__name__}: documents {sorted(documented)}, allows {sorted(actual)}")
assert not wrong, "\n".join(wrong)

# `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():
Expand Down
12 changes: 2 additions & 10 deletions tests/test_move_and_attack.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"""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):
"The spell caster cannot move and cast a spell in the same round." The contract these tests check:

- 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
Expand All @@ -21,7 +20,7 @@
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.
round is checked to cost nothing.
"""

import pytest
Expand Down Expand Up @@ -54,8 +53,6 @@
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"
Expand Down Expand Up @@ -93,7 +90,6 @@ def _set_up_the_front(game):
# ---------------------------------------------------------------------- close and attack


@MOVE_AND_ATTACK
class TestCloseAndAttack:
def test_a_member_closes_and_strikes_in_one_round(self):
game = _held(distance=30)
Expand Down Expand Up @@ -169,7 +165,6 @@ def test_a_dagger_is_thrown_without_a_close(self):
# ---------------------------------------------------------------------- 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."""
Expand Down Expand Up @@ -207,7 +202,6 @@ def test_a_member_falling_back_alone_opens_a_place_the_same_round(self):
# ---------------------------------------------------------------------- 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):
Expand Down Expand Up @@ -271,7 +265,6 @@ def _melee_fizzles(events, actor_id: str) -> list[DeclarationFizzledEvent]:
]


@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.

Expand Down Expand Up @@ -366,7 +359,6 @@ 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)
Expand Down
4 changes: 3 additions & 1 deletion tools/docs/rejection_codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
"battle.declaration.back_stab_not_attack": "A character declares a back-stab on an action other than an attack. A back-stab is a way to make an attack, so the engine refuses it before it checks the action itself.",
"battle.declaration.back_stab_not_melee": "A character declares a back-stab that would be a missile attack, with a missile weapon like a bow, or with a weapon like a dagger aimed at a group farther away than 5 feet. A back-stab is a strike from behind, so it has to be a melee attack.",
"battle.declaration.back_stab_target_aware": "A character declares a back-stab in a round when the monsters can act. A back-stab needs an unaware opponent, which the engine reads as a monster the party surprised, so it's only possible in the free round the party gets by surprising the monsters.",
"battle.declaration.cannot_move": "A character declares a move while dead, paralyzed, petrified, asleep, or entangled (such as by a web spell), any of which makes movement impossible this round.",
"battle.declaration.cannot_move": "A character declares a move, alone or as the close before an attack, while dead, paralyzed, petrified, asleep, or entangled (such as by a web spell), any of which makes movement impossible this round.",
"battle.declaration.cast_cannot_move": "A character declares a spell and sets a move in the same declaration. The OSE SRD says \"The spell caster cannot move and cast a spell in the same round\", so a cast can't set any move.",
"battle.declaration.invisible_target": "A spell or item-use declaration names a specific target that is currently invisible, and the engine won't let the caster target something they can't see.",
"battle.declaration.item_unusable": "The declared item has no usable combat effect: it isn't a weapon-capable item in the character's inventory, or it's a magic item category (other than a potion, scroll, rod, staff, or wand) that has no defined combat use.",
"battle.declaration.missing_move": "A move action is declared without specifying which kind of movement (closing distance, retreating, or a fighting withdrawal) the character is taking.",
"battle.declaration.missing_spell": "A cast action is declared without specifying which spell to cast or which mode of that spell to use.",
"battle.declaration.move_not_combinable": "A character sets a move on an action that can't take one. Only an attack takes a move, and only a close, so the character closes on the group and attacks it in the same round. A turn undead, an item use, or a hold can't set a move. An attack can't set a fighting withdrawal or a retreat, because a character who falls back makes no attack that round.",
"battle.declaration.no_mirror": "A character declares the mirror gaze defense, fighting by watching a petrifying gazer in a mirror, without carrying a mirror from the equipment catalog.",
"battle.declaration.no_target": "The chosen enemy group has no living, visible monster in its front rank that can currently be targeted.",
"battle.declaration.not_in_front_rank": "A character declares a melee attack, or a strike with a staff of striking, on a group they stand within 5 feet of, but they aren't in the party's front rank toward that group. The ranks toward a group are the living members sorted by their distance to it and cut into rows as wide as the space the party stands in allows. Members at the same distance stand in the order they got there, then in marching order, so the front row can be full before this character's turn comes.",
Expand Down
Loading