Implement phase 35 dress appropriateness and school rules.
Adds outfit evaluation, morning home dressing, locker-room ChangeClothes with day-log events, PE/weather AI goals, dress-rules API, and tests.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.Ai;
|
||||
|
||||
/// <summary>Why someone should walk to their locker room.</summary>
|
||||
public enum ApparelGoalKind
|
||||
{
|
||||
None,
|
||||
Pe,
|
||||
Everyday,
|
||||
Weather,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dress-code and weather goals beside duty. Triggered on bells and arrival, not every tick.
|
||||
/// </summary>
|
||||
public static class ApparelGoals
|
||||
{
|
||||
public static Intent? Goal(
|
||||
DefCatalog catalog,
|
||||
MapLayout map,
|
||||
WalkGraph walks,
|
||||
ApparelActor apparel,
|
||||
ActorState state,
|
||||
OccupiedCount occupied)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(map);
|
||||
ArgumentNullException.ThrowIfNull(walks);
|
||||
|
||||
if (apparel.Issues == ApparelIssue.None || apparel.LockerRoomNode is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var weight = catalog.BehaviorRules?.ApparelGoalWeight ?? 5.5f;
|
||||
var actionId = ApparelActions.For(apparel.Female);
|
||||
if (!catalog.Actions.TryGetValue(actionId, out var action) || action.Abstract)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.BoundToLesson && (apparel.Kind == ApparelGoalKind.Pe || apparel.Kind == ApparelGoalKind.Everyday))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var room = RoomFor(catalog, map, walks, state, occupied, action, apparel.LockerRoomNode);
|
||||
if (room is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var id = apparel.Kind switch
|
||||
{
|
||||
ApparelGoalKind.Pe => "pe",
|
||||
ApparelGoalKind.Everyday => "everyday",
|
||||
ApparelGoalKind.Weather => "weather",
|
||||
_ => null,
|
||||
};
|
||||
if (id is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Intent(GoalKind.Apparel, id, weight, actionId);
|
||||
}
|
||||
|
||||
public static ApparelGoalKind Classify(ApparelIssue issues)
|
||||
{
|
||||
if (issues.HasFlag(ApparelIssue.PeRequired))
|
||||
{
|
||||
return ApparelGoalKind.Pe;
|
||||
}
|
||||
|
||||
if (issues.HasFlag(ApparelIssue.PeForbidden))
|
||||
{
|
||||
return ApparelGoalKind.Everyday;
|
||||
}
|
||||
|
||||
if (issues.HasFlag(ApparelIssue.OuterRequired) || issues.HasFlag(ApparelIssue.OuterForbidden))
|
||||
{
|
||||
return ApparelGoalKind.Weather;
|
||||
}
|
||||
|
||||
if (issues.HasFlag(ApparelIssue.Formality)
|
||||
|| issues.HasFlag(ApparelIssue.Color)
|
||||
|| issues.HasFlag(ApparelIssue.SkirtLength))
|
||||
{
|
||||
return ApparelGoalKind.Everyday;
|
||||
}
|
||||
|
||||
return ApparelGoalKind.None;
|
||||
}
|
||||
|
||||
private static string? RoomFor(
|
||||
DefCatalog catalog,
|
||||
MapLayout map,
|
||||
WalkGraph walks,
|
||||
ActorState state,
|
||||
OccupiedCount occupied,
|
||||
ActionDef action,
|
||||
string preferredNode)
|
||||
{
|
||||
foreach (var room in map.Rooms)
|
||||
{
|
||||
if (!room.Id.Equals(preferredNode, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!action.Room!.Equals(room.Def, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!HasSlot(catalog, map, occupied, room.Id, action.Thing))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return room.Id;
|
||||
}
|
||||
|
||||
if (state.NodeId is not null && state.NodeId.Equals(preferredNode, StringComparison.Ordinal))
|
||||
{
|
||||
return preferredNode;
|
||||
}
|
||||
|
||||
var from = state.NodeId ?? walks.TerritoryId;
|
||||
var cost = walks.Minutes(from, preferredNode);
|
||||
return float.IsInfinity(cost) ? null : preferredNode;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Apparel inputs the generic planner does not carry.</summary>
|
||||
public readonly record struct ApparelActor(
|
||||
bool Female,
|
||||
ApparelIssue Issues,
|
||||
ApparelGoalKind Kind,
|
||||
string? LockerRoomNode);
|
||||
@@ -6,6 +6,7 @@ public enum GoalKind
|
||||
{
|
||||
None,
|
||||
Duty,
|
||||
Apparel,
|
||||
Need,
|
||||
Leisure,
|
||||
}
|
||||
@@ -39,7 +40,8 @@ public readonly record struct ActorState(
|
||||
string? DutyRoom,
|
||||
IReadOnlyDictionary<string, float> Needs,
|
||||
Intent Intent,
|
||||
bool LunchWindowOpen = false);
|
||||
bool LunchWindowOpen = false,
|
||||
ApparelActor Apparel = default);
|
||||
|
||||
/// <summary>
|
||||
/// Picks a goal by weight and plans walk-then-do. No world, no clock — a table of inputs to an
|
||||
@@ -101,6 +103,10 @@ public static class DecisionPlanner
|
||||
{
|
||||
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));
|
||||
@@ -138,6 +144,8 @@ public static class DecisionPlanner
|
||||
{
|
||||
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))
|
||||
{
|
||||
@@ -305,6 +313,27 @@ public static class DecisionPlanner
|
||||
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);
|
||||
@@ -485,9 +514,10 @@ public static class DecisionPlanner
|
||||
private static int Order(GoalKind kind) => kind switch
|
||||
{
|
||||
GoalKind.Duty => 0,
|
||||
GoalKind.Need => 1,
|
||||
GoalKind.Leisure => 2,
|
||||
_ => 3,
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user