Skip to content

Commit 8ce2168

Browse files
committed
fix: enforce rule conditions in scoring phases, add GOAP closed set
1 parent e204f70 commit 8ce2168

8 files changed

Lines changed: 326 additions & 43 deletions

File tree

src/brain/goap/planner.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,11 @@ def _search(self, start: PlanWorldState, goal: Goal, ctx: AgentContext | None) -
484484
counter = 1
485485
visited = 0
486486

487+
# Best g-cost seen per state. PlanWorldState is frozen/hashable,
488+
# so equivalent states reached via different action orderings are
489+
# detected and the worse path is pruned.
490+
best_g: dict[PlanWorldState, float] = {start: 0.0}
491+
487492
while open_list:
488493
if visited >= MAX_SEARCH_NODES:
489494
log.log(VERBOSE, "[GOAP] Search exhausted: %d nodes", visited)
@@ -493,6 +498,11 @@ def _search(self, start: PlanWorldState, goal: Goal, ctx: AgentContext | None) -
493498
break
494499

495500
_, _, node = heapq.heappop(open_list)
501+
502+
# Skip if we already expanded this state at equal or lower cost.
503+
prev = best_g.get(node.state)
504+
if prev is not None and node.g_cost > prev:
505+
continue
496506
visited += 1
497507

498508
# Goal test: deterministic satisfaction check
@@ -542,8 +552,14 @@ def _search(self, start: PlanWorldState, goal: Goal, ctx: AgentContext | None) -
542552

543553
new_state = action.apply_effects(node.state)
544554
new_cost = node.g_cost + self.get_corrected_cost(action, ctx)
545-
new_actions = node.actions + [action]
546555

