// A small teaching domain, shared with portfolio.wayrex.dev/dev-notes/goap/. // Pure C#: no Unity runtime dependency. Costs are relative effort, not seconds. using System; using System.Collections.Generic; namespace Wayrex.Notes.Goap { [Flags] public enum World { None = 0, Wood = 1, Axe = 2, Fire = 4, LooseWood = 8, AxeAtCamp = 16 } public enum PlanStatus { Found, Unreachable, BudgetReached } public sealed class GoapAction { public string Name { get; } public int Cost { get; } public World Required { get; } public World Forbidden { get; } public World Add { get; } public World Remove { get; } internal GoapAction(string name, int cost, World required, World forbidden, World add, World remove) { Name = name; Cost = cost; Required = required; Forbidden = forbidden; Add = add; Remove = remove; } public bool CanRun(World state) => (state & Required) == Required && (state & Forbidden) == World.None; // Predicted effects only. A game executor must confirm actual success. public World Predict(World state) => (state | Add) & ~Remove; } public sealed class PlanResult { public PlanStatus Status { get; } public IReadOnlyList Steps { get; } public int? Cost { get; } public int Expanded { get; } internal PlanResult(PlanStatus status, IReadOnlyList steps, int? cost, int expanded) { Status = status; Steps = steps; Cost = cost; Expanded = expanded; } } public static class GoapPlanner { private sealed class Node { public World State; public int Cost; public List Steps; public Node(World state, int cost, List steps) { State = state; Cost = cost; Steps = steps; } } public static PlanResult Plan(World start, int gatherCost = 4, int maxExpanded = 32) { if (((int)start & ~31) != 0) throw new ArgumentOutOfRangeException(nameof(start)); if (gatherCost < 1 || gatherCost > 8) throw new ArgumentOutOfRangeException(nameof(gatherCost)); if (maxExpanded < 0) throw new ArgumentOutOfRangeException(nameof(maxExpanded)); var actions = new[] { new GoapAction("Gather fallen wood", gatherCost, World.LooseWood, World.Wood, World.Wood, World.LooseWood), new GoapAction("Pick up axe", 2, World.AxeAtCamp, World.Axe, World.Axe, World.AxeAtCamp), new GoapAction("Chop wood", 1, World.Axe, World.Wood, World.Wood, World.None), new GoapAction("Light campfire", 1, World.Wood, World.Fire, World.Fire, World.Wood) }; var open = new List { new Node(start, 0, new List()) }; var best = new Dictionary { [start] = 0 }; int expanded = 0; while (open.Count > 0) { // Uniform-cost search. Strict comparison preserves insertion order on ties. int first = 0; for (int i = 1; i < open.Count; i++) if (open[i].Cost < open[first].Cost) first = i; var current = open[first]; open.RemoveAt(first); if (current.Cost != best[current.State]) continue; if ((current.State & World.Fire) != World.None) return new PlanResult(PlanStatus.Found, current.Steps.AsReadOnly(), current.Cost, expanded); if (expanded >= maxExpanded) return new PlanResult(PlanStatus.BudgetReached, Array.Empty(), null, expanded); expanded++; foreach (var action in actions) { if (!action.CanRun(current.State)) continue; World next = action.Predict(current.State); int cost = current.Cost + action.Cost; if (best.TryGetValue(next, out int knownCost) && cost >= knownCost) continue; best[next] = cost; var path = new List(current.Steps) { action }; open.Add(new Node(next, cost, path)); } } return new PlanResult(PlanStatus.Unreachable, Array.Empty(), null, expanded); } } }