Skip to content

Commit b971209

Browse files
committed
feat: GOAP selection and lifecycle
1 parent 2257387 commit b971209

11 files changed

Lines changed: 304 additions & 179 deletions

File tree

src/brain/completion.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def handle_routine_completion(brain: Brain, state: GameState, status: RoutineSta
126126
if breaker:
127127
breaker.record_success()
128128

129+
brain._last_routine_status = str(status)
129130
brain._active = None
130131
brain._active_name = ""
131132
brain._active_start_time = 0.0
@@ -173,6 +174,7 @@ def hard_kill_routine(brain: Brain, state: GameState, now: float) -> None:
173174
if r.routine is brain._active and r.failure_cooldown > 0:
174175
brain._cooldowns[r.name] = now + r.failure_cooldown
175176
break
177+
brain._last_routine_status = "failure"
176178
brain._active = None
177179
brain._active_name = ""
178180
brain._active_start_time = 0.0

src/brain/decision.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def __init__(
104104
self.rule_scores: dict[str, float] = {} # rule_name -> last score
105105
self._score_winner: str = "" # what score-based selection would pick
106106
self._ticked_routine_name: str = "" # name captured before completion clears it
107+
self._last_routine_status: str = "" # SUCCESS/FAILURE, consumed by GOAP planner
107108

108109
# Per-rule circuit breakers (Phase 3 hardening)
109110
self._breakers: dict[str, CircuitBreaker] = {}

src/brain/flee.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""Flee urgency computation.
2+
3+
Extracted from brain.rules.survival so that routines.base can import
4+
flee thresholds and the urgency function without creating a back-edge
5+
in the import DAG (routines -> brain.rules is forbidden).
6+
7+
Both brain.rules.survival and routines.base import from this module.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import logging
13+
from typing import TYPE_CHECKING
14+
15+
if TYPE_CHECKING:
16+
from brain.context_views import SurvivalView
17+
from perception.state import GameState
18+
19+
log = logging.getLogger(__name__)
20+
21+
# -- Flee urgency thresholds (hysteresis) --
22+
FLEE_URGENCY_ENTER = 0.65 # start fleeing at this urgency
23+
FLEE_URGENCY_EXIT = 0.35 # stop fleeing below this
24+
25+
26+
# -- Private urgency axes ----------------------------------------------------
27+
28+
29+
def _fight_winnable(state: GameState) -> bool:
30+
"""True if player can finish the current fight without a pet.
31+
32+
Conditions: player HP > 60% AND target npc HP < 50%.
33+
At these thresholds a few Lifespikes will finish the npc.
34+
"""
35+
t = state.target
36+
if not t or t.hp_max <= 0:
37+
log.debug("[DECISION] _fight_winnable: no valid target")
38+
return False
39+
mob_hp: float = t.hp_current / max(t.hp_max, 1)
40+
winnable: bool = state.hp_pct > 0.60 and mob_hp < 0.50
41+
return winnable
42+
43+
44+
def _count_damaged_npcs(ctx: SurvivalView, state: GameState) -> int:
45+
"""Count damaged NPCs within 40u (for add detection)."""
46+
from brain.rules.skip_log import damaged_npcs_near
47+
48+
return len(damaged_npcs_near(ctx, state, state.pos, 40))
49+
50+
51+
def _mob_attacking_player(state: GameState) -> bool:
52+
"""Return True if any NPC within 30u is targeting the player."""
53+
for sp in state.spawns:
54+
if (
55+
sp.is_npc
56+
and sp.hp_current > 0
57+
and sp.target_name == state.name
58+
and state.pos.dist_to(sp.pos) < 30
59+
):
60+
return True
61+
return False
62+
63+
64+
def _urgency_hp(hp_pct: float) -> float:
65+
"""HP axis: power curve scaled so ~35% HP yields ~0.65."""
66+
result: float = ((1.0 - hp_pct) ** 1.8) * 1.45
67+
return result
68+
69+
70+
def _urgency_pet_died(ctx: SurvivalView, state: GameState, in_combat: bool) -> float:
71+
"""Pet died mid-combat with unwinnable fight: +0.4."""
72+
if in_combat and ctx.pet.just_died() and not _fight_winnable(state):
73+
return 0.4
74+
return 0.0
75+
76+
77+
def _urgency_adds(ctx: SurvivalView, state: GameState, in_combat: bool) -> float:
78+
"""Extra damaged NPCs nearby: +0.15 per add."""
79+
if not in_combat:
80+
return 0.0
81+
count = _count_damaged_npcs(ctx, state)
82+
return 0.15 * max(0, count - 1)
83+
84+
85+
def _urgency_target_dying(state: GameState) -> float:
86+
"""Target nearly dead (<15% HP): -0.25 to finish the defeat."""
87+
t = state.target
88+
if t and t.is_npc and t.hp_max > 0 and t.hp_current > 0:
89+
if t.hp_current / t.hp_max < 0.15:
90+
return -0.25
91+
return 0.0
92+
93+
94+
def _urgency_learned_danger(ctx: SurvivalView, in_combat: bool) -> float:
95+
"""Learned danger from FightHistory (>0.7): +0.2."""
96+
fh = ctx.fight_history
97+
if not fh or not in_combat:
98+
return 0.0
99+
name = ctx.defeat_tracker.last_fight_name
100+
if not name:
101+
return 0.0
102+
danger = fh.learned_danger(name)
103+
return 0.2 if danger is not None and danger > 0.7 else 0.0
104+
105+
106+
def _urgency_pet_hp_low(ctx: SurvivalView) -> float:
107+
"""Pet HP below 30%: +0.1."""
108+
world = ctx.world
109+
if ctx.pet.alive and world:
110+
pet_hp = world.pet_hp_pct
111+
if 0 <= pet_hp < 0.30:
112+
return 0.1
113+
return 0.0
114+
115+
116+
def _urgency_red_threat(ctx: SurvivalView) -> float:
117+
"""RED imminent threat: +0.5."""
118+
if ctx.threat.imminent_threat and ctx.threat.imminent_threat_con == "red":
119+
return 0.5
120+
return 0.0
121+
122+
123+
def _urgency_mob_on_player(ctx: SurvivalView, state: GameState) -> float:
124+
"""No pet + NPC attacking player: +0.5."""
125+
if not ctx.pet.alive and not ctx.combat.engaged and _mob_attacking_player(state):
126+
return 0.5
127+
return 0.0
128+
129+
130+
# -- Public API ---------------------------------------------------------------
131+
132+
133+
def compute_flee_urgency(ctx: SurvivalView, state: GameState) -> float:
134+
"""Composite flee urgency score (0.0 = safe, 1.0 = critical).
135+
136+
Eight axes contribute additively, then clamp to [0, 1].
137+
"""
138+
in_combat = ctx.in_active_combat
139+
140+
hp = _urgency_hp(state.hp_pct)
141+
pet_died = _urgency_pet_died(ctx, state, in_combat)
142+
adds = _urgency_adds(ctx, state, in_combat)
143+
target_dying = _urgency_target_dying(state)
144+
danger = _urgency_learned_danger(ctx, in_combat)
145+
pet_low = _urgency_pet_hp_low(ctx)
146+
red = _urgency_red_threat(ctx)
147+
mob_on = _urgency_mob_on_player(ctx, state)
148+
149+
result: float = max(0.0, min(1.0, hp + pet_died + adds + target_dying + danger + pet_low + red + mob_on))
150+
151+
if result >= FLEE_URGENCY_ENTER:
152+
factors = []
153+
if hp > 0.01:
154+
factors.append(f"hp={hp:.2f}")
155+
if pet_died > 0:
156+
factors.append("pet_died")
157+
if adds > 0:
158+
factors.append(f"extra_npcs={adds / 0.15:.0f}")
159+
if red > 0:
160+
factors.append("RED_threat")
161+
if mob_on > 0:
162+
factors.append("mob_on_player")
163+
log.info(
164+
"[DECISION] Flee urgency: %.3f [%s] combat=%s",
165+
result,
166+
" ".join(factors) if factors else "hp_only",
167+
in_combat,
168+
)
169+
170+
return result

src/brain/goap/planner.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,12 @@ def start_step(self, ctx: AgentContext | None = None) -> None:
212212
"""Called when a plan step's routine begins executing.
213213
214214
Records the start time and estimated cost for accuracy tracking.
215+
Idempotent: subsequent calls while the same step is running are
216+
no-ops, preventing repeated resets of _step_start_time that would
217+
corrupt step-duration learning.
215218
"""
219+
if self._step_start_time > 0:
220+
return # already tracking this step
216221
self._step_start_time = time.time()
217222
step = self.current_step
218223
if step:

src/brain/rules/survival.py

Lines changed: 11 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
from dataclasses import dataclass
88
from typing import TYPE_CHECKING
99

10+
from brain.flee import ( # noqa: F401 -- re-exported for backward compatibility
11+
FLEE_URGENCY_ENTER,
12+
FLEE_URGENCY_EXIT,
13+
_fight_winnable,
14+
_mob_attacking_player,
15+
compute_flee_urgency,
16+
)
1017
from brain.rule_def import Consideration
1118
from brain.rules.skip_log import SkipLog
1219
from brain.scoring.curves import inverse_linear, inverse_logistic
@@ -43,9 +50,7 @@ class _SurvivalRuleState:
4350
last_patrol_evade: float = 0.0
4451

4552

46-
# -- Flee urgency thresholds (hysteresis) --
47-
FLEE_URGENCY_ENTER = 0.65 # start fleeing at this urgency
48-
FLEE_URGENCY_EXIT = 0.35 # stop fleeing below this
53+
# Flee urgency thresholds and computation imported from brain.flee
4954

5055

5156
def _check_core_safety_floors(ctx: SurvivalView, state: GameState, label: str) -> bool | None:
@@ -67,145 +72,9 @@ def _check_core_safety_floors(ctx: SurvivalView, state: GameState, label: str) -
6772
return None
6873

6974

70-
def _fight_winnable(state: GameState) -> bool:
71-
"""True if player can finish the current fight without a pet.
72-
73-
Conditions: player HP > 60% AND target npc HP < 50%.
74-
At these thresholds a few Lifespikes will finish the npc.
75-
"""
76-
t = state.target
77-
if not t or t.hp_max <= 0:
78-
log.debug("[DECISION] _fight_winnable: no valid target")
79-
return False
80-
mob_hp: float = t.hp_current / max(t.hp_max, 1)
81-
winnable: bool = state.hp_pct > 0.60 and mob_hp < 0.50
82-
return winnable
83-
84-
85-
def _count_damaged_npcs(ctx: SurvivalView, state: GameState) -> int:
86-
"""Count damaged NPCs within 40u (for add detection)."""
87-
from brain.rules.skip_log import damaged_npcs_near
88-
89-
return len(damaged_npcs_near(ctx, state, state.pos, 40))
90-
91-
92-
def _mob_attacking_player(state: GameState) -> bool:
93-
"""Return True if any NPC within 30u is targeting the player."""
94-
for sp in state.spawns:
95-
if (
96-
sp.is_npc
97-
and sp.hp_current > 0
98-
and sp.target_name == state.name
99-
and state.pos.dist_to(sp.pos) < 30
100-
):
101-
return True
102-
return False
103-
104-
105-
def _urgency_hp(hp_pct: float) -> float:
106-
"""HP axis: power curve scaled so ~35% HP yields ~0.65."""
107-
result: float = ((1.0 - hp_pct) ** 1.8) * 1.45
108-
return result
109-
110-
111-
def _urgency_pet_died(ctx: SurvivalView, state: GameState, in_combat: bool) -> float:
112-
"""Pet died mid-combat with unwinnable fight: +0.4."""
113-
if in_combat and ctx.pet.just_died() and not _fight_winnable(state):
114-
return 0.4
115-
return 0.0
116-
117-
118-
def _urgency_adds(ctx: SurvivalView, state: GameState, in_combat: bool) -> float:
119-
"""Extra damaged NPCs nearby: +0.15 per add."""
120-
if not in_combat:
121-
return 0.0
122-
count = _count_damaged_npcs(ctx, state)
123-
return 0.15 * max(0, count - 1)
124-
125-
126-
def _urgency_target_dying(state: GameState) -> float:
127-
"""Target nearly dead (<15% HP): -0.25 to finish the defeat."""
128-
t = state.target
129-
if t and t.is_npc and t.hp_max > 0 and t.hp_current > 0:
130-
if t.hp_current / t.hp_max < 0.15:
131-
return -0.25
132-
return 0.0
133-
134-
135-
def _urgency_learned_danger(ctx: SurvivalView, in_combat: bool) -> float:
136-
"""Learned danger from FightHistory (>0.7): +0.2."""
137-
fh = ctx.fight_history
138-
if not fh or not in_combat:
139-
return 0.0
140-
name = ctx.defeat_tracker.last_fight_name
141-
if not name:
142-
return 0.0
143-
danger = fh.learned_danger(name)
144-
return 0.2 if danger is not None and danger > 0.7 else 0.0
145-
146-
147-
def _urgency_pet_hp_low(ctx: SurvivalView) -> float:
148-
"""Pet HP below 30%: +0.1."""
149-
world = ctx.world
150-
if ctx.pet.alive and world:
151-
pet_hp = world.pet_hp_pct
152-
if 0 <= pet_hp < 0.30:
153-
return 0.1
154-
return 0.0
155-
156-
157-
def _urgency_red_threat(ctx: SurvivalView) -> float:
158-
"""RED imminent threat: +0.5."""
159-
if ctx.threat.imminent_threat and ctx.threat.imminent_threat_con == "red":
160-
return 0.5
161-
return 0.0
162-
163-
164-
def _urgency_mob_on_player(ctx: SurvivalView, state: GameState) -> float:
165-
"""No pet + NPC attacking player: +0.5."""
166-
if not ctx.pet.alive and not ctx.combat.engaged and _mob_attacking_player(state):
167-
return 0.5
168-
return 0.0
169-
170-
171-
def compute_flee_urgency(ctx: SurvivalView, state: GameState) -> float:
172-
"""Composite flee urgency score (0.0 = safe, 1.0 = critical).
173-
174-
Eight axes contribute additively, then clamp to [0, 1].
175-
"""
176-
in_combat = ctx.in_active_combat
177-
178-
hp = _urgency_hp(state.hp_pct)
179-
pet_died = _urgency_pet_died(ctx, state, in_combat)
180-
adds = _urgency_adds(ctx, state, in_combat)
181-
target_dying = _urgency_target_dying(state)
182-
danger = _urgency_learned_danger(ctx, in_combat)
183-
pet_low = _urgency_pet_hp_low(ctx)
184-
red = _urgency_red_threat(ctx)
185-
mob_on = _urgency_mob_on_player(ctx, state)
186-
187-
result: float = max(0.0, min(1.0, hp + pet_died + adds + target_dying + danger + pet_low + red + mob_on))
188-
189-
if result >= FLEE_URGENCY_ENTER:
190-
factors = []
191-
if hp > 0.01:
192-
factors.append(f"hp={hp:.2f}")
193-
if pet_died > 0:
194-
factors.append("pet_died")
195-
if adds > 0:
196-
factors.append(f"extra_npcs={adds / 0.15:.0f}")
197-
if red > 0:
198-
factors.append("RED_threat")
199-
if mob_on > 0:
200-
factors.append("mob_on_player")
201-
log.info(
202-
"[DECISION] Flee urgency: %.3f [%s] combat=%s",
203-
result,
204-
" ".join(factors) if factors else "hp_only",
205-
in_combat,
206-
)
207-
208-
return result
75+
# _fight_winnable, _count_damaged_npcs, _mob_attacking_player, _urgency_*,
76+
# and compute_flee_urgency are imported from brain.flee at the top of this
77+
# module. They remain importable from here for backward compatibility.
20978

21079

21180
def _next_pull_mana_estimate(ctx: SurvivalView) -> int | None:

0 commit comments

Comments
 (0)