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
22 changes: 12 additions & 10 deletions packages/domain/src/sagasmith_dnd/chase_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
)
from sagasmith_dnd.conditions import apply_condition_change, condition_ids
from sagasmith_dnd.editions import DEFAULT_CHARACTER_EDITION, normalize_dnd_edition
from sagasmith_dnd.engine import roll, roll_d20
from sagasmith_dnd.engine import resolve_attack, roll
from sagasmith_dnd.rule_engine import ResolutionContext

CHASE_BOUNDARY_IDS = (
Expand Down Expand Up @@ -421,24 +421,26 @@ def _guard_attack(
) -> tuple[dict[str, Any], dict[str, Any]]:
if moved_ft < 20:
return sheet, {"triggered": False, "reason": "moved less than 20 feet"}
attack = roll_d20(rng=rng)
attack_total = int(attack["natural"]) + 3
derived_ac = actor_derived(actor).get("armor_class")
ac = 10 if derived_ac is None else int(derived_ac)
hit = attack_total >= ac
attack = resolve_attack(armor_class=ac, attack_bonus=3, rng=rng)
result: dict[str, Any] = {
"triggered": True,
"attack_roll": attack,
"attack_bonus": 3,
"total": attack_total,
"attack_roll": {
key: value
for key, value in attack.items()
if key not in {"kind", "armor_class", "attack_bonus", "total", "hit"}
},
"attack_bonus": attack["attack_bonus"],
"total": attack["total"],
"target_ac": ac,
"hit": hit,
"hit": attack["hit"],
"damage": None,
}
if hit:
if attack["hit"]:
sheet, result["damage"] = _apply_chase_damage(
sheet,
expression="1d6+1",
expression="2d6+1" if attack["critical"] else "1d6+1",
damage_type="piercing",
source="urban chase complication: overzealous guard spear",
death_saves=death_saves,
Expand Down
89 changes: 74 additions & 15 deletions packages/domain/tests/test_chase_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,28 @@ def _give_magic_resistance(actor: dict) -> None:
actor["derived"] = derive_character_sheet(actor["sheet"])


def _guard_chase(*, armor_class: int = 10) -> tuple[dict, dict]:
pursuer = _actor("pursuer", initiative=20)
quarry = _actor("quarry", initiative=10)
quarry["sheet"]["combat"]["ac"] = {
"base": armor_class,
"override": armor_class,
}
quarry["derived"] = derive_character_sheet(quarry["sheet"])
chase = start_chase(
[pursuer, quarry],
quarry_ids=["quarry"],
initial_distance_ft=100,
)
chase["turn_index"] = 1
chase["pending_complication"] = {
"number": 9,
"source_actor_id": "pursuer",
"rolled_round": 1,
}
return chase, quarry


def test_module_close_transition_ends_chase() -> None:
pursuer = _actor("pursuer", initiative=20)
quarry = _actor("quarry", initiative=10)
Expand Down Expand Up @@ -207,21 +229,7 @@ def test_urban_complication_affects_next_participant() -> None:


def test_urban_guard_attack_preserves_zero_armor_class() -> None:
pursuer = _actor("pursuer", initiative=20)
quarry = _actor("quarry", initiative=10)
quarry["sheet"]["combat"]["ac"] = {"base": 0, "override": 0}
quarry["derived"] = derive_character_sheet(quarry["sheet"])
chase = start_chase(
[pursuer, quarry],
quarry_ids=["quarry"],
initial_distance_ft=100,
)
chase["turn_index"] = 1
chase["pending_complication"] = {
"number": 9,
"source_actor_id": "pursuer",
"rolled_round": 1,
}
chase, quarry = _guard_chase(armor_class=0)

result = advance_chase_turn(
chase,
Expand All @@ -238,6 +246,57 @@ def test_urban_guard_attack_preserves_zero_armor_class() -> None:
assert guard_attack["damage"]["total"] == 2


def test_urban_guard_attack_applies_natural_one_and_ordinary_miss_rules() -> None:
natural_one_chase, quarry = _guard_chase(armor_class=0)
natural_one = advance_chase_turn(
natural_one_chase,
quarry,
actor_id_value="quarry",
action="move",
rng=_SequenceRng(1, 20),
)["turn"]["guard_attack"]

assert natural_one["attack_roll"]["fumble"] is True
assert natural_one["hit"] is False
assert natural_one["damage"] is None

ordinary_chase, quarry = _guard_chase(armor_class=10)
ordinary_miss = advance_chase_turn(
ordinary_chase,
quarry,
actor_id_value="quarry",
action="move",
rng=_SequenceRng(6, 20),
)["turn"]["guard_attack"]

assert ordinary_miss["attack_roll"]["fumble"] is False
assert ordinary_miss["total"] == 9
assert ordinary_miss["hit"] is False
assert ordinary_miss["damage"] is None


def test_urban_guard_attack_natural_twenty_hits_and_doubles_the_damage_die() -> None:
chase, quarry = _guard_chase(armor_class=30)

result = advance_chase_turn(
chase,
quarry,
actor_id_value="quarry",
action="move",
rng=_SequenceRng(20, 3, 4, 20),
)

guard_attack = result["turn"]["guard_attack"]
assert guard_attack["attack_roll"]["critical"] is True
assert guard_attack["total"] == 23
assert guard_attack["target_ac"] == 30
assert guard_attack["hit"] is True
assert guard_attack["damage"]["expression"] == "2d6+1"
assert guard_attack["damage"]["rolls"] == [3, 4]
assert guard_attack["damage"]["total"] == 8
assert result["sheet"]["combat"]["hp"]["value"] == 12


def test_urban_complication_incapacitation_prevents_movement() -> None:
pursuer = _actor("pursuer", initiative=20)
quarry = _actor("quarry", initiative=10)
Expand Down
27 changes: 24 additions & 3 deletions packages/mcp/tests/test_chase_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,17 @@ def test_public_chase_uses_exact_module_source_and_no_combat_map(
)
original_advance = server_module.advance_chase_turn

def deterministic_advance(*args, **kwargs):
kwargs["rng"] = _SequenceRng(20)
return original_advance(*args, **kwargs)
def deterministic_advance(chase, *args, **kwargs):
chase = {
**chase,
"pending_complication": {
"number": 9,
"source_actor_id": chase["quarry_ids"][0],
"rolled_round": 1,
},
}
kwargs["rng"] = _SequenceRng(20, 3, 4, 20)
return original_advance(chase, *args, **kwargs)

monkeypatch.setattr(server_module, "advance_chase_turn", deterministic_advance)

Expand Down Expand Up @@ -346,6 +354,19 @@ async def exercise() -> None:
)

assert turn["turn"]["moved_ft"] == 60
guard_attack = turn["turn"]["guard_attack"]
assert guard_attack["attack_roll"]["critical"] is True
assert guard_attack["hit"] is True
assert guard_attack["damage"]["expression"] == "2d6+1"
assert guard_attack["damage"]["rolls"] == [3, 4]
assert guard_attack["damage"]["total"] == 8
assert turn["status"] == "committed"
assert turn["character"]["sheet"]["combat"]["hp"]["value"] == 12
assert turn["character"]["revision"] == current_pursuer["revision"] + 1
assert any(
receipt["mechanic_id"] == "dnd5e.core.chase.urban_complications"
for receipt in turn["rule_receipts"]
)
assert turn["chase"]["active"] is False
assert turn["chase"]["outcome"]["status"] == "destination_reached"
queried = await _call(
Expand Down