Skip to content

Commit 9d5f4a6

Browse files
committed
fix: z-axis issues, lock, telemetry
1 parent 8ce2168 commit 9d5f4a6

4 files changed

Lines changed: 120 additions & 26 deletions

File tree

src/brain/scoring_phases.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,15 @@ def _is_rule_blocked(
7474
diag_results.append(f"{r.name}=OPEN")
7575
rule_times[r.name] = 0.0
7676
return True
77-
if not r.condition(state):
77+
try:
78+
cond = r.condition(state)
79+
except Exception as exc:
80+
log.warning("[DECISION] Rule %s condition raised %s -- skipping", r.name, exc)
81+
rule_eval[r.name] = "ERROR"
82+
diag_results.append(f"{r.name}=ERROR")
83+
rule_times[r.name] = 0.0
84+
return True
85+
if not cond:
7886
rule_eval[r.name] = "cond=F"
7987
diag_results.append(f"{r.name}=cond_F")
8088
rule_times[r.name] = 0.0
@@ -114,6 +122,14 @@ def compute_divergence(brain: Brain, state: GameState, now: float, binary_winner
114122
if breaker and not breaker.allow():
115123
scores[r.name] = -2.0 # circuit-broken
116124
continue
125+
# Check condition so telemetry doesn't report ineligible "winners"
126+
try:
127+
if not r.condition(state):
128+
scores[r.name] = -3.0 # condition false
129+
continue
130+
except Exception:
131+
scores[r.name] = -4.0 # condition error
132+
continue
117133
s = _safe_score(r.score_fn, state)
118134
scores[r.name] = s
119135
if s > best_score:

src/brain/transitions.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,15 @@ def handle_transition(
3535
if brain._ctx and brain._ctx.combat.engaged:
3636
maybe_clear_stale_engagement(brain, state, selected, now)
3737

38-
if selected is brain._active:
39-
return
40-
41-
# Check lock timeout before honoring the lock
38+
# Check lock timeout even when the active routine re-selects itself,
39+
# otherwise max_lock_seconds is bypassed whenever the locked routine
40+
# keeps winning selection (the early return below would skip the check).
4241
if brain._active is not None and brain._active.locked and brain._active_start_time > 0:
4342
maybe_force_lock_exit(brain, state, now)
4443

44+
if selected is brain._active:
45+
return
46+
4547
# Locked routines can only be interrupted by emergency rules
4648
if brain._active is not None and brain._active.locked and not selected_emergency:
4749
lock_key = f"{brain._active_name}:{selected_name}"

src/nav/pathfinding.py

Lines changed: 92 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,10 @@ def find_path(
245245

246246
if current == goal_node:
247247
path = _jps_reconstruct(came_from, current, terrain, near_z)
248-
path = _simplify_path(path, terrain)
249-
path = vary_path(path, terrain, jitter_range=jitter)
248+
path = _simplify_path(path, terrain, wb_override=wb, wbc_override=wbc)
249+
path = vary_path(
250+
path, terrain, jitter_range=jitter, near_z=near_z, wb_override=wb, wbc_override=wbc
251+
)
250252
_log_path_found(path, terrain, t0, explored, start.x, start.y, goal.x, goal.y)
251253
return path
252254

@@ -468,8 +470,10 @@ def _find_path_astar(
468470

469471
if cc == gc and cr == gr:
470472
path = _jps_reconstruct(came_from, current, terrain, near_z)
471-
path = _simplify_path(path, terrain)
472-
path = vary_path(path, terrain, jitter_range=jitter)
473+
path = _simplify_path(path, terrain, wb_override=wb, wbc_override=wbc)
474+
path = vary_path(
475+
path, terrain, jitter_range=jitter, near_z=near_z, wb_override=wb, wbc_override=wbc
476+
)
473477
elapsed = time.perf_counter() - t0
474478
log.info("[NAV] A* fallback: path %d waypoints, %d nodes in %.2fs", len(path), explored, elapsed)
475479
return path
@@ -780,7 +784,12 @@ def _reconstruct(
780784
return waypoints
781785

782786

783-
def _simplify_path(path: list[Point], terrain: ZoneTerrain) -> list[Point]:
787+
def _simplify_path(
788+
path: list[Point],
789+
terrain: ZoneTerrain,
790+
wb_override: bytearray | None = None,
791+
wbc_override: int | None = None,
792+
) -> list[Point]:
784793
"""Two-phase path smoothing: greedy LOS + clearance-aware funnel.
785794
786795
Phase 1 (greedy forward): scan forward from each anchor, skip
@@ -790,6 +799,9 @@ def _simplify_path(path: list[Point], terrain: ZoneTerrain) -> list[Point]:
790799
try the FARTHEST remaining point first (reverse scan). Prefer paths
791800
with clearance margin; fall back to center-only for narrow passages.
792801
Finds shortcuts that Phase 1 misses due to its early-break heuristic.
802+
803+
When *wb_override*/*wbc_override* are provided, LOS checks use the
804+
Z-filtered bitfield so smoothing respects multi-level terrain.
793805
"""
794806
if len(path) <= 2:
795807
return path
@@ -800,21 +812,25 @@ def _simplify_path(path: list[Point], terrain: ZoneTerrain) -> list[Point]:
800812
while i < len(path) - 1:
801813
best_j = i + 1
802814
for j in range(i + 2, len(path)):
803-
if _clear_line(terrain, path[i][0], path[i][1], path[j][0], path[j][1]):
815+
if _clear_line(
816+
terrain, path[i][0], path[i][1], path[j][0], path[j][1], wb_override, wbc_override
817+
):
804818
best_j = j
805819
else:
806820
break
807821
phase1.append(path[best_j])
808822
i = best_j
809823

810824
# Phase 2: reverse-scan funnel with clearance
811-
return _funnel_smooth(phase1, terrain)
825+
return _funnel_smooth(phase1, terrain, wb_override=wb_override, wbc_override=wbc_override)
812826

813827

814828
def _funnel_smooth(
815829
path: list[Point],
816830
terrain: ZoneTerrain,
817831
clearance: float = 2.0,
832+
wb_override: bytearray | None = None,
833+
wbc_override: int | None = None,
818834
) -> list[Point]:
819835
"""Clearance-aware reverse-scan funnel smoothing.
820836
@@ -840,14 +856,18 @@ def _funnel_smooth(
840856

841857
# Try farthest point first -- with clearance
842858
for j in range(len(path) - 1, i + 1, -1):
843-
if _clear_line_wide(terrain, path[i][0], path[i][1], path[j][0], path[j][1], clearance):
859+
if _clear_line_wide(
860+
terrain, path[i][0], path[i][1], path[j][0], path[j][1], clearance, wb_override, wbc_override
861+
):
844862
best_j = j
845863
break
846864

847865
# Fallback: center-only for narrow passages
848866
if best_j == i + 1 and i + 2 < len(path):
849867
for j in range(len(path) - 1, i + 1, -1):
850-
if _clear_line(terrain, path[i][0], path[i][1], path[j][0], path[j][1]):
868+
if _clear_line(
869+
terrain, path[i][0], path[i][1], path[j][0], path[j][1], wb_override, wbc_override
870+
):
851871
best_j = j
852872
break
853873

@@ -857,16 +877,39 @@ def _funnel_smooth(
857877
return result
858878

859879

860-
def vary_path(path: list[Point], terrain: ZoneTerrain, jitter_range: float = 3.0) -> list[Point]:
880+
def vary_path(
881+
path: list[Point],
882+
terrain: ZoneTerrain,
883+
jitter_range: float = 3.0,
884+
near_z: float = float("nan"),
885+
wb_override: bytearray | None = None,
886+
wbc_override: int | None = None,
887+
) -> list[Point]:
861888
"""Add small perpendicular offsets to intermediate waypoints.
862889
863890
Applies a uniform lateral drift (1-3u) to each interior point.
864891
Start/end waypoints are never modified. All offset positions are
865892
validated against terrain before use.
893+
894+
When *near_z* and a Z-filtered bitfield are provided, jitter
895+
validation uses the bitfield (respecting multi-level terrain) and
896+
Z lookup uses ``get_level_z`` to stay on the correct floor.
866897
"""
867898
if len(path) <= 2:
868899
return path
869900

901+
# Pre-fetch grid constants for bitfield walkability checks
902+
_use_zbit = wb_override is not None and wbc_override is not None
903+
if _use_zbit:
904+
_cs = terrain.cell_size
905+
_mx = terrain._min_x
906+
_my = terrain._min_y
907+
_cols = terrain._cols
908+
_rows = terrain._rows
909+
_bw = _bit_walkable
910+
911+
_has_near_z = not math.isnan(near_z)
912+
870913
result: list[Point] = [path[0]]
871914

872915
for i in range(1, len(path) - 1):
@@ -882,21 +925,44 @@ def vary_path(path: list[Point], terrain: ZoneTerrain, jitter_range: float = 3.0
882925
offset = random.uniform(-jitter_range, jitter_range)
883926
jx = px + perp_x * offset
884927
jy = py + perp_y * offset
885-
if terrain.is_walkable(jx, jy):
928+
# Z-aware walkability: use the filtered bitfield when available
929+
if _use_zbit:
930+
col = int((jy - _mx) / _cs)
931+
row = int((jx - _my) / _cs)
932+
walkable = _bw(wb_override, wbc_override, col, row, _cols, _rows)
933+
else:
934+
walkable = terrain.is_walkable(jx, jy)
935+
if walkable:
886936
px, py = jx, jy
887937

888-
_z = terrain.get_z(px, py)
938+
# Z-aware height: use get_level_z to stay on the correct floor
939+
if _has_near_z:
940+
_z = terrain.get_level_z(px, py, near_z)
941+
else:
942+
_z = terrain.get_z(px, py)
889943
result.append(Point(px, py, _z if _z == _z else 0.0))
890944

891945
result.append(path[-1])
892946
return result
893947

894948

895-
def _clear_line(terrain: ZoneTerrain, x1: float, y1: float, x2: float, y2: float) -> bool:
949+
def _clear_line(
950+
terrain: ZoneTerrain,
951+
x1: float,
952+
y1: float,
953+
x2: float,
954+
y2: float,
955+
wb_override: bytearray | None = None,
956+
wbc_override: int | None = None,
957+
) -> bool:
896958
"""Check if a straight line between two game-coord points is walkable.
897959
898960
Uses precomputed bitfield when available (~10x faster than
899961
_cell_walkable per sample). Falls back to _cell_walkable otherwise.
962+
963+
When *wb_override*/*wbc_override* are provided (e.g. the Z-filtered
964+
bitfield from ``build_walk_bits_z``), they take precedence over the
965+
cached Z-agnostic ``terrain._walk_bits``.
900966
"""
901967
dx, dy = x2 - x1, y2 - y1
902968
dist = math.hypot(dx, dy)
@@ -907,9 +973,9 @@ def _clear_line(terrain: ZoneTerrain, x1: float, y1: float, x2: float, y2: float
907973
steps = max(1, int(dist / (cs * 0.5)))
908974

909975
# Fast path: bitfield-accelerated (no coord conversion overhead)
910-
wb = terrain._walk_bits
976+
wb = wb_override if wb_override is not None else terrain._walk_bits
911977
if wb:
912-
wbc = terrain._walk_byte_cols
978+
wbc = wbc_override if wbc_override is not None else terrain._walk_byte_cols
913979
cols = terrain._cols
914980
rows = terrain._rows
915981
mx = terrain._min_x
@@ -936,14 +1002,21 @@ def _clear_line(terrain: ZoneTerrain, x1: float, y1: float, x2: float, y2: float
9361002

9371003

9381004
def _clear_line_wide(
939-
terrain: ZoneTerrain, x1: float, y1: float, x2: float, y2: float, clearance: float = 2.0
1005+
terrain: ZoneTerrain,
1006+
x1: float,
1007+
y1: float,
1008+
x2: float,
1009+
y2: float,
1010+
clearance: float = 2.0,
1011+
wb_override: bytearray | None = None,
1012+
wbc_override: int | None = None,
9401013
) -> bool:
9411014
"""Check if a line with clearance margin is fully walkable.
9421015
9431016
Tests center line plus two parallel lines offset by clearance.
9441017
Ensures the agent has breathing room and won't wall-hug.
9451018
"""
946-
if not _clear_line(terrain, x1, y1, x2, y2):
1019+
if not _clear_line(terrain, x1, y1, x2, y2, wb_override, wbc_override):
9471020
return False
9481021

9491022
dx, dy = x2 - x1, y2 - y1
@@ -955,8 +1028,8 @@ def _clear_line_wide(
9551028
px = -dy / dist * clearance
9561029
py = dx / dist * clearance
9571030

958-
if not _clear_line(terrain, x1 + px, y1 + py, x2 + px, y2 + py):
1031+
if not _clear_line(terrain, x1 + px, y1 + py, x2 + px, y2 + py, wb_override, wbc_override):
9591032
return False
960-
if not _clear_line(terrain, x1 - px, y1 - py, x2 - px, y2 - py):
1033+
if not _clear_line(terrain, x1 - px, y1 - py, x2 - px, y2 - py, wb_override, wbc_override):
9611034
return False
9621035
return True

tests/test_pathfinding_algo.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,10 +1063,13 @@ def test_ground_agent_avoids_bridge_cells(self):
10631063
path = find_path(t, start, goal, jitter=0.0)
10641064

10651065
assert path is not None
1066-
# No waypoint should be in the bridge columns (8-12)
1066+
# No waypoint should be in an actual bridge cell (cols 8-12, rows 5-24).
1067+
# The path may pass through cols 8-12 at rows outside the bridge
1068+
# (the gap at top/bottom) -- that's correct routing around the bridge.
10671069
for wp in path:
10681070
col, row = t._game_to_grid(wp.x, wp.y)
1069-
assert col < 8 or col > 12, f"ground path crossed bridge at col={col}"
1071+
in_bridge = 8 <= col <= 12 and 5 <= row <= 24
1072+
assert not in_bridge, f"ground path crossed bridge at col={col} row={row}"
10701073

10711074
def test_bridge_agent_crosses_bridge(self):
10721075
"""Agent at Z=50 should path straight across the bridge."""

0 commit comments

Comments
 (0)