Skip to content

RuleBrain: approach along a path that leaves no enemy's reach (W3-5) - #86

Merged
vladmesh merged 3 commits into
mainfrom
claude/rulebrain-avoid-reach-0hl6r0
Sep 29, 2026
Merged

vladmesh merged 3 commits into
mainfrom
claude/rulebrain-avoid-reach-0hl6r0

Conversation

@vladmesh

@vladmesh vladmesh commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Card W3-5: issue combat-pathfinding-avoidance (remainder). I checked against main 2f79530 and the problem was still there.

Root cause

RuleBrain._plan_approach picked the cheapest Dijkstra path to an attack cell. _try_advance then sent MOVE_TO to the farthest affordable cell on that path. Neither step looked at other enemies' reach. CombatAwareness also 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_triggers uses it, and so does the brain.

  • Path search. rules/movement.compute_reachable accepts an optional step_allowed(from, to) predicate. Existing callers are unchanged.

  • Awareness. CombatEntity gains reach_ft (primary attack reach) and can_react. can_react is true when the creature has a reaction left and is not incapacitated, which matches find_oa_triggers' eligibility checks.

  • Approach planning. _plan_approach runs a second search that forbids any step leaving the reach of a hostile creature with can_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:

    • it costs at most MAX_SAFE_DETOUR_FT (10 ft, two cells) more than the cheapest path;
    • if the cheapest path reaches an attack cell with this turn's movement, the safe path does too. The NPC never gives up this turn's attack for a detour.

    Otherwise the old cheapest path is kept. That covers both "no safe path" and "detour too long".

  • Keeping the walk safe. MOVE_TO walks 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 same compute_reachable call 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::TestAvoidsOpportunityAttacks runs full turns through Round with 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::TestLeavesReach
  • test_movement.py::TestComputeReachable::test_step_allowed_excludes_steps_and_reroutes
  • test_awareness_builder.py::TestCombatAwarenessThreat (reach, spent reaction, incapacitated)

Checks:

  • make check is green: 2950 backend tests and 353 frontend tests pass, plus lint, format and mypy.
  • Integration: Docker is not available in this environment (no daemon socket). I ran tests/integration/ against a local backend with the docker-compose.test.yml settings (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.md and the reactions paragraph in ARCHITECTURE.md.

No new bug was found while writing the tests.

Found but not fixed (out of scope)

  • The retreat and flee back-off (_move_away_from) still ignores OA threats. It already moves away from the threats, but its MOVE_TO route can leave a reach. Only _try_advance was in scope here.
  • compute_reachable keys 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 uses path_cost on the returned paths, so the comparisons are consistent.
  • can_react exposes whether an enemy's reaction is spent. That is tactical information, but players can also see it on the frontend payload (it rides along in nearby). The frontend does not display it.

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)
@vladmesh

Copy link
Copy Markdown
Owner Author

Independent review: PASS

  • What changed (card W3-5):
    • CombatEntity now carries reach_ft and can_react. can_react means the creature has its reaction and is not incapacitated.
    • RuleBrain._plan_approach computes a second, "safe" Dijkstra with a step_allowed filter that excludes steps leaving a threat's reach. The filter uses the leaves_reach predicate, now shared with find_oa_triggers, so the brain and the rules cannot drift apart.
    • The safe path is taken when it costs at most 10 ft more, and when it still reaches attack range this turn wherever the cheapest path would. The MOVE_TO goal is then capped so that its own cheapest route stays safe.
    • With no threats, behaviour is unchanged.
  • Checks: local make check is green and required CI is green.
  • Regression check: against main's src/, 3 new awareness tests fail, test_step_allowed_excludes_steps_and_reroutes fails, and the brain scenario test_takes_a_safe_detour_past_a_bystander fails (the goblin provokes on main). test_rules_reactions.py cannot import on main because it imports the new leaves_reach. All pass with the fix.
  • Non-blocking: the extra fields also show up in the combat awareness payload sent to clients (dataclasses.asdict). That is harmless extra data, because the frontend ignores unknown keys.

@vladmesh
vladmesh merged commit c2b2329 into main Sep 29, 2026
4 checks passed
@vladmesh
vladmesh deleted the claude/rulebrain-avoid-reach-0hl6r0 branch September 29, 2026 09:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant