Whispering cuts lesson skill gain from BehaviorDef; a teacher in the room may interrupt into a discipline circle, and pedagogy scales the pupil's opinion of the teacher. Co-authored-by: Cursor <cursoragent@cursor.com>
596 lines
18 KiB
C#
596 lines
18 KiB
C#
using HSchool.Content;
|
|
|
|
namespace HSchool.Ai;
|
|
|
|
public enum GoalKind
|
|
{
|
|
None,
|
|
Duty,
|
|
Apparel,
|
|
Need,
|
|
Leisure,
|
|
}
|
|
|
|
/// <summary>What this person is currently pursuing. Compared against a new winner using switchMargin.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Walk, start an action, or stay. Heading home is not a goal — the day plan handles it.</summary>
|
|
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<string, float> Needs,
|
|
Intent Intent,
|
|
bool LunchWindowOpen = false,
|
|
ApparelActor Apparel = default,
|
|
TalkPlannerContext Talk = default);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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.
|
|
|
|
/// <summary>Lesson or posted work. Beats leisure and a need that only just crossed the threshold.</summary>
|
|
public const float DutyLessonWeight = 10f;
|
|
|
|
/// <summary>Walk to the next room on a break. Beats chatting in the corridor you are standing in.</summary>
|
|
public const float DutyTravelWeight = 5f;
|
|
|
|
/// <summary>Need at zero. Beats a lesson so a desperate toilet trip leaves class.</summary>
|
|
public const float NeedWeightAtZero = 20f;
|
|
|
|
/// <summary>
|
|
/// A sitting during this parallel's own lunch break. Above <see cref="DutyTravelWeight"/> so
|
|
/// lunch beats walking on to the next room, below <see cref="DutyLessonWeight"/> 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string>();
|
|
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,
|
|
};
|
|
|
|
/// <summary>Effective numbers: the catalog's BehaviorDef, or the constants when a pack has none.</summary>
|
|
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);
|