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:
Leonid Pershin
2026-08-20 05:16:52 +03:00
parent 5b63c72806
commit 6a0d5a9886
32 changed files with 1894 additions and 25 deletions
+204
View File
@@ -0,0 +1,204 @@
namespace HSchool.Content;
/// <summary>What the person should wear right now: everyday school clothes or PE kit.</summary>
public enum ApparelMode
{
Everyday,
Pe,
}
/// <summary>Why the current outfit is wrong. Used by AI goals and tests.</summary>
[Flags]
public enum ApparelIssue
{
None = 0,
PeRequired = 1,
PeForbidden = 2,
OuterRequired = 4,
OuterForbidden = 8,
Formality = 16,
Color = 32,
SkirtLength = 64,
}
/// <summary>
/// Inputs for dress-code and weather checks. Thresholds live on <see cref="BehaviorDef"/>, not here.
/// </summary>
public readonly record struct ApparelContext(
bool Female,
int Age,
bool IsStudent,
DressRulePair Rules,
ApparelMode Mode,
float OutdoorTemperatureC,
BehaviorDef? Behavior);
/// <summary>
/// Scores worn items against school rules, age, weather and lesson. Evaluation only — no mutation.
/// </summary>
public static class Appropriateness
{
public static ApparelIssue Issues(
DefCatalog catalog,
ApparelContext context,
IEnumerable<(ThingDef Def, string? Color)> worn)
{
var issues = ApparelIssue.None;
var behavior = context.Behavior;
var list = worn.ToList();
var hasPe = list.Any(entry => entry.Def.Pe);
var hasOuter = list.Any(entry => entry.Def.Layers.Contains(ApparelLayers.Outer, StringComparer.Ordinal));
if (context.Mode == ApparelMode.Pe)
{
if (!hasPe || list.Any(entry => !entry.Def.Pe && entry.Def.Layers.Count > 0 && !IsUnderLayer(entry.Def)))
{
issues |= ApparelIssue.PeRequired;
}
}
else if (hasPe)
{
issues |= ApparelIssue.PeForbidden;
}
var outerBelow = behavior?.OuterBelowC ?? 10f;
var heavyAbove = behavior?.HeavyOuterAboveC ?? 15f;
if (context.OutdoorTemperatureC < outerBelow && !hasOuter)
{
issues |= ApparelIssue.OuterRequired;
}
if (context.OutdoorTemperatureC > heavyAbove && list.Any(entry => entry.Def.DefName.Equals("FurCoat", StringComparison.Ordinal)))
{
issues |= ApparelIssue.OuterForbidden;
}
foreach (var (def, color) in list.Where(entry => !entry.Def.Pe && entry.Def.Layers.Count > 0))
{
if (!FormalityFits(context, def))
{
issues |= ApparelIssue.Formality;
}
if (!ColorFits(catalog, context, def, color))
{
issues |= ApparelIssue.Color;
}
if (!SkirtLengthFits(context, def))
{
issues |= ApparelIssue.SkirtLength;
}
}
return issues;
}
public static bool ColorAllowed(DefCatalog catalog, DressRulePair rules, ThingDef def, string? color)
{
if (color is null)
{
return true;
}
if (!catalog.Colors.TryGetValue(color, out var colorDef))
{
return true;
}
if (rules.Color.Equals(ColorPolicies.NoBright, StringComparison.Ordinal)
&& colorDef.Tags.Contains(ColorTags.Bright, StringComparer.Ordinal))
{
return false;
}
return true;
}
public static bool FormalityFits(ApparelContext context, ThingDef def)
{
if (def.Pe || IsUnderLayer(def))
{
return true;
}
var behavior = context.Behavior;
var regularMin = behavior?.FormalityRegularMin ?? 20;
var strictMin = behavior?.FormalityStrictMin ?? 60;
var form = EffectiveForm(context);
return form switch
{
FormPolicies.Strict => def.Formality >= strictMin,
_ => def.Formality >= regularMin || def.Pe,
};
}
public static bool SkirtLengthFits(ApparelContext context, ThingDef def)
{
if (def.SkirtLength is null
|| !def.SkirtLength.Equals(SkirtLengths.Short, StringComparison.Ordinal))
{
return true;
}
var minAge = context.Behavior?.ShortFormMinAge ?? 13;
if (context.Age < minAge)
{
return false;
}
if (!EffectiveForm(context).Equals(FormPolicies.Short, StringComparison.Ordinal))
{
return false;
}
return true;
}
public static string EffectiveForm(ApparelContext context)
{
var form = context.Rules.Form;
if (form.Equals(FormPolicies.Short, StringComparison.Ordinal)
&& context.Age < (context.Behavior?.ShortFormMinAge ?? 13))
{
return FormPolicies.Regular;
}
return form;
}
private static bool ColorFits(DefCatalog catalog, ApparelContext context, ThingDef def, string? color)
{
if (!ColorAllowed(catalog, context.Rules, def, color))
{
return false;
}
if (!context.Rules.Color.Equals(ColorPolicies.WhiteTopBlackBottom, StringComparison.Ordinal))
{
return true;
}
if (def.Layers.Contains(ApparelLayers.Top, StringComparer.Ordinal)
&& color is not null
&& !color.Equals("White", StringComparison.Ordinal))
{
return false;
}
if (def.Layers.Contains(ApparelLayers.Bottom, StringComparer.Ordinal)
&& color is not null
&& !color.Equals("Black", StringComparison.Ordinal))
{
return false;
}
return true;
}
private static bool IsUnderLayer(ThingDef def) =>
def.Layers.All(layer =>
layer.Equals(ApparelLayers.Underwear, StringComparison.Ordinal)
|| layer.Equals(ApparelLayers.Socks, StringComparison.Ordinal));
}
+79
View File
@@ -0,0 +1,79 @@
namespace HSchool.Content;
/// <summary>School dress-code form strictness. Values are wire and save ids.</summary>
public static class FormPolicies
{
public const string Regular = "regular";
public const string Short = "short";
public const string Strict = "strict";
public static readonly IReadOnlyList<string> All = [Regular, Short, Strict];
public static bool IsKnown(string value) =>
All.Any(candidate => candidate.Equals(value, StringComparison.Ordinal));
}
/// <summary>Colour policy ids. Independent from <see cref="FormPolicies"/>.</summary>
public static class ColorPolicies
{
public const string Free = "free";
public const string NoBright = "noBright";
public const string WhiteTopBlackBottom = "whiteTopBlackBottom";
public static readonly IReadOnlyList<string> All = [Free, NoBright, WhiteTopBlackBottom];
public static bool IsKnown(string value) =>
All.Any(candidate => candidate.Equals(value, StringComparison.Ordinal));
}
/// <summary>One role's form + colour pair.</summary>
public sealed record DressRulePair(string Form, string Color)
{
public static DressRulePair Default { get; } = new(FormPolicies.Regular, ColorPolicies.NoBright);
}
/// <summary>Live rules plus optional next-day overrides waiting for a work morning.</summary>
public sealed record SchoolDressRules
{
public DressRulePair Students { get; init; } = DressRulePair.Default;
public DressRulePair Staff { get; init; } = DressRulePair.Default;
public DressRulePair? PendingStudents { get; init; }
public DressRulePair? PendingStaff { get; init; }
public DressRulePair ForStudent => Students;
public DressRulePair ForStaff => Staff;
public SchoolDressRules WithPending(DressRulePair? students, DressRulePair? staff) =>
this with { PendingStudents = students, PendingStaff = staff };
public SchoolDressRules ApplyPending()
{
var next = this;
if (PendingStudents is { } students)
{
next = next with { Students = students, PendingStudents = null };
}
if (PendingStaff is { } staff)
{
next = next with { Staff = staff, PendingStaff = null };
}
return next;
}
public bool HasPending => PendingStudents is not null || PendingStaff is not null;
}
/// <summary>Catalog action ids for locker-room changes.</summary>
public static class ApparelActions
{
public const string ChangeMale = "ChangeClothesMale";
public const string ChangeFemale = "ChangeClothesFemale";
public static string For(bool female) => female ? ChangeFemale : ChangeMale;
}
+21
View File
@@ -287,6 +287,27 @@ public sealed class BehaviorDef : Def
/// </summary>
public IReadOnlyList<ApparelConditionBand> ApparelConditionBands { get; init; } = DefaultConditionBands;
/// <summary>Younger pupils keep regular hemlines even when the school chose short form.</summary>
public int ShortFormMinAge { get; init; } = 13;
/// <summary>Everyday and short-form minimum formality on worn layers.</summary>
public int FormalityRegularMin { get; init; } = 20;
/// <summary>Strict-form minimum formality on worn layers.</summary>
public int FormalityStrictMin { get; init; } = 60;
/// <summary>Street below this °C expects an <see cref="ApparelLayers.Outer"/> layer.</summary>
public float OuterBelowC { get; init; } = 10f;
/// <summary>Above this street °C a fur coat is inappropriate.</summary>
public float HeavyOuterAboveC { get; init; } = 15f;
/// <summary>Walk to the locker room and change. Just above duty travel.</summary>
public float ApparelGoalWeight { get; init; } = 6f;
/// <summary>Game minutes for <c>ChangeClothes*</c> actions.</summary>
public float ChangeClothesMinutes { get; init; } = 5f;
public static IReadOnlyList<ApparelConditionBand> DefaultConditionBands { get; } =
[
new() { Min = 0.75f, Id = "ApparelConditionIntact" },