Skip to main content

proof_engine/ai/
goap.rs

1//! Goal-Oriented Action Planning (GOAP).
2//!
3//! Each agent has a world state (map of bool conditions), a goal state, and
4//! a set of actions with preconditions and effects. The planner uses A* to
5//! find the cheapest sequence of actions that transforms current state into
6//! goal state.
7
8use std::collections::{HashMap, BinaryHeap, HashSet};
9use std::cmp::Ordering;
10
11// ── WorldState ────────────────────────────────────────────────────────────────
12
13/// A set of named boolean conditions describing world state.
14#[derive(Debug, Clone, PartialEq, Eq, Default)]
15pub struct WorldState(HashMap<String, bool>);
16
17impl WorldState {
18    pub fn new() -> Self { Self::default() }
19
20    pub fn set(&mut self, key: &str, value: bool) {
21        self.0.insert(key.to_string(), value);
22    }
23
24    pub fn get(&self, key: &str) -> bool {
25        *self.0.get(key).unwrap_or(&false)
26    }
27
28    /// Check if this state satisfies all conditions in `goal`.
29    pub fn satisfies(&self, goal: &WorldState) -> bool {
30        goal.0.iter().all(|(k, &v)| self.get(k) == v)
31    }
32
33    /// Apply an action's effects, returning a new state.
34    pub fn apply(&self, effects: &WorldState) -> WorldState {
35        let mut next = self.clone();
36        for (k, &v) in &effects.0 {
37            next.0.insert(k.clone(), v);
38        }
39        next
40    }
41
42    /// Distance heuristic: number of conditions in goal not satisfied.
43    pub fn distance_to(&self, goal: &WorldState) -> usize {
44        goal.0.iter().filter(|(k, &v)| self.get(k) != v).count()
45    }
46}
47
48// ── GoapAction ────────────────────────────────────────────────────────────────
49
50/// An action that can be taken by a GOAP agent.
51#[derive(Debug, Clone)]
52pub struct GoapAction {
53    pub name:          String,
54    pub cost:          f32,
55    pub preconditions: WorldState,
56    pub effects:       WorldState,
57    /// Optional: position/range requirements evaluated at plan time.
58    pub requires_in_range: Option<String>,
59}
60
61impl GoapAction {
62    pub fn new(name: &str, cost: f32) -> Self {
63        Self {
64            name: name.to_string(),
65            cost,
66            preconditions: WorldState::new(),
67            effects: WorldState::new(),
68            requires_in_range: None,
69        }
70    }
71
72    pub fn with_precondition(mut self, key: &str, value: bool) -> Self {
73        self.preconditions.set(key, value);
74        self
75    }
76
77    pub fn with_effect(mut self, key: &str, value: bool) -> Self {
78        self.effects.set(key, value);
79        self
80    }
81
82    pub fn requires_range(mut self, target: &str) -> Self {
83        self.requires_in_range = Some(target.to_string());
84        self
85    }
86
87    pub fn is_applicable(&self, state: &WorldState) -> bool {
88        state.satisfies(&self.preconditions)
89    }
90}
91
92// ── A* search node ────────────────────────────────────────────────────────────
93
94#[derive(Clone)]
95struct SearchNode {
96    state:    WorldState,
97    path:     Vec<String>, // action names taken
98    cost:     f32,
99    heuristic: usize,
100}
101
102impl PartialEq for SearchNode {
103    fn eq(&self, other: &Self) -> bool { self.f() == other.f() }
104}
105impl Eq for SearchNode {}
106
107impl PartialOrd for SearchNode {
108    fn partial_cmp(&self, other: &Self) -> Option<Ordering> { Some(self.cmp(other)) }
109}
110
111impl Ord for SearchNode {
112    fn cmp(&self, other: &Self) -> Ordering {
113        other.f_ord().cmp(&self.f_ord()) // min-heap
114    }
115}
116
117impl SearchNode {
118    fn f(&self) -> f32 { self.cost + self.heuristic as f32 }
119    fn f_ord(&self) -> u64 { (self.f() * 1000.0) as u64 }
120}
121
122// ── GoapPlanner ───────────────────────────────────────────────────────────────
123
124/// Plans a sequence of actions to reach goal state from current state.
125pub struct GoapPlanner;
126
127impl GoapPlanner {
128    /// Returns the cheapest action plan, or None if no plan exists.
129    pub fn plan(
130        start: &WorldState,
131        goal:  &WorldState,
132        actions: &[GoapAction],
133        max_depth: usize,
134    ) -> Option<Vec<String>> {
135        let mut open: BinaryHeap<SearchNode> = BinaryHeap::new();
136        let mut closed: Vec<WorldState>  = Vec::new();
137
138        open.push(SearchNode {
139            state: start.clone(),
140            path: Vec::new(),
141            cost: 0.0,
142            heuristic: start.distance_to(goal),
143        });
144
145        while let Some(node) = open.pop() {
146            if node.state.satisfies(goal) {
147                return Some(node.path);
148            }
149            if node.path.len() >= max_depth { continue; }
150            if closed.contains(&node.state) { continue; }
151            closed.push(node.state.clone());
152
153            for action in actions {
154                if !action.is_applicable(&node.state) { continue; }
155                let next_state = node.state.apply(&action.effects);
156                if closed.iter().any(|s| s == &next_state) { continue; }
157                let mut path = node.path.clone();
158                path.push(action.name.clone());
159                open.push(SearchNode {
160                    heuristic: next_state.distance_to(goal),
161                    state: next_state,
162                    path,
163                    cost: node.cost + action.cost,
164                });
165            }
166        }
167        None
168    }
169}
170
171// ── GoapAgent ─────────────────────────────────────────────────────────────────
172
173/// A runtime GOAP agent that re-plans when its goal or state changes.
174pub struct GoapAgent<W> {
175    pub name:       String,
176    pub world_state: WorldState,
177    pub goal:       WorldState,
178    pub actions:    Vec<GoapAction>,
179    current_plan:   Vec<String>,
180    plan_step:      usize,
181    pub max_depth:  usize,
182    /// Callbacks: action_name → execute fn.
183    executors:      HashMap<String, Box<dyn Fn(&mut W, &mut WorldState) -> ActionResult + Send + Sync>>,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq)]
187pub enum ActionResult {
188    /// Still executing.
189    InProgress,
190    /// Action completed; advance plan.
191    Done,
192    /// Action failed; re-plan.
193    Failed,
194}
195
196impl<W> GoapAgent<W> {
197    pub fn new(name: &str) -> Self {
198        Self {
199            name: name.to_string(),
200            world_state: WorldState::new(),
201            goal: WorldState::new(),
202            actions: Vec::new(),
203            current_plan: Vec::new(),
204            plan_step: 0,
205            max_depth: 10,
206            executors: HashMap::new(),
207        }
208    }
209
210    pub fn add_action(&mut self, action: GoapAction) {
211        self.actions.push(action);
212    }
213
214    pub fn add_executor(
215        &mut self,
216        name: &str,
217        func: impl Fn(&mut W, &mut WorldState) -> ActionResult + Send + Sync + 'static,
218    ) {
219        self.executors.insert(name.to_string(), Box::new(func));
220    }
221
222    pub fn set_state(&mut self, key: &str, value: bool) {
223        self.world_state.set(key, value);
224    }
225
226    pub fn set_goal(&mut self, key: &str, value: bool) {
227        self.goal.set(key, value);
228        self.current_plan.clear();
229        self.plan_step = 0;
230    }
231
232    /// Call each frame. Returns None if no plan/goal, Some(action_name) for current action.
233    pub fn tick(&mut self, world: &mut W) -> Option<String> {
234        // Re-plan if needed
235        if self.plan_step >= self.current_plan.len() {
236            if self.world_state.satisfies(&self.goal) {
237                return None; // Already at goal
238            }
239            match GoapPlanner::plan(&self.world_state, &self.goal, &self.actions, self.max_depth) {
240                Some(plan) => { self.current_plan = plan; self.plan_step = 0; }
241                None       => return None,
242            }
243        }
244
245        let action_name = self.current_plan[self.plan_step].clone();
246
247        if let Some(executor) = self.executors.get(&action_name) {
248            let result = executor(world, &mut self.world_state);
249            match result {
250                ActionResult::Done     => { self.plan_step += 1; }
251                ActionResult::Failed   => {
252                    self.current_plan.clear();
253                    self.plan_step = 0;
254                }
255                ActionResult::InProgress => {}
256            }
257        } else {
258            // No executor registered — auto-advance
259            self.plan_step += 1;
260        }
261
262        Some(action_name)
263    }
264
265    pub fn current_plan(&self) -> &[String] { &self.current_plan }
266    pub fn has_goal(&self) -> bool { !self.goal.0.is_empty() }
267    pub fn plan_length(&self) -> usize { self.current_plan.len() }
268}
269
270// ── Pre-built action sets ─────────────────────────────────────────────────────
271
272/// Standard combat actions for a melee fighter.
273pub fn melee_combat_actions() -> Vec<GoapAction> {
274    vec![
275        GoapAction::new("move_to_target", 1.0)
276            .with_precondition("has_target", true)
277            .with_effect("in_range", true),
278        GoapAction::new("attack", 1.0)
279            .with_precondition("has_target", true)
280            .with_precondition("in_range", true)
281            .with_precondition("weapon_ready", true)
282            .with_effect("target_dead", true),
283        GoapAction::new("equip_weapon", 2.0)
284            .with_precondition("has_weapon", true)
285            .with_effect("weapon_ready", true),
286        GoapAction::new("pick_up_weapon", 1.5)
287            .with_precondition("weapon_nearby", true)
288            .with_effect("has_weapon", true),
289        GoapAction::new("flee", 1.0)
290            .with_precondition("low_health", true)
291            .with_effect("safe", true),
292        GoapAction::new("heal", 2.0)
293            .with_precondition("has_potion", true)
294            .with_effect("low_health", false),
295    ]
296}
297
298/// Patrol and investigation actions.
299pub fn guard_actions() -> Vec<GoapAction> {
300    vec![
301        GoapAction::new("patrol", 1.0)
302            .with_effect("patrolling", true),
303        GoapAction::new("investigate_noise", 1.5)
304            .with_precondition("heard_noise", true)
305            .with_effect("area_clear", true)
306            .with_effect("heard_noise", false),
307        GoapAction::new("sound_alarm", 2.0)
308            .with_precondition("sees_intruder", true)
309            .with_effect("alarm_raised", true),
310        GoapAction::new("chase_intruder", 1.0)
311            .with_precondition("sees_intruder", true)
312            .with_effect("in_range", true),
313        GoapAction::new("return_to_post", 0.5)
314            .with_effect("at_post", true),
315    ]
316}
317
318// ── Tests ─────────────────────────────────────────────────────────────────────
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn test_world_state_satisfies() {
326        let mut state = WorldState::new();
327        state.set("alive", true);
328        state.set("armed", false);
329
330        let mut goal = WorldState::new();
331        goal.set("alive", true);
332        assert!(state.satisfies(&goal));
333
334        goal.set("armed", true);
335        assert!(!state.satisfies(&goal));
336    }
337
338    #[test]
339    fn test_world_state_apply() {
340        let mut state = WorldState::new();
341        state.set("alive", true);
342        let mut effects = WorldState::new();
343        effects.set("armed", true);
344        let next = state.apply(&effects);
345        assert!(next.get("alive"));
346        assert!(next.get("armed"));
347    }
348
349    #[test]
350    fn test_planner_finds_plan() {
351        let actions = melee_combat_actions();
352
353        let mut start = WorldState::new();
354        start.set("has_target", true);
355        start.set("in_range", false);
356        start.set("weapon_ready", true);
357
358        let mut goal = WorldState::new();
359        goal.set("target_dead", true);
360
361        let plan = GoapPlanner::plan(&start, &goal, &actions, 5);
362        assert!(plan.is_some(), "should find a plan");
363        let plan = plan.unwrap();
364        assert!(plan.contains(&"move_to_target".to_string()));
365        assert!(plan.contains(&"attack".to_string()));
366    }
367
368    #[test]
369    fn test_planner_longer_chain() {
370        let actions = melee_combat_actions();
371
372        let mut start = WorldState::new();
373        start.set("has_target", true);
374        start.set("weapon_nearby", true);
375
376        let mut goal = WorldState::new();
377        goal.set("target_dead", true);
378
379        let plan = GoapPlanner::plan(&start, &goal, &actions, 8);
380        assert!(plan.is_some(), "should plan pick_up → equip → move → attack chain");
381    }
382
383    #[test]
384    fn test_planner_no_possible_plan() {
385        let mut start = WorldState::new();
386        start.set("has_target", false);
387
388        let mut goal = WorldState::new();
389        goal.set("target_dead", true);
390
391        // No action to acquire target
392        let actions = vec![
393            GoapAction::new("attack", 1.0)
394                .with_precondition("has_target", true)
395                .with_effect("target_dead", true),
396        ];
397
398        let plan = GoapPlanner::plan(&start, &goal, &actions, 5);
399        assert!(plan.is_none());
400    }
401
402    #[test]
403    fn test_agent_executes_plan() {
404        let mut agent: GoapAgent<Vec<String>> = GoapAgent::new("test_agent");
405
406        agent.add_action(
407            GoapAction::new("do_thing", 1.0)
408                .with_effect("thing_done", true),
409        );
410
411        agent.add_executor("do_thing", |world, state| {
412            world.push("did_thing".to_string());
413            state.set("thing_done", true);
414            ActionResult::Done
415        });
416
417        agent.set_goal("thing_done", true);
418
419        let mut world: Vec<String> = Vec::new();
420        let action = agent.tick(&mut world);
421        assert_eq!(action, Some("do_thing".to_string()));
422        assert!(world.contains(&"did_thing".to_string()));
423    }
424
425    #[test]
426    fn test_agent_no_replan_at_goal() {
427        let mut agent: GoapAgent<()> = GoapAgent::new("agent");
428        let mut state = agent.world_state.clone();
429        state.set("done", true);
430        agent.world_state = state;
431        agent.set_goal("done", true);
432        let mut world = ();
433        let action = agent.tick(&mut world);
434        assert!(action.is_none(), "already at goal — no action needed");
435    }
436
437    #[test]
438    fn test_guard_actions_plan() {
439        let actions = guard_actions();
440        let mut start = WorldState::new();
441        start.set("heard_noise", true);
442        let mut goal = WorldState::new();
443        goal.set("area_clear", true);
444        let plan = GoapPlanner::plan(&start, &goal, &actions, 3);
445        assert!(plan.is_some());
446        assert!(plan.unwrap().contains(&"investigate_noise".to_string()));
447    }
448}