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
705 changes: 470 additions & 235 deletions src/osrlib/crawl/battle.py

Large diffs are not rendered by default.

5 changes: 0 additions & 5 deletions tests/test_battle.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,6 @@ def test_declaring_an_item_nobody_carries_rejects_the_same_way(self):


class TestRangeTrack:
@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)
Expand Down Expand Up @@ -1166,7 +1165,6 @@ class TestAFormationMoveNeedsEveryDeclarer:
always the declaring member's own, so a lone `close` moves that member and nobody else.
"""

@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)
Expand All @@ -1182,7 +1180,6 @@ def test_a_lone_defensive_move_moves_the_member_alone(self, move, feet):
)
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(
Expand All @@ -1194,7 +1191,6 @@ def test_a_majority_does_not_carry_the_minority(self):
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]

@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")
Expand All @@ -1215,7 +1211,6 @@ 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))

@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(
Expand Down
13 changes: 12 additions & 1 deletion tests/test_crawl_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,18 @@ def test_the_player_view_never_leaks(seed, commands):
# Monster internals: no HP fields beyond the party's own.
if parsed.get("encounter"):
for group in parsed["encounter"]["groups"]:
assert set(group) == {"id", "label", "count", "distance_feet", "visible_conditions", "fled"}
assert set(group) == {
"id",
"label",
"count",
"distance_feet",
"visible_conditions",
"fled",
"member_distance_feet",
"front_rank",
"lit",
"seen_by",
}
# Referee-visibility outcomes never appear (views carry no events at all).
assert "exploration.detection.rolled" not in blob
# Gate wiring is content wiring, and content wiring is the game's secret: the
Expand Down
23 changes: 8 additions & 15 deletions tests/test_member_positions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Acceptance tests for each party member's own place on the range track (issue #209, chunk: positions).
"""Acceptance tests for each party member's own place on the range track (issue #209).

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
Expand All @@ -20,8 +20,7 @@
- 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.
own preconditions.
"""

import sys
Expand Down Expand Up @@ -67,8 +66,6 @@

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."""
Expand Down Expand Up @@ -139,7 +136,6 @@ def test_the_new_names_are_public(self):
# ---------------------------------------------------------------------- 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)
Expand Down Expand Up @@ -262,7 +258,6 @@ def test_the_helpers_refuse_a_session_with_no_battle(self):
# ---------------------------------------------------------------------- falling back alone


@POSITIONS
class TestFallingBackAlone:
def test_a_fighting_withdrawal_alone_moves_the_member_back_at_half_rate(self):
game = _held(distance=40)
Expand Down Expand Up @@ -342,7 +337,6 @@ def test_a_move_back_reports_the_nearest_group(self):
# ---------------------------------------------------------------------- the marching-order trade


@POSITIONS
class TestTheTrade:
def test_the_named_member_trades_places_in_the_marching_order(self):
game = _held(distance=5)
Expand Down Expand Up @@ -457,7 +451,6 @@ def test_two_members_cannot_name_one_member(self):
# ---------------------------------------------------------------------- the ranks toward a group


@POSITIONS
class TestTheRanksTowardAGroup:
def test_the_front_rank_is_the_members_within_five_feet(self):
game = _held(distance=30)
Expand Down Expand Up @@ -518,12 +511,16 @@ def test_a_front_row_member_out_of_reach_is_refused_at_their_own_distance(self):
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."""
"""A row never mixes distances: Nim at 5' is the whole front row, and Hild at 30' is in the next one.

Hild is also out of reach, and reach is checked first, so the refusal names the distance.
"""
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})]
assert group_front_rank(game, group_id(game)) == (nim,)
assert refused(game, strike(game, hild)) == [("combat.attack.out_of_reach", {"distance_feet": 30})]
events = play(game, strike(game, nim))

assert any(getattr(event, "attacker_id", None) == nim for event in events)
Expand All @@ -532,7 +529,6 @@ def test_the_member_alone_in_front_strikes_and_the_row_behind_waits(self):
# ---------------------------------------------------------------------- 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
Expand Down Expand Up @@ -604,7 +600,6 @@ def test_an_invisible_member_on_the_line_does_not_hold_it(self):
# ---------------------------------------------------------------------- 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)
Expand Down Expand Up @@ -677,7 +672,6 @@ def test_positions_end_with_the_battle(self):
# ---------------------------------------------------------------------- determinism


@POSITIONS
class TestDeterminism:
def test_a_refused_round_with_a_close_in_it_costs_nothing(self):
game = _held(distance=30)
Expand Down Expand Up @@ -723,7 +717,6 @@ def test_a_replay_rebuilds_the_positions(self):
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()
Expand Down
11 changes: 1 addition & 10 deletions tests/test_single_withdrawal.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@
- 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 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.
The classes at the end check that the formation moves stay as they were.
"""

