Skip to content

Commit 2257387

Browse files
committed
fix: dispatch strategy pattern, context view protocols, lock-timeout, tick hardening
1 parent 83d4198 commit 2257387

64 files changed

Lines changed: 1551 additions & 679 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/brain/completion.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,22 @@ def tick_active_routine(brain: Brain, state: GameState, now: float) -> None:
3434

3535
rt0 = brain.perf_clock()
3636
brain._active._tick_deadline = rt0 + 0.200 # 200ms cooperative budget
37-
status = brain._active.tick(state)
37+
try:
38+
status = brain._active.tick(state)
39+
except Exception as exc:
40+
brain.routine_tick_ms = (brain.perf_clock() - rt0) * 1000
41+
brain._ticked_routine_name = brain._active_name
42+
log.warning(
43+
"[DECISION] Routine %s tick() raised %s -- treating as FAILURE",
44+
brain._active_name,
45+
exc,
46+
)
47+
brain._active.failure_reason = f"tick_exception: {exc}"
48+
from core.types import FailureCategory as _FC
49+
50+
brain._active.failure_category = _FC.EXECUTION
51+
handle_routine_completion(brain, state, RoutineStatus.FAILURE, now)
52+
return
3853
brain.routine_tick_ms = (brain.perf_clock() - rt0) * 1000
3954

4055
# Capture name before completion/hard-kill clears it (for profiling)

src/brain/context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Contains 12 focused sub-state objects for clean grouping:
44
ctx.combat - CombatState (engaged, pull_target_id, dot/lifetap timers)
55
ctx.pet - PetState (alive, spawn_id, name, has_add)
6-
ctx.camp - CampConfig (camp_x/y, guard_x/y, hunt zone, danger points)
6+
ctx.camp - CampConfig (camp_pos, guard_pos, hunt zone, danger points)
77
ctx.inventory - InventoryState (weight tracking, loot count)
88
ctx.plan - PlanState (typed travel plans)
99
ctx.player - PlayerState (death, position, engagement)

src/brain/context_views.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Narrowed view protocols for AgentContext consumers.
2+
3+
Each rule module depends on a subset of AgentContext's surface area.
4+
These Protocol classes make that dependency explicit at the type level:
5+
mypy enforces that closures only access attributes defined on their view.
6+
7+
AgentContext satisfies all views via structural subtyping -- no changes
8+
to AgentContext are needed.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
14+
15+
if TYPE_CHECKING:
16+
from brain.learning.encounters import FightHistory
17+
from brain.state.camp import CampConfig
18+
from brain.state.combat import CombatState
19+
from brain.state.kill_tracker import DefeatTracker
20+
from brain.state.loot_config import LootConfig
21+
from brain.state.pet import PetState
22+
from brain.state.plan import PlanState
23+
from brain.state.player import PlayerState
24+
from brain.state.threat import ThreatState
25+
from brain.state.zone import ZoneState
26+
from brain.world.model import WorldModel
27+
from nav.travel_planner import TunnelRoute
28+
from nav.waypoint_graph import WaypointGraph
29+
from perception.state import GameState
30+
31+
32+
@runtime_checkable
33+
class SurvivalView(Protocol):
34+
"""Context surface used by survival rules (FLEE, REST, FEIGN_DEATH, etc.)."""
35+
36+
combat: CombatState
37+
pet: PetState
38+
player: PlayerState
39+
threat: ThreatState
40+
defeat_tracker: DefeatTracker
41+
fight_history: FightHistory | None
42+
world: WorldModel | None
43+
44+
# Rest thresholds
45+
rest_hp_entry: float
46+
rest_mana_entry: float
47+
rest_hp_threshold: float
48+
rest_mana_threshold: float
49+
50+
@property
51+
def in_active_combat(self) -> bool: ...
52+
53+
54+
@runtime_checkable
55+
class CombatView(Protocol):
56+
"""Context surface used by combat rules (IN_COMBAT, ACQUIRE, PULL, etc.)."""
57+
58+
combat: CombatState
59+
pet: PetState
60+
zone: ZoneState
61+
plan: PlanState
62+
defeat_tracker: DefeatTracker
63+
fight_history: FightHistory | None
64+
loot: LootConfig
65+
world: WorldModel | None
66+
67+
@property
68+
def in_active_combat(self) -> bool: ...
69+
70+
def nearby_player_count(self, state: GameState, radius: float = ...) -> int: ...
71+
72+
73+
@runtime_checkable
74+
class MaintenanceView(Protocol):
75+
"""Context surface used by maintenance rules (BUFF, SUMMON_PET, etc.)."""
76+
77+
combat: CombatState
78+
pet: PetState
79+
plan: PlanState
80+
player: PlayerState
81+
82+
83+
@runtime_checkable
84+
class NavigationView(Protocol):
85+
"""Context surface used by navigation rules (TRAVEL, WANDER)."""
86+
87+
combat: CombatState
88+
pet: PetState
89+
plan: PlanState
90+
camp: CampConfig
91+
world: WorldModel | None
92+
tunnel_routes: list[TunnelRoute]
93+
waypoint_graph: WaypointGraph | None

src/brain/decision.py