556+
# Prune if we already reached this state at equal or lower cost.
557+
prev_best = best_g.get(new_state)
558+
if prev_best is not None and new_cost >= prev_best:
559+
continue
560+
best_g[new_state] = new_cost
561+
562+
new_actions = node.actions + [action]
547563
child = _Node(
548564
state=new_state,
549565
g_cost=new_cost,

src/brain/scoring_phases.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,19 @@ def _apply_modifiers(score: float, phase: str, rule_name: str, goap_hint: str) -
4848
def _is_rule_blocked(
4949
brain: Brain,
5050
r: RuleDef,
51+
state: GameState,
5152
now: float,
5253
rule_eval: dict,
5354
diag_results: list,
5455
rule_times: dict,
5556
) -> bool:
56-
"""Check cooldown and circuit breaker. Returns True if rule should be skipped.
57+
"""Check condition, cooldown, and circuit breaker. Returns True if
58+
rule should be skipped.
5759
5860
Shared by Phases 2/3/4 to avoid duplicating eligibility logic.
61+
The condition check is critical: without it, a rule whose predicate
62+
returns False (e.g. ACQUIRE when suppressed by hard gates) could
63+
still win selection based on its utility score alone.
5964
"""
6065
if r.name in brain._cooldowns and now < brain._cooldowns[r.name]:
6166
remaining = brain._cooldowns[r.name] - now
@@ -69,6 +74,11 @@ def _is_rule_blocked(
6974
diag_results.append(f"{r.name}=OPEN")
7075
rule_times[r.name] = 0.0
7176
return True
77+
if not r.condition(state):
78+
rule_eval[r.name] = "cond=F"
79+
diag_results.append(f"{r.name}=cond_F")
80+
rule_times[r.name] = 0.0
81+
return True
7282
return False
7383

7484

@@ -134,7 +144,7 @@ def select_by_tier(
134144
tier_groups: dict[int, list[RuleDef]] = defaultdict(list)
135145

136146
for r in brain._rules:
137-
if _is_rule_blocked(brain, r, now, rule_eval, diag_results, rule_times):
147+
if _is_rule_blocked(brain, r, state, now, rule_eval, diag_results, rule_times):
138148
continue
139149
tier_groups[r.tier].append(r)
140150

@@ -169,7 +179,7 @@ def select_weighted(
169179
phase, goap_hint = _resolve_phase_context(brain)
170180

171181
for r in brain._rules:
172-
if _is_rule_blocked(brain, r, now, rule_eval, diag_results, rule_times):
182+
if _is_rule_blocked(brain, r, state, now, rule_eval, diag_results, rule_times):
173183
continue
174184
t0 = time.perf_counter()
175185
s = _apply_modifiers(_safe_score(r.score_fn, state), phase, r.name, goap_hint)
@@ -210,7 +220,7 @@ def select_with_considerations(
210220
phase, goap_hint = _resolve_phase_context(brain)
211221

212222
for r in brain._rules:
213-
if _is_rule_blocked(brain, r, now, rule_eval, diag_results, rule_times):
223+
if _is_rule_blocked(brain, r, state, now, rule_eval, diag_results, rule_times):
214224
continue
215225
t0 = time.perf_counter()
216226
# Phase 4: prefer considerations over score_fn when defined

src/nav/pathfinding.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,15 @@ def _validate_endpoints(
6161

6262
snap_radius = max(10, int(100.0 / terrain.cell_size))
6363
if not _cell_walkable(terrain, sc, sr, near_z):
64-
snapped = _snap_to_walkable(terrain, sc, sr, radius=snap_radius)
64+
snapped = _snap_to_walkable(terrain, sc, sr, radius=snap_radius, near_z=near_z)
6565
if snapped:
6666
sc, sr = snapped
6767
else:
6868
log.warning("[NAV] Pathfind: start is blocked and no walkable cell nearby")
6969
return None
7070

7171
if not _cell_walkable(terrain, gc, gr, near_z):
72-
snapped = _snap_to_walkable(terrain, gc, gr, radius=snap_radius)
72+
snapped = _snap_to_walkable(terrain, gc, gr, radius=snap_radius, near_z=near_z)
7373
if snapped:
7474
gc, gr = snapped
7575
else:
@@ -199,11 +199,15 @@ def find_path(
199199
rows = terrain._rows
200200
explored = 0
201201

202-
# Extract bitfield for hot-path (local refs = faster than attr access)
203-
if not terrain._walk_bits:
204-
terrain._build_walk_bits()
205-
wb = terrain._walk_bits
206-
wbc = terrain._walk_byte_cols
202+
# Build a Z-filtered walkability bitfield so the search never routes
203+
# through cells on a different vertical level (bridges, multi-floor).
204+
if not math.isnan(near_z) and terrain._z_ceiling:
205+
wb, wbc = terrain.build_walk_bits_z(near_z)
206+
else:
207+
if not terrain._walk_bits:
208+
terrain._build_walk_bits()
209+
wb = terrain._walk_bits
210+
wbc = terrain._walk_byte_cols
207211

208212
# All 8 directions for the start node (no parent to prune from)
209213
_ALL_DIRS = [
@@ -297,8 +301,9 @@ def _cell_walkable(terrain: ZoneTerrain, col: int, row: int, near_z: float = flo
297301
"""Check if a grid cell is walkable with Z-level filtering.
298302
299303
When near_z is provided (not NaN), cells on a different vertical
300-
level (bridges, multi-floor buildings) are rejected. This prevents
301-
pathfinding from routing through geometry on the wrong floor.
304+
level (bridges, multi-floor buildings) are rejected. Bridge cells
305+
with water/lava underneath are only walkable for agents at bridge
306+
height, not ground level.
302307
"""
303308
idx = terrain._grid_idx(col, row)
304309
if idx < 0:
@@ -310,6 +315,18 @@ def _cell_walkable(terrain: ZoneTerrain, col: int, row: int, near_z: float = flo
310315
if not terrain.is_walkable(game_x, game_y):
311316
return False
312317
if not math.isnan(near_z):
318+
# Multi-level check: reject bridge cells when agent is at ground
319+
# level and ground surface is hazardous (water/lava).
320+
from nav.terrain.heightmap import SURFACE_BRIDGE, SURFACE_LAVA, SURFACE_WATER
321+
322+
f = terrain._flags[idx]
323+
z_ceil = terrain.get_z_ceiling(game_x, game_y)
324+
if not math.isnan(z_ceil):
325+
z_ground = terrain.get_z(game_x, game_y)
326+
mid = (z_ground + z_ceil) / 2.0
327+
on_upper = near_z > mid
328+
if not on_upper and (f & SURFACE_BRIDGE) and (f & (SURFACE_WATER | SURFACE_LAVA)):
329+
return False
313330
level_z = terrain.get_level_z(game_x, game_y, near_z)
314331
if abs(level_z - near_z) > _WALKABLE_CLIMB:
315332
return False
@@ -352,14 +369,16 @@ def _cell_cost(terrain: ZoneTerrain, col: int, row: int, from_col: int = -1, fro
352369
return cost
353370

354371

355-
def _snap_to_walkable(terrain: ZoneTerrain, col: int, row: int, radius: int = 5) -> tuple[int, int] | None:
356-
"""Find nearest walkable cell within radius."""
372+
def _snap_to_walkable(
373+
terrain: ZoneTerrain, col: int, row: int, radius: int = 5, near_z: float = float("nan")
374+
) -> tuple[int, int] | None:
375+
"""Find nearest walkable cell within radius, respecting Z-level."""
357376
best = None
358377
best_dist = float("inf")
359378
for dr in range(-radius, radius + 1):
360379
for dc in range(-radius, radius + 1):
361380
nc, nr = col + dc, row + dr
362-
if _cell_walkable(terrain, nc, nr):
381+
if _cell_walkable(terrain, nc, nr, near_z):
363382
d = dc * dc + dr * dr
364383
if d < best_dist:
365384
best_dist = d
@@ -399,13 +418,13 @@ def _find_path_astar(
399418

400419
snap_radius = max(10, int(100.0 / terrain.cell_size))
401420
if not _bit_walkable(wb, wbc, sc, sr, cols, rows):
402-
snapped = _snap_to_walkable(terrain, sc, sr, radius=snap_radius)
421+
snapped = _snap_to_walkable(terrain, sc, sr, radius=snap_radius, near_z=near_z)
403422
if snapped:
404423
sc, sr = snapped
405424
else:
406425
return None
407426
if not _bit_walkable(wb, wbc, gc, gr, cols, rows):
408-
snapped = _snap_to_walkable(terrain, gc, gr, radius=snap_radius)
427+
snapped = _snap_to_walkable(terrain, gc, gr, radius=snap_radius, near_z=near_z)
409428
if snapped:
410429
gc, gr = snapped
411430
else:

src/nav/terrain/heightmap.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@
3838
SURFACE_OBSTACLE = 0x0040 # static object (tree, rock, building, pillar)
3939
SURFACE_BRIDGE = 0x0080 # bridge deck (walkable over water, auto-detected)
4040

41+
# Max Z difference between agent and cell surface to consider walkable.
42+
# Shared with pathfinding (which defines its own copy for module independence).
43+
_WALKABLE_CLIMB = 15.0
44+
_NAN = float("nan")
45+
4146
# Material IDs (uint8, stored per cell in v3 cache)
4247
MAT_UNKNOWN = 0
4348
MAT_STONE = 1
@@ -268,6 +273,78 @@ def _build_walk_bits(self) -> None:
268273
walkable = sum(bin(b).count("1") for b in wb)
269274
log.debug("[NAV] Walk bitfield built: %d walkable bits, %d bytes", walkable, len(wb))
270275

276+
def build_walk_bits_z(self, near_z: float) -> tuple[bytearray, int]:
277+
"""Build a Z-filtered walkability bitfield for pathfinding.
278+
279+
Like ``_build_walk_bits`` but rejects cells whose surface Z is
280+
too far from *near_z* (the agent's current level). This prevents
281+
paths from routing across bridges or floors the agent is not on.
282+
283+
Returns ``(bitfield, byte_cols)`` without mutating the cached
284+
``_walk_bits`` (which is Z-agnostic and used elsewhere).
285+
"""
286+
if not self._flags:
287+
return bytearray(), 0
288+
289+
cols = self._cols
290+
rows = self._rows
291+
byte_cols = (cols + 7) >> 3
292+
wb = bytearray(rows * byte_cols)
293+
blocked = SURFACE_WATER | SURFACE_LAVA | SURFACE_CLIFF | SURFACE_OBSTACLE
294+
has_ceil = bool(self._z_ceiling)
295+
_z = self._z
296+
_zc = self._z_ceiling
297+
climb = _WALKABLE_CLIMB
298+
299+
for row in range(rows):
300+
rb = row * byte_cols
301+
rf = row * cols
302+
for col in range(cols):
303+
idx = rf + col
304+
f = self._flags[idx]
305+
306+
if has_ceil:
307+
z_ceil = _zc[idx]
308+
else:
309+
z_ceil = _NAN
310+
311+
is_multi = z_ceil == z_ceil # not NaN → multi-level cell
312+
313+
if is_multi:
314+
# Multi-level cell (bridge, multi-floor): decide
315+
# walkability based on which surface the agent is on.
316+
z_ground = _z[idx]
317+
mid = (z_ground + z_ceil) / 2.0
318+
on_upper = near_z > mid
319+
if on_upper:
320+
# Agent on the upper surface (bridge deck) — walkable
321+
# only if BRIDGE flag is set.
322+
walkable = bool(f & SURFACE_BRIDGE)
323+
else:
324+
# Agent at ground level — walkable only if the
325+
# ground surface itself is walkable (not water/lava
326+
# under a bridge).
327+
walkable = bool((f & SURFACE_WALKABLE) and not (f & blocked))
328+
# Bridge-only cells are not ground-walkable
329+
if (f & SURFACE_BRIDGE) and (f & (SURFACE_WATER | SURFACE_LAVA)):
330+
walkable = False
331+
level_z = z_ceil if on_upper else z_ground
332+
if abs(level_z - near_z) > climb:
333+
walkable = False
334+
else:
335+
# Single-level cell: standard walkability check
336+
if f & SURFACE_BRIDGE:
337+
walkable = True
338+
elif (f & SURFACE_WALKABLE) and not (f & blocked):
339+
walkable = True
340+
else:
341+
walkable = False
342+
343+
if walkable:
344+
wb[rb + (col >> 3)] |= 1 << (col & 7)
345+
346+
return wb, byte_cols
347+
271348
def invalidate_walk_bits(self) -> None:
272349
"""Force rebuild of the walkability bitfield.
273350

src/perception/log_parser.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,16 @@ def __init__(self, log_path: str, pet_names: set[str] | None = None) -> None:
9999

100100
def _open(self) -> None:
101101
"""Open the log file and seek to the end (only read new lines)."""
102+
f = None
102103
try:
103104
f = open(self._path, encoding="utf-8", errors="replace")
104-
self._file = f
105105
f.seek(0, os.SEEK_END)
106106
log.info("[PERCEPTION] LogParser tailing %s (pos=%d)", self._path, f.tell())
107+
self._file = f
107108
except OSError as e:
108109
log.warning("[PERCEPTION] Could not open log file %s: %s", self._path, e)
110+
if f is not None:
111+
f.close()
109112
self._file = None
110113

111114
def _parse_line(self, body: str, events: LogEvents) -> None:

0 commit comments

Comments
 (0)