using HSchool.Content; namespace HSchool.Ai; public enum GoalKind { None, Duty, Apparel, Need, Leisure, } /// What this person is currently pursuing. Compared against a new winner using switchMargin. public readonly record struct Intent(GoalKind Kind, string? Id, float Weight, string? ActionId) { public static Intent None { get; } = new(GoalKind.None, null, 0f, null); public bool IsSet => Kind != GoalKind.None; } /// Walk, start an action, or stay. Heading home is not a goal — the day plan handles it. public readonly record struct Decision( string? WalkTo, string? StartAction, Intent Intent) { public static Decision Stay(Intent intent) => new(null, null, intent); } public readonly record struct ActorState( string? NodeId, string? DestinationId, bool IsWalking, bool ActivityActive, bool IsStudent, bool IsStaff, bool IsParent, bool BoundToLesson, string? DutyRoom, IReadOnlyDictionary Needs, Intent Intent, bool LunchWindowOpen = false, ApparelActor Apparel = default, TalkPlannerContext Talk = default); /// /// Picks a goal by weight and plans walk-then-do. No world, no clock — a table of inputs to an /// output. Duty is a strong goal, not an order; a need at zero beats it, a need just under the /// threshold does not. /// public static class DecisionPlanner { // Fallbacks when the catalog has no BehaviorDef. Vanilla writes the same numbers into the // def; a pack that omits the ruleset must keep today's decisions, not empty the classrooms. /// Lesson or posted work. Beats leisure and a need that only just crossed the threshold. public const float DutyLessonWeight = 10f; /// Walk to the next room on a break. Beats chatting in the corridor you are standing in. public const float DutyTravelWeight = 5f; /// Need at zero. Beats a lesson so a desperate toilet trip leaves class. public const float NeedWeightAtZero = 20f; /// /// A sitting during this parallel's own lunch break. Above so /// lunch beats walking on to the next room, below so it never /// pulls anybody out of a lesson. Lunch is a timetable, not an urge: waiting for hunger to /// cross the threshold made the juniors miss their sitting and starve all afternoon. /// public const float LunchWeight = 6f; public static Decision Decide( DefCatalog catalog, MapLayout map, WalkGraph walks, ActorState state, OccupiedCount occupied) { ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(map); ArgumentNullException.ThrowIfNull(walks); ArgumentNullException.ThrowIfNull(occupied); var rules = Rules.From(catalog); var best = PickGoal(catalog, map, walks, state, occupied, rules); var held = HeldGoal(catalog, map, walks, state, occupied, rules); if (held.IsSet && best.Weight <= held.Weight + rules.Margin && SameGoal(state.Intent, held)) { return Continue(state with { Intent = held }); } return Plan(catalog, map, walks, state, occupied, best); } private static Intent PickGoal( DefCatalog catalog, MapLayout map, WalkGraph walks, ActorState state, OccupiedCount occupied, Rules rules) { var best = Intent.None; Consider(ref best, DutyGoal(state, rules)); if (ApparelGoals.Goal(catalog, map, walks, state.Apparel, state, occupied) is { } apparelGoal) { Consider(ref best, apparelGoal); } foreach (var need in catalog.Needs.Values.OrderBy(def => def.DefName, StringComparer.Ordinal)) { Consider(ref best, NeedGoal(catalog, map, walks, state, occupied, need, rules)); } if (best.Kind != GoalKind.Duty || best.Weight < rules.DutyLesson) { foreach (var action in catalog.Actions.Values.OrderBy(def => def.DefName, StringComparer.Ordinal)) { Consider(ref best, LeisureGoal(catalog, map, walks, state, occupied, action)); } } return best; } /// /// The stored intent's weight under current inputs. A lesson that just ended is not still /// worth the lesson weight — otherwise leisure can never beat a stale duty. /// private static Intent HeldGoal( DefCatalog catalog, MapLayout map, WalkGraph walks, ActorState state, OccupiedCount occupied, Rules rules) { if (!state.Intent.IsSet) { return Intent.None; } switch (state.Intent.Kind) { case GoalKind.Duty: return DutyGoal(state, rules); case GoalKind.Apparel: return ApparelGoals.Goal(catalog, map, walks, state.Apparel, state, occupied) ?? Intent.None; case GoalKind.Need: if (state.Intent.Id is null || !catalog.Needs.TryGetValue(state.Intent.Id, out var need)) { return Intent.None; } return NeedGoal(catalog, map, walks, state, occupied, need, rules); case GoalKind.Leisure: if (state.Intent.ActionId is null || !catalog.Actions.TryGetValue(state.Intent.ActionId, out var action)) { return Intent.None; } return LeisureGoal(catalog, map, walks, state, occupied, action); default: return Intent.None; } } private static Intent DutyGoal(ActorState state, Rules rules) { if (state.DutyRoom is null) { return Intent.None; } if (state.BoundToLesson) { return new Intent(GoalKind.Duty, state.DutyRoom, rules.DutyLesson, null); } if (state.Intent.Kind == GoalKind.Leisure) { return Intent.None; } if (state.NodeId is not null && state.NodeId.Equals(state.DutyRoom, StringComparison.Ordinal) && !state.IsWalking) { // Arrived this break (travel-weight duty). Stay put so a 3-weight chat does not // pull them out of the gym they just walked to. A leftover lesson intent in the // same room is the other case: the next lesson is here, leisure can win. if (state.Intent.Kind == GoalKind.Duty && string.Equals(state.Intent.Id, state.DutyRoom, StringComparison.Ordinal) && state.Intent.Weight <= rules.DutyTravel) { return new Intent(GoalKind.Duty, state.DutyRoom, rules.DutyTravel, null); } return Intent.None; } return new Intent(GoalKind.Duty, state.DutyRoom, rules.DutyTravel, null); } private static Intent NeedGoal( DefCatalog catalog, MapLayout map, WalkGraph walks, ActorState state, OccupiedCount occupied, NeedDef need, Rules rules) { if (need.Abstract || !state.Needs.TryGetValue(need.DefName, out var value)) { return Intent.None; } var urgent = value < rules.Threshold; // ActionForNeed already refuses a sitting outside its window, so an action that comes back // Lunch means this person's own break is open right now. var action = ActionForNeed(catalog, state, need.DefName); if (action is null || (!urgent && !action.Lunch)) { return Intent.None; } if (RoomFor(catalog, map, walks, state, occupied, action) is null) { return Intent.None; } var span = Math.Max(rules.Threshold, 0.0001f); var weight = urgent ? (rules.Threshold - value) / span * rules.NeedAtZero : 0f; if (action.Lunch) { weight = Math.Max(weight, rules.Lunch); } return new Intent(GoalKind.Need, need.DefName, weight, action.DefName); } private static Intent LeisureGoal( DefCatalog catalog, MapLayout map, WalkGraph walks, ActorState state, OccupiedCount occupied, ActionDef action) { if (action.Abstract || !RoleFits(action, state)) { return Intent.None; } if (state.BoundToLesson && TalkCircles.BlocksOnLesson(action.DefName)) { return Intent.None; } if (TalkActions.IsTeacherTalk(action.DefName)) { if (state.BoundToLesson) { return Intent.None; } var teacherWeight = catalog.BehaviorRules?.TeacherAfterLessonWeight ?? 0f; if (teacherWeight <= 0) { return Intent.None; } if (RoomFor(catalog, map, walks, state, occupied, action) is null) { return Intent.None; } return new Intent(GoalKind.Leisure, action.DefName, teacherWeight, action.DefName); } if (action.Weight <= 0) { return Intent.None; } if (action.DefName.Equals(TalkActions.PhoneChat, StringComparison.Ordinal) && !state.Talk.HasPhone) { return Intent.None; } if (RoomFor(catalog, map, walks, state, occupied, action) is null) { return Intent.None; } return new Intent(GoalKind.Leisure, action.DefName, action.Weight, action.DefName); } private static Decision Continue(ActorState state) { if (state.ActivityActive) { return Decision.Stay(state.Intent); } if (state.Intent.ActionId is not null && !state.IsWalking && state.NodeId is not null && (state.DestinationId is null || state.NodeId.Equals(state.DestinationId, StringComparison.Ordinal))) { return new Decision(null, state.Intent.ActionId, state.Intent); } if (state.Intent.Kind == GoalKind.Duty && state.NodeId is not null && state.Intent.Id is not null && state.NodeId.Equals(state.Intent.Id, StringComparison.Ordinal) && !state.IsWalking) { return Decision.Stay(state.Intent); } return new Decision(state.DestinationId ?? state.Intent.Id, null, state.Intent); } private static Decision Plan( DefCatalog catalog, MapLayout map, WalkGraph walks, ActorState state, OccupiedCount occupied, Intent goal) { if (!goal.IsSet) { return Decision.Stay(Intent.None); } if (goal.Kind == GoalKind.Duty) { var room = goal.Id; if (room is null || (state.NodeId is not null && state.NodeId.Equals(room, StringComparison.Ordinal) && !state.IsWalking)) { return Decision.Stay(goal); } return new Decision(room, null, goal); } if (goal.Kind == GoalKind.Apparel) { if (goal.ActionId is null || !catalog.Actions.TryGetValue(goal.ActionId, out var apparelAction)) { return Decision.Stay(Intent.None); } var apparelNode = RoomFor(catalog, map, walks, state, occupied, apparelAction); if (apparelNode is null) { return Decision.Stay(Intent.None); } if (state.NodeId is not null && state.NodeId.Equals(apparelNode, StringComparison.Ordinal) && !state.IsWalking) { return new Decision(null, apparelAction.DefName, goal); } return new Decision(apparelNode, null, goal); } if (goal.ActionId is null || !catalog.Actions.TryGetValue(goal.ActionId, out var action)) { return Decision.Stay(Intent.None); } var node = RoomFor(catalog, map, walks, state, occupied, action); if (node is null) { return Decision.Stay(Intent.None); } if (state.NodeId is not null && state.NodeId.Equals(node, StringComparison.Ordinal) && !state.IsWalking) { return new Decision(null, action.DefName, goal); } return new Decision(node, null, goal); } private static ActionDef? ActionForNeed(DefCatalog catalog, ActorState state, string need) { ActionDef? best = null; foreach (var action in catalog.Actions.Values.OrderBy(def => def.DefName, StringComparer.Ordinal)) { if (action.Abstract || !need.Equals(action.Need, StringComparison.Ordinal) || !RoleFits(action, state) || (state.BoundToLesson && TalkCircles.BlocksOnLesson(action.DefName))) { continue; } // A sitting is only on offer during this person's own lunch break. Outside it hunger // keeps building instead of pulling somebody out of a lesson — that is what keeps the // canteen from filling with the whole school at once. if (action.Lunch && !state.LunchWindowOpen) { continue; } if (best is null || action.NeedGain > best.NeedGain) { best = action; } } return best; } private static string? RoomFor( DefCatalog catalog, MapLayout map, WalkGraph walks, ActorState state, OccupiedCount occupied, ActionDef action) { if (string.IsNullOrWhiteSpace(action.Room)) { return null; } string? best = null; var bestScore = float.NegativeInfinity; var from = state.NodeId ?? walks.TerritoryId; var candidates = new List(); foreach (var room in map.Rooms) { if (!action.Room.Equals(room.Def, StringComparison.Ordinal)) { continue; } if (!HasSlot(catalog, map, occupied, room.Id, action.Thing)) { continue; } candidates.Add(room.Id); } if (map.Territory is { } territory && action.Room.Equals(territory.Def, StringComparison.Ordinal) && HasSlot(catalog, map, occupied, territory.Id, action.Thing)) { candidates.Add(territory.Id); } var hasAlternative = candidates.Count > 1; foreach (var nodeId in candidates) { if (action.Lunch && TalkCircles.LunchNodeBlockedByEnemy(nodeId, state.Talk.SelfId ?? "", state.Talk, hasAlternative)) { continue; } var cost = walks.Minutes(from, nodeId); if (float.IsInfinity(cost)) { continue; } var score = -cost + TalkCircles.NodeFriendScore(nodeId, state.Talk.SelfId ?? "", state.Talk); if (score > bestScore || (score == bestScore && (best is null || string.CompareOrdinal(nodeId, best) < 0))) { best = nodeId; bestScore = score; } } return best; } private static bool HasSlot(DefCatalog catalog, MapLayout map, OccupiedCount occupied, string nodeId, string? thing) { if (string.IsNullOrWhiteSpace(thing)) { return true; } var available = RoomOccupancy.ThingCount(catalog, map, nodeId, thing); return ActionStepper.CanOccupy(available, occupied(nodeId, thing)); } private static bool RoleFits(ActionDef action, ActorState state) { if (action.Roles.Count == 0) { return true; } foreach (var role in action.Roles) { if (role.Equals(PersonRoles.Student, StringComparison.OrdinalIgnoreCase) && state.IsStudent) { return true; } if (role.Equals(PersonRoles.Staff, StringComparison.OrdinalIgnoreCase) && state.IsStaff) { return true; } if (role.Equals(PersonRoles.Parent, StringComparison.OrdinalIgnoreCase) && state.IsParent) { return true; } } return false; } private static bool SameGoal(Intent current, Intent held) { if (current.Kind != held.Kind) { return false; } // A new duty room (classroom → gym on the break) is not the same goal even at a similar // weight; keeping the old id would walk back to the lesson they just left. if (current.Kind == GoalKind.Duty) { return string.Equals(current.Id, held.Id, StringComparison.Ordinal); } return true; } private static void Consider(ref Intent best, Intent candidate) { if (!candidate.IsSet) { return; } if (!best.IsSet || candidate.Weight > best.Weight || (candidate.Weight == best.Weight && Order(candidate.Kind) < Order(best.Kind)) || (candidate.Weight == best.Weight && candidate.Kind == best.Kind && string.CompareOrdinal(candidate.Id, best.Id) < 0)) { best = candidate; } } private static int Order(GoalKind kind) => kind switch { GoalKind.Duty => 0, GoalKind.Apparel => 1, GoalKind.Need => 2, GoalKind.Leisure => 3, _ => 4, }; /// Effective numbers: the catalog's BehaviorDef, or the constants when a pack has none. private readonly record struct Rules( float DutyLesson, float DutyTravel, float NeedAtZero, float Lunch, float Threshold, float Margin) { public static Rules From(DefCatalog catalog) { var def = catalog.BehaviorRules; return new( def?.DutyLessonWeight ?? DutyLessonWeight, def?.DutyTravelWeight ?? DutyTravelWeight, def?.NeedWeightAtZero ?? NeedWeightAtZero, def?.LunchWeight ?? LunchWeight, def?.NeedThreshold ?? 0.35f, def?.SwitchMargin ?? 0.15f); } } } public delegate int OccupiedCount(string nodeId, string thing);