Lines changed: 24 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,10 @@
3636
from brain.circuit_breaker import CircuitBreaker
3737
from brain.completion import tick_active_routine
3838
from brain.profiling import tick_profiling
39-
from brain.rule_def import RuleDef
39+
from brain.rule_def import Consideration, RuleDef
4040
from brain.scoring_phases import (
41+
build_phase_selector,
4142
compute_divergence,
42-
select_by_tier,
43-
select_weighted,
44-
select_with_considerations,
4543
)
4644
from brain.transitions import handle_transition
4745
from perception.state import GameState
@@ -90,6 +88,7 @@ def __init__(
9088
self._active_start_time: float = 0.0 # when current routine activated
9189
self._last_lock_blocked: str = "" # suppress repeated lock messages
9290
self.utility_phase: int = utility_phase
91+
self._phase_selector = build_phase_selector(utility_phase)
9392

9493
# Per-tick profiling (rule eval + routine tick times in ms)
9594
self.rule_times: dict[str, float] = {} # rule_name -> eval ms
@@ -120,7 +119,7 @@ def add_rule(
120119
score_fn: Callable[[GameState], float] | None = None,
121120
tier: int = 0,
122121
weight: float = 1.0,
123-
considerations: list | None = None,
122+
considerations: list[Consideration] | None = None,
124123
breaker_max_failures: int = 5,
125124
breaker_window: float = 300.0,
126125
breaker_recovery: float = 120.0,
@@ -170,6 +169,23 @@ def add_rule(
170169
recovery_seconds=breaker_recovery,
171170
)
172171

172+
# -- Public accessors (used by BrainRunner, simulator) --
173+
174+
@property
175+
def active_routine(self) -> RoutineBase | None:
176+
"""The currently running routine, or None."""
177+
return self._active
178+
179+
@property
180+
def active_routine_name(self) -> str:
181+
"""Name of the currently running routine."""
182+
return self._active_name
183+
184+
@property
185+
def last_matched_rule(self) -> str:
186+
"""Name of the rule that matched on the most recent tick."""
187+
return self._last_matched_rule
188+
173189
def tick(self, state: GameState) -> None:
174190
"""Evaluate rules and run the appropriate routine."""
175191
tick_start = self.perf_clock()
@@ -199,51 +215,9 @@ def _evaluate_rules(
199215
rule_eval: dict[str, str] = {}
200216
rule_times: dict[str, float] = {}
201217

202-
if self.utility_phase >= 4:
203-
selected, selected_name, selected_emergency = select_with_considerations(
204-
self, state, now, rule_eval, diag_results, rule_times
205-
)
206-
elif self.utility_phase >= 3:
207-
selected, selected_name, selected_emergency = select_weighted(
208-
self, state, now, rule_eval, diag_results, rule_times
209-
)
210-
elif self.utility_phase >= 2:
211-
selected, selected_name, selected_emergency = select_by_tier(
212-
self, state, now, rule_eval, diag_results, rule_times
213-
)
214-
else:
215-
# Phase 0/1: binary conditions, insertion-order priority.
216-
# Short-circuit after winner found: skip condition evaluation
217-
# for lower-priority rules. This is safe because detection
218-
# logic (threat scan, add detection) runs pre-rule in the
219-
# tick pipeline, not inside condition functions.
220-
for r in self._rules:
221-
if r.name in self._cooldowns and now < self._cooldowns[r.name]:
222-
remaining = self._cooldowns[r.name] - now
223-
rule_eval[r.name] = f"cooldown({remaining:.0f}s)"
224-
diag_results.append(f"{r.name}=CD")
225-
rule_times[r.name] = 0.0
226-
continue
227-
breaker = self._breakers.get(r.name)
228-
if breaker and not breaker.allow():
229-
rule_eval[r.name] = "OPEN"
230-
diag_results.append(f"{r.name}=OPEN")
231-
rule_times[r.name] = 0.0
232-
continue
233-
if selected is not None:
234-
rule_eval[r.name] = "skip"
235-
diag_results.append(f"{r.name}=skip")
236-
rule_times[r.name] = 0.0
237-
continue
238-
t0 = self.perf_clock()
239-
matched = r.condition(state)
240-
rule_times[r.name] = (self.perf_clock() - t0) * 1000
241-
rule_eval[r.name] = "YES" if matched else "no"
242-
diag_results.append(f"{r.name}={'YES' if matched else 'no'}")
243-
if matched:
244-
selected = r.routine
245-
selected_name = r.name
246-
selected_emergency = r.emergency
218+
selected, selected_name, selected_emergency = self._phase_selector.select(
219+
self, state, now, rule_eval, diag_results, rule_times
220+
)
247221

248222
# Phase 1+: compute scores in parallel for divergence logging
249223
if self.utility_phase >= 1:

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/goap/world_state.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,7 @@ def build_world_state(state: GameState, ctx: AgentContext) -> PlanWorldState:
7272
# At camp
7373
at_camp = True
7474
if ctx.camp.roam_radius > 0:
75-
from core.types import Point
76-
77-
d = state.pos.dist_to(Point(ctx.camp.camp_x, ctx.camp.camp_y, 0.0))
75+
d = state.pos.dist_to(ctx.camp.camp_pos)
7876
at_camp = d <= ctx.camp.roam_radius * 1.2 # slight buffer
7977

8078
return PlanWorldState(

src/brain/rule_def.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class RuleDef:
3939
# Phase 3+: weight for cross-tier scoring
4040
weight: float = 1.0
4141
# Phase 4: considerations (list of Consideration objects)
42-
considerations: list = field(default_factory=list)
42+
considerations: list[Consideration] = field(default_factory=list)
4343
# Circuit breaker: max failures in window before tripping (0 = disabled)
4444
breaker_max_failures: int = 5
4545
breaker_window: float = 300.0
@@ -74,7 +74,7 @@ def score_from_considerations(
7474
weight_sum = 0.0
7575
for c in considerations:
7676
raw = c.input_fn(state, ctx)
77-
mapped = c.curve(raw)
77+
mapped = max(0.0, min(c.curve(raw), 1.0))
7878
if mapped <= 0.0:
7979
return 0.0 # hard gate
8080
total_log += c.weight * math.log(mapped)

0 commit comments

Comments
 (0)