import pytest
Expand Down Expand Up @@ -262,8 +260,6 @@ def choose(self, session, group, stream):

# ---------------------------------------------------------------------- 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)
Expand All @@ -278,7 +274,6 @@ def _moves(result) -> list[MemberMovedEvent]:
return [event for event in result.events if isinstance(event, MemberMovedEvent)]


@POSITIONS
class TestAFightingWithdrawalAlone:
"""One member falls back at half rate, the named member takes their marching place, and the party stays put."""

Expand Down Expand Up @@ -340,7 +335,6 @@ def test_a_withdrawal_shares_a_round_with_a_close_and_comes_first(self):
assert (_advance(session, BRAKK), _advance(session, SABLE)) == (-20, 35)


@POSITIONS
class TestARetreatAlone:
def test_the_member_retreats_alone_and_the_battle_goes_on(self):
session = _battle(distance=5)
Expand Down Expand Up @@ -406,7 +400,6 @@ def test_once_the_party_has_moved_melee_no_longer_reaches_the_member(self, move)
assert {attack.modifier for attack in attacks} == held_modifiers


@POSITIONS
class TestHowFarAMemberFallsBack:
"""A member falls back at half their encounter rate, or their full rate for a retreat, and names anyone."""

Expand Down Expand Up @@ -445,7 +438,6 @@ def test_a_laden_member_names_a_member_three_ranks_back(self):
# ---------------------------------------------------------------------- the named member


@POSITIONS
class TestWhoCanBeNamed:
"""Any other declarer who isn't falling back too, because the trade isn't a move."""

Expand Down Expand Up @@ -608,7 +600,6 @@ def test_a_strike_chosen_against_a_withdrawn_member_is_skipped(self):
assert BRAKK not in {attack.defender_id for attack in _attacks(result)}


@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
Expand Down
6 changes: 2 additions & 4 deletions tools/docs/rejection_codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@
"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.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_replacement": "A front-rank character declares a fighting withdrawal or a retreat that not every character declaring that round makes, and names no member to step up into their place. A character who withdraws alone trades places with a member from a back rank, because a rank has no empty places.",
"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 who isn't in the party's front rank declares a melee attack, or a fighting withdrawal or a retreat that not every character declaring that round makes. The formation-width rule in effect this round means only front-rank members are within melee range, and only they can step back out of it. A member who steps up into the front rank this round as another character's replacement may declare a melee attack.",
"battle.declaration.replacement_too_far": "The member a character names to step up stands too many ranks back. Each rank stands 5 feet behind the one in front of it. The withdrawing character covers that distance at up to half their encounter movement rate for a fighting withdrawal, or their full rate for a retreat, and the replacement covers it at up to their full encounter rate. Haste doubles either limit for the member who has it.",
"battle.declaration.replacement_unavailable": "The member a character names to step up for their fighting withdrawal or retreat can't take the front place this round. The member isn't a living member behind the front rank, can't act or move, is charmed or confused, declared a spell or a defensive move of their own, or was already named by a withdrawing character earlier in marching order. A declaration that names a replacement in a fighting withdrawal or retreat the whole party makes gets this code too, because every member is moving with the party.",
"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.",
"battle.declaration.replacement_unavailable": "A character who makes a fighting withdrawal or a retreat alone names a member to trade places with in the marching order, and the named member can't be named. The named member isn't another character declaring this round, makes a fighting withdrawal or a retreat of their own, or was already named by a character earlier in marching order. A declaration that names a member in a fighting withdrawal or a retreat the whole party makes gets this code too, because every member is moving with the party.",
"battle.declaration.roster_mismatch": "The set of characters submitting declarations for the round doesn't exactly match the party members who are currently able to act and are expected to declare.",
"battle.declaration.unknown_action": "The declaration's action type isn't one the engine recognizes as a legal move for the round (such as hold, move, attack, use_item, turn_undead, or cast).",
"battle.declaration.unknown_group": "The declaration references a target group that doesn't exist in the current encounter, or that has already fled.",
Expand Down
Loading