-
Notifications
You must be signed in to change notification settings - Fork 0
GOAP Planning
AzureCortex's GOAP (Goal-Oriented Action Planning) layer is intentionally thin: it ships no scoring logic at all — every mod's notion of "what's a good goal right now" is domain-specific. What it does provide is the bookkeeping around committing to a goal, deciding when to reconsider, and feeding failure information back into scoring.
| Type | Role |
|---|---|
Goal |
Marker interface your goal-type enum implements. |
GoalPlanner<E, G> |
Your scoring strategy: chooseGoal(agent, blackboard, cooldowns) -> PlannedGoal<E, G>. |
PlannedGoal<E, G> |
An immutable committed goal: type, score, commit-time window, target/destination, urgency, reason. |
GoalExecutor |
Writes a PlannedGoal to the blackboard; provides the shouldReplan gate. |
GoalUrgency |
LOW / NORMAL / HIGH / EMERGENCY, only EMERGENCY has special handling (bypasses min-commit). |
PlanFailureReason |
Structured codes an action reports back when it can't make progress. |
PlanFeedback<G> |
What CortexRuntime writes to the blackboard from an action's non-NONE failure reason. |
GoalFailureCooldowns<G> |
Per-goal-type score penalty that decays over time after a recorded failure. |
WorldStateSnapshot / PlanInvalidation
|
Coarse, goal-agnostic "does the world still look like it did when I committed to this?" check. |
EmergencyDetector |
Cheap, pre-planner probes (on fire, critical health, ...) that can force an immediate replan. |
public enum MyGoalType implements Goal {
NONE,
WANDER,
INVESTIGATE,
HUNT_TARGET,
EAT_TO_HEAL;
@Override
public boolean isNone() {
return this == NONE;
}
}isNone() matters: PlannedGoal.isNone() checks it, and framework code (like the emergency-vs-passive check in the
example entity) relies on being able to tell "no real goal yet" apart from an ordinary low-priority goal.
A planner is a plain priority chain, or a weighted scorer, AzureCortex doesn't care. The bundled example
(CortexZombieGoalPlanner) is a simple linear chain:
public final class MyGoalPlanner implements GoalPlanner<MyEntity, MyGoalType> {
@Override
public PlannedGoal<MyEntity, MyGoalType> chooseGoal(MyEntity agent, Blackboard blackboard, CooldownTracker cooldowns) {
var currentTick = (int) agent.level().getGameTime();
// 1. Emergency-tier check first (highest priority, bypasses commit locks)
if (isCriticallyWoundedAndCanHeal(agent)) {
return PlannedGoal.of(
MyGoalType.EAT_TO_HEAL, 100f, currentTick,
10, 100, // minCommitTicks, maxCommitTicks
null, null,
GoalUrgency.EMERGENCY, true,
"Wounded and carrying a golden apple"
);
}
// 2. Read failure feedback and apply score penalties
var failureCooldowns = GoalFailureCooldowns.<MyGoalType>getOrCreate(blackboard);
failureCooldowns.evictExpired(currentTick);
var feedback = (PlanFeedback<MyGoalType>) blackboard.get(CommonBlackboardKeys.LAST_PLAN_FEEDBACK);
if (feedback != null && feedback.isFresh(currentTick)
&& (feedback.reason() == PlanFailureReason.FAILED_STUCK
|| feedback.reason() == PlanFailureReason.FAILED_NO_PATH)) {
failureCooldowns.recordFailure(MyGoalType.HUNT_TARGET, currentTick);
}
// 3. Ordinary candidates, in priority order
var target = blackboard.get(CommonBlackboardKeys.TARGET);
if (target != null && target.isAlive()) {
var score = 50f - failureCooldowns.getPenalty(MyGoalType.HUNT_TARGET, currentTick);
return PlannedGoal.of(MyGoalType.HUNT_TARGET, score, currentTick, 20, 60,
target, null, GoalUrgency.NORMAL, true, "Live target acquired");
}
// ... investigate, wander, etc.
return PlannedGoal.of(MyGoalType.WANDER, 10f, currentTick, 20, 200,
null, null, GoalUrgency.LOW, true, "Nothing better to do");
}
}The planner isn't ticked by CortexRuntime automatically, you call it yourself, gated by GoalExecutor.shouldReplan,
typically right before runtime.tick():
@Override
public void tick() {
super.tick();
if (!level().isClientSide() && isAlive() && !isNoAi()) {
tickGoalPlanner();
runtime.tick();
}
}
private void tickGoalPlanner() {
var blackboard = runtime.getBlackboard();
var cooldowns = runtime.getCooldowns();
var currentTick = (int) level().getGameTime();
var activeGoalType = blackboard.get(CommonBlackboardKeys.ACTIVE_GOAL_TYPE);
var isPassive = activeGoalType == null || activeGoalType == MyGoalType.NONE || activeGoalType == MyGoalType.WANDER;
var reactiveReplan = isPassive && blackboard.has(CommonBlackboardKeys.TARGET);
var preplanUrgency = EmergencyDetector.detectPreplanUrgency(this, emergencyProbes);
if (!reactiveReplan && preplanUrgency == null && cooldowns.isOnCooldown(CommonBlackboardKeys.GOAL_REPLAN))
return;
if (!reactiveReplan && !GoalExecutor.shouldReplan(blackboard, currentTick, preplanUrgency, this))
return;
cooldowns.set(CommonBlackboardKeys.GOAL_REPLAN, 20);
var newGoal = goalPlanner.chooseGoal(this, blackboard, cooldowns);
GoalExecutor.apply(this, blackboard, newGoal);
}This shape, reactiveReplan for "was passively wandering and just noticed a target," an EmergencyDetector check,
then falling back to a plain replan cooldown, is a convention from the bundled example, not something the framework
enforces. It exists so an idle agent reacts to a newly-acquired target immediately rather than waiting out a stale
20-tick cooldown.
This is the actual policy governing when the planner is allowed to run:
-
Emergency override, a candidate urgency of
EMERGENCYalways forces a replan, ignoring commit timers. -
Min-commit lock, suppressed until
PlannedGoal.canReplan(tick)(i.e.minCommitTickshave elapsed). Prevents thrashing between two closely-scored goals. -
Max-commit expiry,
PlannedGoal.isExpired(tick)forces a replan regardless, so a goal can't run forever if the agent gets stuck in a state that never self-terminates. - World-state invalidation, see below; forces a replan the moment the facts that justified the current plan stop being true, even if nothing has technically failed yet.
-
Normal replan, otherwise, gated by whatever cooldown the caller maintains (
CommonBlackboardKeys.GOAL_REPLANis the conventional one).
Actions never write failure feedback to the blackboard themselves. Instead, Action.tick() returns
ActionOutcome.blocked(reason, ...) (still running, early signal) or ActionOutcome.failed(reason, ...) (terminating),
and CortexRuntime is the single place that translates that into CommonBlackboardKeys.LAST_PLAN_FEEDBACK:
if (path == null) {
return ActionOutcome.failed(PlanFailureReason.FAILED_NO_PATH, mob.blockPosition());
}PlanFailureReason is a fixed, domain-agnostic enum: FAILED_NO_PATH, FAILED_STUCK, FAILED_TARGET_LOST,
FAILED_MISSING_INFRASTRUCTURE, FAILED_UNSUITABLE_CONDITIONS, FAILED_BLOCKED, FAILED_DANGER, FAILED_COOLDOWN,
FAILED_PRECONDITION, FAILED_OBSTACLE_UNBREAKABLE, FAILED_NO_VALID_PLACEMENT. Your planner decides what each one
means for your goals (e.g. bias toward an "investigate" goal on FAILED_TARGET_LOST).
PlanFeedback.isFresh(tick) returns true for feedback recorded within the last 80 ticks, check this before acting on
it so stale feedback from several planning cycles ago doesn't keep influencing scores.
var gfc = GoalFailureCooldowns.<MyGoalType>getOrCreate(blackboard);
gfc.evictExpired(currentTick);
huntScore -= gfc.getPenalty(MyGoalType.HUNT_TARGET, currentTick);
// on failure:
gfc.recordFailure(MyGoalType.HUNT_TARGET, currentTick, 100); // suppress for 100 ticksThe penalty isn't binary, it decays linearly from 60 at the moment of failure to 0 at expiry, so a goal that just failed is heavily suppressed but recovers gradually rather than snapping back on.
PlanFeedback is reactive, it only exists if the running action itself notices something went wrong on its own
tick. WorldStateSnapshot/PlanInvalidation sit above that: a cheap, coarse snapshot (target identity, distance
bucket, health bucket, in-darkness) is captured the moment a plan commits (GoalExecutor.apply does this
automatically), and compared against live state every tick. If they've diverged enough, target changed, health
dropped a bucket, darkness state flipped, distance changed by two-or-more buckets, a replan is forced even though no
action has failed yet.
This is deliberately coarse (buckets, not exact values) so ordinary noise never trips it, it should only fire on changes big enough that "does this plan still make sense" is a legitimate question.
Scoring candidates requires running the planner, but shouldReplan's emergency override needs to know the candidate's
urgency before the planner runs. EmergencyDetector breaks this chicken-and-egg problem with cheap, pluggable probes
that run every tick without allocating or scoring anything:
private List<EmergencyDetector.EmergencyProbe<MyEntity>> buildEmergencyProbes() {
var probes = new ArrayList<>(EmergencyDetector.<MyEntity>defaultProbes()); // on-fire + critical-health
probes.add(agent -> agent.getHealth() / agent.getMaxHealth() <= 0.4f
&& agent.getOffhandItem().is(Items.GOLDEN_APPLE));
return probes;
}
// each tick:
var urgency = EmergencyDetector.detectPreplanUrgency(this, emergencyProbes); // EMERGENCY or nulldefaultProbes() bundles the two conditions that apply to virtually any mob (on fire, critical health); add your own
mod-specific probes (nearby explosives, a specific predator, ...) to the list.
GoalExecutor.apply(this, blackboard, newGoal);This writes ACTIVE_GOAL, ACTIVE_GOAL_TYPE, LAST_GOAL_REASON, and (if present) GOAL_TARGET/GOAL_DESTINATION to
the blackboard; clears stale LAST_PLAN_FEEDBACK/LAST_FAILURE_REASON; and captures a fresh WorldStateSnapshot for
PlanInvalidation to compare against going forward.
-
Sensing for how
TARGET/LAST_SEEN_POSactually get populated. -
Behavior Trees for how
ACTIVE_GOAL_TYPEmaps to an actual running action. - Full Example Walkthrough to see the whole loop assembled.