Repository navigation
RuleBrain: approach along a path that leaves no enemy's reach (W3-5) - #86
Merged
Merged
Conversation
When a rule-based NPC advanced toward its target it walked the cheapest path, even out of a bystander's reach, provoking opportunity attacks it could have avoided. The brain now also searches for a path that never leaves the reach of a hostile creature able to react, and takes it when it costs at most 10 ft more and, if the direct path ends in reach this turn, still does. MOVE_TO waypoints are chosen so the handler's own route stays safe. No safe path, or too long a detour, keeps the old route. - rules/reactions.leaves_reach: the single OA trigger test, reused by find_oa_triggers and the brain - compute_reachable gains an optional step_allowed predicate - CombatEntity carries reach_ft and can_react (reaction left, not incapacitated)
Owner
Author
|
Independent review: PASS
|
…d-reach-0hl6r0 # Conflicts: # CLAUDE.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Card W3-5: issue
combat-pathfinding-avoidance(remainder). I checked against main2f79530and the problem was still there.Root cause
RuleBrain._plan_approachpicked the cheapest Dijkstra path to an attack cell._try_advancethen sentMOVE_TOto the farthest affordable cell on that path. Neither step looked at other enemies' reach.CombatAwarenessalso carried no enemy reach, so the brain had nothing to check against. An NPC therefore walked through a bystander's reach and back out of it, which provoked an opportunity attack it could have avoided.Fix
One trigger rule.
rules/reactions.leaves_reach(reactor_pos, reach, cur, nxt)is now the single OA trigger test: leaving a reach provokes, entering it or moving inside it does not.find_oa_triggersuses it, and so does the brain.Path search.
rules/movement.compute_reachableaccepts an optionalstep_allowed(from, to)predicate. Existing callers are unchanged.Awareness.
CombatEntitygainsreach_ft(primary attack reach) andcan_react.can_reactis true when the creature has a reaction left and is not incapacitated, which matchesfind_oa_triggers' eligibility checks.Approach planning.
_plan_approachruns a second search that forbids any step leaving the reach of a hostile creature withcan_react. This set includes the chosen target, because leaving the target's reach also provokes; entering it never does. The safe path is used when both conditions hold:MAX_SAFE_DETOUR_FT(10 ft, two cells) more than the cheapest path;Otherwise the old cheapest path is kept. That covers both "no safe path" and "detour too long".
Keeping the walk safe.
MOVE_TOwalks the handler's own cheapest route to the chosen cell, so the brain only chooses a waypoint whose handler route is also safe. It predicts that route with the samecompute_reachablecall on its map view. It then replans after each move. The first step of a safe path is always a single safe step, so there is always a valid waypoint. Everything is deterministic.Tests
tests/unit/test_rule_brain_pathfinding.py::TestAvoidsOpportunityAttacksruns full turns throughRoundwith a guard that has its reaction ready:test_takes_a_safe_detour_past_a_bystander: the direct line leaves the guard's reach, and an equal-cost route along y=5 does not. The goblin takes the detour, the guard's reaction is unspent, and the goblin attacks the hero. Verified failing on main (the guard's reaction is spent).test_engaged_mover_has_no_safe_path_and_keeps_the_old_route: the goblin starts next to the guard, so no safe path exists. It ends on the same cell as when the guard cannot react, and the OA is taken. Passes on main too, as a guard for unchanged behaviour.test_long_detour_is_not_taken: walls leave a safe route of 40 ft against 25 ft direct. The goblin takes the direct route and the OA. Passes on main too, as a guard.test_long_reach_target_is_still_approached: the target itself has a pike (reach 10) and a ready reaction. The goblin still moves in and attacks, with no OA. Passes on main too, as a guard.Helper tests. These fail on main with an import error or missing attribute, since they exercise new API:
test_rules_reactions.py::TestLeavesReachtest_movement.py::TestComputeReachable::test_step_allowed_excludes_steps_and_reroutestest_awareness_builder.py::TestCombatAwarenessThreat(reach, spent reaction, incapacitated)Checks:
make checkis green: 2950 backend tests and 353 frontend tests pass, plus lint, format and mypy.tests/integration/against a local backend with thedocker-compose.test.ymlsettings (DND_DICE_SEED=42,DND_CONTENT_DIR=tests/integration/content,BACKEND_URL=http://localhost:8001): 184 passed.Docs: I added a sentence each to the RuleBrain movement description in
CLAUDE.mdand the reactions paragraph inARCHITECTURE.md.No new bug was found while writing the tests.
Found but not fixed (out of scope)
_move_away_from) still ignores OA threats. It already moves away from the threats, but itsMOVE_TOroute can leave a reach. Only_try_advancewas in scope here.compute_reachablekeys its best cost by cell only, not by diagonal parity. Under the 5/10 diagonal alternation the path it returns can occasionally cost more than the true optimum. This predates the change; the brain usespath_coston the returned paths, so the comparisons are consistent.can_reactexposes whether an enemy's reaction is spent. That is tactical information, but players can also see it on the frontend payload (it rides along innearby). The frontend does not display it.