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
+152
View File
@@ -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);
+34 -4
View File
@@ -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>
+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" },
@@ -0,0 +1,65 @@
using HSchool.Content;
namespace HSchool.Server.Api;
internal sealed record DressRulePairDto(string Form, string Color);
internal sealed record DressRulesResponse(
DressRulePairDto Students,
DressRulePairDto Staff,
DressRulePairDto? PendingStudents,
DressRulePairDto? PendingStaff)
{
public static DressRulesResponse From(SchoolDressRules rules) =>
new(
Pair(rules.Students),
Pair(rules.Staff),
rules.PendingStudents is { } students ? Pair(students) : null,
rules.PendingStaff is { } staff ? Pair(staff) : null);
private static DressRulePairDto Pair(DressRulePair pair) => new(pair.Form, pair.Color);
}
internal sealed record SetDressRulesRequest(
DressRulePairDto? Students,
DressRulePairDto? Staff);
internal enum DressRulesError
{
None,
UnknownSchool,
UnknownForm,
UnknownColor,
}
internal sealed record DressRulesOutcome(DressRulesError Error, DressRulesResponse? Rules)
{
public static DressRulesOutcome Ok(SchoolDressRules rules) =>
new(DressRulesError.None, DressRulesResponse.From(rules));
public static DressRulesOutcome Fail(DressRulesError error) => new(error, null);
}
internal static class DressRulesValidation
{
public static bool TryParse(DressRulePairDto dto, out DressRulePair pair, out DressRulesError error)
{
if (!FormPolicies.IsKnown(dto.Form))
{
pair = default!;
error = DressRulesError.UnknownForm;
return false;
}
if (!ColorPolicies.IsKnown(dto.Color))
{
pair = default!;
error = DressRulesError.UnknownColor;
return false;
}
pair = new DressRulePair(dto.Form, dto.Color);
error = DressRulesError.None;
return true;
}
}
+72
View File
@@ -312,6 +312,53 @@ internal static class SchoolEndpoints
})
.WithName("GenerateSchoolPersonPortrait");
schools.MapGet("/{id:int}/dress-rules", async (
int id,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
var command = new GameCommand.GetDressRules(id, NewCompletion<DressRulesOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return DressRulesHttp(outcome);
})
.WithName("GetSchoolDressRules");
schools.MapPost("/{id:int}/dress-rules", async (
int id,
SetDressRulesRequest request,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
DressRulePair? students = null;
if (request.Students is { } studentDto)
{
if (!DressRulesValidation.TryParse(studentDto, out var parsed, out var studentError))
{
return DressRulesBadRequest(studentError);
}
students = parsed;
}
DressRulePair? staff = null;
if (request.Staff is { } staffDto)
{
if (!DressRulesValidation.TryParse(staffDto, out var parsed, out var staffError))
{
return DressRulesBadRequest(staffError);
}
staff = parsed;
}
var command = new GameCommand.SetDressRules(id, students, staff, NewCompletion<DressRulesOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return DressRulesHttp(outcome);
})
.WithName("SetSchoolDressRules");
schools.MapGet("/{id:int}/staffing", (
int id,
string? lang,
@@ -678,6 +725,31 @@ internal static class SchoolEndpoints
return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions);
}
private static IResult DressRulesHttp(DressRulesOutcome outcome) =>
outcome.Error switch
{
DressRulesError.None => Results.Ok(outcome.Rules),
DressRulesError.UnknownSchool => Problem(
StatusCodes.Status404NotFound,
"unknown-school",
"That school does not exist."),
_ => DressRulesBadRequest(outcome.Error),
};
private static IResult DressRulesBadRequest(DressRulesError error) =>
error switch
{
DressRulesError.UnknownForm => Problem(
StatusCodes.Status400BadRequest,
"unknown-form",
"That form policy is not recognized."),
DressRulesError.UnknownColor => Problem(
StatusCodes.Status400BadRequest,
"unknown-color",
"That colour policy is not recognized."),
_ => Problem(StatusCodes.Status400BadRequest, "invalid-query", "The dress rules request is not valid."),
};
}
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
+10
View File
@@ -101,4 +101,14 @@ internal abstract record GameCommand
int Day,
int Period,
TaskCompletionSource<TimetableOutcome> Result) : GameCommand;
internal sealed record GetDressRules(
int SchoolId,
TaskCompletionSource<DressRulesOutcome> Result) : GameCommand;
internal sealed record SetDressRules(
int SchoolId,
DressRulePair? PendingStudents,
DressRulePair? PendingStaff,
TaskCompletionSource<DressRulesOutcome> Result) : GameCommand;
}
+24 -2
View File
@@ -225,6 +225,17 @@ internal sealed class GameLoopService(
new WorkerCommand.UnpinLesson(unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period, unpin.Result),
unpin.Result);
break;
case GameCommand.GetDressRules getRules:
HandleDressRules(getRules.SchoolId, new WorkerCommand.GetDressRules(getRules.Result), getRules.Result);
break;
case GameCommand.SetDressRules setRules:
HandleDressRules(
setRules.SchoolId,
new WorkerCommand.SetDressRules(setRules.PendingStudents, setRules.PendingStaff, setRules.Result),
setRules.Result);
break;
}
}
@@ -255,6 +266,14 @@ internal sealed class GameLoopService(
}
}
private void HandleDressRules(int schoolId, WorkerCommand command, TaskCompletionSource<DressRulesOutcome> result)
{
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
{
result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
}
}
private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource<StaffingOutcome> result)
{
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
@@ -560,7 +579,8 @@ internal sealed class GameLoopService(
save.ClimatePresetId,
save.NativeLanguage,
createSeed: null,
save.Presence);
save.Presence,
save.DressRules);
worker.Start();
try
@@ -611,7 +631,8 @@ internal sealed class GameLoopService(
string? climatePresetId,
string? nativeLanguage,
int? createSeed = null,
IReadOnlyList<PresenceSnapshot>? presence = null) =>
IReadOnlyList<PresenceSnapshot>? presence = null,
SchoolDressRules? dressRules = null) =>
new(
id,
name,
@@ -626,6 +647,7 @@ internal sealed class GameLoopService(
nativeLanguage,
createSeed,
presence,
dressRules,
_options,
clients,
metrics,
+5
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using HSchool.Content;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
using HSchool.Simulation;
@@ -36,6 +37,8 @@ internal sealed class SchoolSave
public string? NativeLanguage { get; init; }
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
public SchoolDressRules? DressRules { get; init; }
}
/// <summary>Allocates school ids that survive a process restart.</summary>
@@ -188,6 +191,7 @@ internal sealed class SchoolStore
NameSetId = save.NameSetId,
NativeLanguage = save.NativeLanguage,
Presence = save.Presence,
DressRules = save.DressRules,
});
}
catch (Exception ex)
@@ -233,6 +237,7 @@ internal sealed class SchoolStore
NameSetId = save.NameSetId,
NativeLanguage = save.NativeLanguage,
Presence = save.Presence,
DressRules = save.DressRules,
};
}
+40
View File
@@ -37,6 +37,7 @@ internal sealed class SchoolWorker
private string? _nativeLanguage;
private readonly int? _createSeed;
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
private readonly SchoolDressRules? _savedDressRules;
private readonly Action<int> _onFailed;
private readonly int _id;
@@ -72,6 +73,7 @@ internal sealed class SchoolWorker
string? nativeLanguage,
int? createSeed,
IReadOnlyList<PresenceSnapshot>? savedPresence,
SchoolDressRules? savedDressRules,
SimulationOptions options,
ClientRegistry clients,
GameMetrics metrics,
@@ -93,6 +95,7 @@ internal sealed class SchoolWorker
_nativeLanguage = nativeLanguage;
_createSeed = createSeed;
_savedPresence = savedPresence;
_savedDressRules = savedDressRules;
_options = options;
_clients = clients;
_metrics = metrics;
@@ -248,6 +251,7 @@ internal sealed class SchoolWorker
}
_school = school;
school.DressRules = _savedDressRules ?? new SchoolDressRules();
PublishSnapshot();
if (_isNew)
@@ -458,6 +462,29 @@ internal sealed class SchoolWorker
unpin.Result.TrySetResult(
ApplyUnpin(school, unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period));
break;
case WorkerCommand.GetDressRules getRules:
getRules.Result.TrySetResult(DressRulesOutcome.Ok(school.DressRules));
break;
case WorkerCommand.SetDressRules setRules:
{
var next = school.DressRules;
if (setRules.PendingStudents is { } students)
{
next = next with { PendingStudents = students };
}
if (setRules.PendingStaff is { } staff)
{
next = next with { PendingStaff = staff };
}
school.DressRules = next;
setRules.Result.TrySetResult(DressRulesOutcome.Ok(school.DressRules));
dirty = true;
break;
}
}
}
catch (Exception ex)
@@ -511,6 +538,12 @@ internal sealed class SchoolWorker
case WorkerCommand.UnpinLesson unpin:
unpin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
break;
case WorkerCommand.GetDressRules getRules:
getRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
break;
case WorkerCommand.SetDressRules setRules:
setRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
break;
}
}
@@ -542,6 +575,12 @@ internal sealed class SchoolWorker
case WorkerCommand.UnpinLesson unpin:
unpin.Result.TrySetException(exception);
break;
case WorkerCommand.GetDressRules getRules:
getRules.Result.TrySetException(exception);
break;
case WorkerCommand.SetDressRules setRules:
setRules.Result.TrySetException(exception);
break;
}
}
@@ -1014,6 +1053,7 @@ internal sealed class SchoolWorker
ClimatePresetId = school.ClimatePresetId,
NativeLanguage = _nativeLanguage,
Presence = school.CapturePresence(),
DressRules = school.DressRules,
});
}
catch (Exception ex)
+8
View File
@@ -1,3 +1,4 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Server.Api;
using HSchool.Server.Net;
@@ -63,4 +64,11 @@ internal abstract record WorkerCommand
int Day,
int Period,
TaskCompletionSource<TimetableOutcome> Result) : WorkerCommand;
internal sealed record GetDressRules(TaskCompletionSource<DressRulesOutcome> Result) : WorkerCommand;
internal sealed record SetDressRules(
DressRulePair? PendingStudents,
DressRulePair? PendingStaff,
TaskCompletionSource<DressRulesOutcome> Result) : WorkerCommand;
}
@@ -50,4 +50,20 @@
"roles": ["student", "staff"],
"weight": 1,
},
{
"defName": "ChangeClothesMale",
"room": "MaleChangingRoom",
"thing": "ChangingSpot",
"minutes": 5,
"roles": ["student", "staff"],
"weight": 0,
},
{
"defName": "ChangeClothesFemale",
"room": "FemaleChangingRoom",
"thing": "ChangingSpot",
"minutes": 5,
"roles": ["student", "staff"],
"weight": 0,
},
]
@@ -38,4 +38,12 @@
{ "min": 0.15, "id": "ApparelConditionTorn" },
{ "min": 0, "id": "ApparelConditionRags" },
],
// Dress code thresholds — axes live in code, numbers here.
"shortFormMinAge": 13,
"formalityRegularMin": 20,
"formalityStrictMin": 60,
"outerBelowC": 10,
"heavyOuterAboveC": 15,
"apparelGoalWeight": 6,
"changeClothesMinutes": 5,
}
@@ -56,6 +56,7 @@
"lockerSex": "male",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 },
{ "key": "spots", "thing": "ChangingSpot", "count": 4 },
],
"travelMinutes": 0.5,
},
@@ -64,6 +65,7 @@
"lockerSex": "female",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 },
{ "key": "spots", "thing": "ChangingSpot", "count": 4 },
],
"travelMinutes": 0.5,
},
@@ -7,4 +7,5 @@
{ "defName": "Computer", "pupilSlots": 1 },
{ "defName": "MedicalCouch" },
{ "defName": "Locker" },
{ "defName": "ChangingSpot" },
]
@@ -95,6 +95,9 @@
"UtilityRoom": "Utility room",
"MaleChangingRoom": "Boys' changing room",
"FemaleChangingRoom": "Girls' changing room",
"ChangingSpot": "Changing spot",
"ChangeClothesMale": "Change clothes",
"ChangeClothesFemale": "Change clothes",
"Mathematics": "Mathematics",
"RussianLanguage": "Russian",
"Literature": "Literature",
@@ -173,5 +176,6 @@
"ActionStarted": "started: {0}",
"ActionEnded": "finished: {0}",
"ApparelReplaced": "got a new {0}",
"ApparelChanged": "changed clothes: {0}",
"core": "Core",
}
@@ -95,6 +95,9 @@
"UtilityRoom": "Теплоузел",
"MaleChangingRoom": "Мужская раздевалка",
"FemaleChangingRoom": "Женская раздевалка",
"ChangingSpot": "Место переодевания",
"ChangeClothesMale": "Переодеться",
"ChangeClothesFemale": "Переодеться",
"Mathematics": "Математика",
"RussianLanguage": "Русский язык",
"Literature": "Литература",
@@ -173,5 +176,6 @@
"ActionStarted": "начал: {0}",
"ActionEnded": "закончил: {0}",
"ApparelReplaced": "получил новую {0}",
"ApparelChanged": "переоделся: {0}",
"core": "Базовая игра",
}
@@ -205,6 +205,7 @@
"floor": "gym-floor",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 },
{ "key": "spots", "thing": "ChangingSpot", "count": 4 },
],
},
{
@@ -214,6 +215,7 @@
"floor": "gym-floor",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 },
{ "key": "spots", "thing": "ChangingSpot", "count": 4 },
],
},
],
+61
View File
@@ -1,6 +1,7 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation;
@@ -25,6 +26,21 @@ internal static class ActivitySystem
return false;
}
Person? person = null;
if (IsChangeClothes(action.DefName))
{
if (school.Roster is null)
{
return false;
}
person = school.Roster.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal));
if (person is null)
{
return false;
}
}
var started = false;
var world = school.World;
world.Query(
@@ -46,6 +62,11 @@ internal static class ActivitySystem
return;
}
if (person is not null && !SexFitsChange(action, person))
{
return;
}
var location = school.Map.NodeDef(presence.NodeId);
if (location is null || !location.Equals(action.Room, StringComparison.Ordinal))
{
@@ -117,6 +138,11 @@ internal static class ActivitySystem
needs.Values[action.Need] = ActionStepper.ApplyNeedGain(current, action, need);
}
if (IsChangeClothes(action.DefName))
{
FinishChangeClothes(school, identity.Id);
}
activity = PersonActivity.Idle;
completed.Add(identity.Id);
});
@@ -124,6 +150,26 @@ internal static class ActivitySystem
return completed;
}
private static void FinishChangeClothes(School school, string personId)
{
var person = school.Roster?.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal));
if (person is null)
{
return;
}
var schoolClass = person.ClassId is { } classId
? school.Roster!.Classes.FirstOrDefault(row => row.Id.Equals(classId, StringComparison.Ordinal))
: null;
var mode = ApparelPresence.ModeAt(school, person, schoolClass);
var subjects = ApparelDresser.TodaySubjects(school, person, schoolClass);
ApparelDresser.RedressPerson(school, person, mode, includeHome: false, todaySubjects: subjects);
}
private static bool IsChangeClothes(string actionId) =>
actionId.Equals(ApparelActions.ChangeMale, StringComparison.Ordinal)
|| actionId.Equals(ApparelActions.ChangeFemale, StringComparison.Ordinal);
private static int Occupied(School school, string nodeId, string thing)
{
var occupied = 0;
@@ -168,4 +214,19 @@ internal static class ActivitySystem
return false;
}
private static bool SexFitsChange(ActionDef action, Person person)
{
if (action.DefName.Equals(ApparelActions.ChangeMale, StringComparison.Ordinal))
{
return !person.Female;
}
if (action.DefName.Equals(ApparelActions.ChangeFemale, StringComparison.Ordinal))
{
return person.Female;
}
return true;
}
}
+499
View File
@@ -0,0 +1,499 @@
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation;
/// <summary>
/// Picks and applies outfits from worn, bag, locker and home. Used at home instantly and after
/// <c>ChangeClothes</c> in a locker room.
/// </summary>
public static class ApparelDresser
{
private static readonly string[] LayerOrder =
[
ApparelLayers.Underwear,
ApparelLayers.Socks,
ApparelLayers.Bottom,
ApparelLayers.Top,
ApparelLayers.OverTop,
ApparelLayers.Outer,
ApparelLayers.Shoes,
ApparelLayers.Head,
ApparelLayers.Accessory,
];
public static bool RedressPerson(
School school,
Person person,
ApparelMode mode,
bool includeHome,
IReadOnlyList<string>? todaySubjects = null,
bool logChanges = true)
{
if (school.Catalog is null || school.Roster is null)
{
return false;
}
var catalog = school.Catalog;
var rules = RuleFor(school, person);
var age = person.AgeOn(school.Clock.Time);
var context = new ApparelContext(
person.Female,
age,
person.IsStudent,
rules,
mode,
school.Weather.TemperatureC,
catalog.BehaviorRules);
var pool = Pool(person, includeHome);
var target = SelectOutfit(catalog, context, pool, mode);
if (target.Count == 0)
{
return false;
}
var changed = ApplyOutfit(school, person, pool, target, todaySubjects, logChanges);
if (changed)
{
var updated = school.Roster!.People.First(row => row.Id.Equals(person.Id, StringComparison.Ordinal));
SyncInsulation(school, updated);
}
return changed;
}
public static ApparelIssue CurrentIssues(School school, Person person, ApparelMode mode)
{
if (school.Catalog is null)
{
return ApparelIssue.None;
}
var catalog = school.Catalog;
var rules = RuleFor(school, person);
var context = new ApparelContext(
person.Female,
person.AgeOn(school.Clock.Time),
person.IsStudent,
rules,
mode,
school.Weather.TemperatureC,
catalog.BehaviorRules);
var worn = WornEntries(catalog, person);
return Appropriateness.Issues(catalog, context, worn);
}
public static ApparelMode ModeForLesson(string? subject) =>
subject is not null && subject.Equals("PhysicalEducation", StringComparison.Ordinal)
? ApparelMode.Pe
: ApparelMode.Everyday;
public static string ChangeActionId(bool female) => ApparelActions.For(female);
public static string? ChangingRoomDef(bool female) =>
female ? "FemaleChangingRoom" : "MaleChangingRoom";
internal static DressRulePair RuleFor(School school, Person person) =>
person.IsStudent ? school.DressRules.Students : school.DressRules.Staff;
private static List<(InventoryItem Item, int Index)> Pool(Person person, bool includeHome)
{
var pool = new List<(InventoryItem, int)>();
for (var i = 0; i < person.Items.Count; i++)
{
var item = person.Items[i];
if (item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal)
|| item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal)
|| item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)
|| (includeHome && item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)))
{
pool.Add((item, i));
}
}
return pool;
}
private static List<InventoryItem> SelectOutfit(
DefCatalog catalog,
ApparelContext context,
List<(InventoryItem Item, int Index)> pool,
ApparelMode mode)
{
var chosen = new List<InventoryItem>();
var occupied = new HashSet<string>(StringComparer.Ordinal);
var candidates = pool
.Where(entry => catalog.Things.TryGetValue(entry.Item.Def, out var def) && def.Layers.Count > 0)
.Select(entry => (Item: entry.Item, Def: catalog.Things[entry.Item.Def]))
.Where(entry => SexFits(entry.Def, context.Female) && AgeFits(entry.Def, context.Age))
.ToList();
foreach (var layer in LayerOrder)
{
if (occupied.Contains(layer))
{
continue;
}
var pick = PickLayer(catalog, context, candidates, layer, mode, occupied);
if (pick is null)
{
continue;
}
chosen.Add(pick.Value.Item);
foreach (var taken in pick.Value.Def.Layers)
{
occupied.Add(taken);
}
}
return chosen;
}
private static (InventoryItem Item, ThingDef Def)? PickLayer(
DefCatalog catalog,
ApparelContext context,
List<(InventoryItem Item, ThingDef Def)> candidates,
string layer,
ApparelMode mode,
HashSet<string> occupied)
{
(InventoryItem Item, ThingDef Def)? best = null;
foreach (var entry in candidates)
{
if (!entry.Def.Layers.Any(candidate => candidate.Equals(layer, StringComparison.Ordinal))
|| !entry.Def.Layers.All(candidate => !occupied.Contains(candidate)))
{
continue;
}
if (mode == ApparelMode.Pe && !entry.Def.Pe)
{
continue;
}
if (mode == ApparelMode.Everyday && entry.Def.Pe)
{
continue;
}
if (!LayerAllowed(catalog, context, entry.Def, entry.Item.Color, mode))
{
continue;
}
if (best is null || Score(entry.Def) > Score(best.Value.Def))
{
best = entry;
}
}
return best;
}
private static bool LayerAllowed(
DefCatalog catalog,
ApparelContext context,
ThingDef def,
string? color,
ApparelMode mode)
{
if (mode == ApparelMode.Pe)
{
return def.Pe;
}
if (def.Layers.Any(layer => layer.Equals(ApparelLayers.Outer, StringComparison.Ordinal)))
{
var outerBelow = context.Behavior?.OuterBelowC ?? 10f;
var heavyAbove = context.Behavior?.HeavyOuterAboveC ?? 15f;
if (context.OutdoorTemperatureC >= outerBelow)
{
return false;
}
if (context.OutdoorTemperatureC > heavyAbove
&& def.DefName.Equals("FurCoat", StringComparison.Ordinal))
{
return false;
}
}
return Appropriateness.FormalityFits(context, def)
&& Appropriateness.SkirtLengthFits(context, def)
&& Appropriateness.ColorAllowed(catalog, context.Rules, def, color);
}
private static int Score(ThingDef def)
{
var score = def.Formality;
if (def.Layers.Any(layer => layer.Equals(ApparelLayers.Outer, StringComparison.Ordinal)))
{
score += (int)def.Insulation;
}
return score;
}
private static bool ApplyOutfit(
School school,
Person person,
List<(InventoryItem Item, int Index)> pool,
List<InventoryItem> target,
IReadOnlyList<string>? todaySubjects,
bool logChanges)
{
var targetKeys = target
.Select(item => Key(item))
.ToHashSet(StringComparer.Ordinal);
var hasLocker = person.LockerRoomId is not null;
var changed = false;
var items = person.Items.ToList();
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal))
{
continue;
}
if (targetKeys.Contains(Key(item)))
{
continue;
}
items[i] = item with { Location = StashLocation(hasLocker) };
if (logChanges)
{
school.AppendDayLog(new PersonLogEvent(
person.Id,
school.Clock.Time,
PersonLogTypes.ApparelChanged,
item.Def));
}
changed = true;
}
foreach (var want in target)
{
var index = items.FindIndex(candidate =>
Key(candidate).Equals(Key(want), StringComparison.Ordinal));
if (index < 0)
{
continue;
}
if (items[index].Location.Equals(ItemLocations.Worn, StringComparison.Ordinal))
{
continue;
}
items[index] = items[index] with { Location = ItemLocations.Worn };
if (logChanges)
{
school.AppendDayLog(new PersonLogEvent(
person.Id,
school.Clock.Time,
PersonLogTypes.ApparelChanged,
want.Def));
}
changed = true;
}
if (todaySubjects is not null && school.Catalog is { } catalog)
{
changed |= RepackBag(school, person, items, todaySubjects);
changed |= StagePeKit(catalog, person, items, todaySubjects);
}
if (changed)
{
ReplacePerson(person, items);
}
return changed;
}
private static bool StagePeKit(
DefCatalog catalog,
Person person,
List<InventoryItem> items,
IReadOnlyList<string> todaySubjects)
{
if (!todaySubjects.Contains("PhysicalEducation", StringComparer.Ordinal))
{
return false;
}
var hasLocker = person.LockerRoomId is not null;
var changed = false;
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
if (!item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)
|| !catalog.Things.TryGetValue(item.Def, out var def)
|| !def.Pe)
{
continue;
}
items[i] = item with { Location = StashLocation(hasLocker) };
changed = true;
}
return changed;
}
private static bool RepackBag(
School school,
Person person,
List<InventoryItem> items,
IReadOnlyList<string> todaySubjects)
{
if (school.Catalog is null)
{
return false;
}
var catalog = school.Catalog;
var haveLocker = person.LockerRoomId is not null;
var changed = false;
var want = todaySubjects.ToHashSet(StringComparer.Ordinal);
var capacity = CarryMass.Capacity(catalog, person.Skills);
var held = 0f;
foreach (var item in items.Where(row => row.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal)))
{
if (catalog.Things.TryGetValue(item.Def, out var def))
{
held += def.Mass;
}
}
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
if (item.Subject is null)
{
continue;
}
if (want.Contains(item.Subject))
{
if (!item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal)
&& catalog.Things.TryGetValue(item.Def, out var def)
&& held + def.Mass <= capacity)
{
items[i] = item with { Location = ItemLocations.Bag };
held += def.Mass;
changed = true;
}
continue;
}
if (item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal))
{
items[i] = item with { Location = StashLocation(haveLocker) };
if (catalog.Things.TryGetValue(item.Def, out var def))
{
held = Math.Max(0, held - def.Mass);
}
changed = true;
}
}
return changed;
}
private static string StashLocation(bool hasLocker) =>
hasLocker ? ItemLocations.Locker : ItemLocations.Bag;
private static string Key(InventoryItem item) =>
item.Subject is null ? item.Def : $"{item.Def}:{item.Subject}";
private static IEnumerable<(ThingDef Def, string? Color)> WornEntries(DefCatalog catalog, Person person)
{
foreach (var item in person.Items)
{
if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal))
{
continue;
}
if (catalog.Things.TryGetValue(item.Def, out var def))
{
yield return (def, item.Color);
}
}
}
private static void SyncInsulation(School school, Person person)
{
var insulation = PersonInsulation.FromPerson(person, school.Catalog);
var world = school.World;
var query = new Arch.Core.QueryDescription().WithAll<PersonIdentity, PersonInsulation>();
world.Query(in query, (ref PersonIdentity identity, ref PersonInsulation worn) =>
{
if (identity.Id.Equals(person.Id, StringComparison.Ordinal))
{
worn = insulation;
}
});
}
private static void ReplacePerson(Person person, List<InventoryItem> items)
{
if (person.Items is IList<InventoryItem> list && !list.IsReadOnly)
{
list.Clear();
foreach (var item in items)
{
list.Add(item);
}
return;
}
throw new InvalidOperationException($"Cannot mutate items for {person.Id}.");
}
private static bool SexFits(ThingDef def, bool female)
{
if (def.Sex is null)
{
return true;
}
var want = female ? "female" : "male";
return def.Sex.Equals(want, StringComparison.OrdinalIgnoreCase);
}
private static bool AgeFits(ThingDef def, int age) =>
def.Age is not { } range || (age >= range.Min && age <= range.Max);
/// <summary>Subjects this pupil needs in the bag today.</summary>
public static IReadOnlyList<string> TodaySubjects(
School school,
Person person,
SchoolClass? schoolClass)
{
if (school.Catalog is null || school.Timetable is null || !person.IsStudent)
{
return [];
}
var day = SchoolDay.WeekdayIndex(school.Clock.Time);
return Duty.LessonsToday(person, schoolClass, school.Timetable, day)
.Select(lesson => lesson.Subject)
.Distinct(StringComparer.Ordinal)
.ToArray();
}
}
+96
View File
@@ -0,0 +1,96 @@
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation;
/// <summary>Builds apparel goal inputs for the decision planner.</summary>
internal static class ApparelPresence
{
public static ApparelActor Build(School school, Person person, SchoolClass? schoolClass)
{
if (school.Catalog is null || !person.IsStudent && !person.IsStaff)
{
return default;
}
var mode = ModeAt(school, person, schoolClass);
var issues = ApparelDresser.CurrentIssues(school, person, mode);
var kind = ApparelGoals.Classify(issues);
var room = ChangingRoomNode(school, person);
return new ApparelActor(person.Female, issues, kind, room);
}
public static void EnqueueOnWeatherChange(School school, OutdoorWeather before, OutdoorWeather after)
{
if (before.Tenths == after.Tenths && before.Precipitation == after.Precipitation)
{
return;
}
foreach (var person in school.Roster!.People.OrderBy(row => row.Id, StringComparer.Ordinal))
{
school.DecisionQueue.Enqueue(person.Id);
}
}
internal static ApparelMode ModeAt(School school, Person person, SchoolClass? schoolClass)
{
if (school.Catalog is null || school.Timetable is null)
{
return ApparelMode.Everyday;
}
var slot = SchoolDay.At(school.Catalog, school.Clock.Time, school.SchoolWeekDays);
var day = SchoolDay.WeekdayIndex(school.Clock.Time);
var lessons = Duty.LessonsToday(person, schoolClass, school.Timetable, day);
if (lessons.Count == 0)
{
return ApparelMode.Everyday;
}
if (slot.Kind == DaySlotKind.Lesson)
{
var current = lessons.FirstOrDefault(lesson => lesson.Period == slot.Index);
return ApparelDresser.ModeForLesson(current?.Subject);
}
var next = lessons.Where(lesson => lesson.Period > slot.Index).OrderBy(lesson => lesson.Period).FirstOrDefault();
if (next is not null)
{
return ApparelDresser.ModeForLesson(next.Subject);
}
var previous = lessons.Where(lesson => lesson.Period < slot.Index).OrderByDescending(lesson => lesson.Period).FirstOrDefault();
if (previous is not null && ApparelDresser.ModeForLesson(previous.Subject) == ApparelMode.Pe)
{
return ApparelMode.Everyday;
}
return ApparelMode.Everyday;
}
internal static string? ChangingRoomNode(School school, Person person)
{
if (person.LockerRoomId is { } assigned)
{
return assigned;
}
if (school.Map is null || school.Catalog is null)
{
return null;
}
var want = ApparelDresser.ChangingRoomDef(person.Female);
if (want is null)
{
return null;
}
return school.Map.Rooms
.FirstOrDefault(room => room.Def.Equals(want, StringComparison.Ordinal))
?.Id;
}
}
+5 -2
View File
@@ -47,12 +47,15 @@ internal static class ApparelWear
}
school.ResetDayLog();
if (school.Catalog is null || !SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays))
if (school.Catalog is null)
{
return bandCrossed;
}
return bandCrossed | ReplaceRags(school);
var morningChanged = MorningDress.Apply(school);
var ragsReplaced = SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays)
&& ReplaceRags(school);
return bandCrossed | morningChanged | ragsReplaced;
}
internal static bool CrossedDayStart(DateTime before, DateTime after)
+76
View File
@@ -0,0 +1,76 @@
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation;
/// <summary>Applies pending dress rules and home morning outfits at six in the morning.</summary>
internal static class MorningDress
{
private static readonly Arch.Core.QueryDescription Identities =
new Arch.Core.QueryDescription().WithAll<PersonIdentity, Presence>();
public static bool Apply(School school)
{
if (school.Catalog is null || school.Roster is null)
{
return false;
}
var isWorkday = SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays);
var changed = false;
if (isWorkday && school.DressRules.HasPending)
{
school.DressRules = school.DressRules.ApplyPending();
changed = true;
}
var offCampus = OffCampusIds(school);
foreach (var person in school.Roster.People)
{
if (!offCampus.Contains(person.Id))
{
continue;
}
var schoolClass = ClassOf(school, person);
var subjects = isWorkday
? ApparelDresser.TodaySubjects(school, person, schoolClass)
: [];
changed |= ApparelDresser.RedressPerson(
school,
person,
ApparelMode.Everyday,
includeHome: true,
todaySubjects: subjects,
logChanges: false);
}
return changed;
}
private static HashSet<string> OffCampusIds(School school)
{
var ids = new HashSet<string>(StringComparer.Ordinal);
var world = school.World;
world.Query(in Identities, (ref PersonIdentity identity, ref Presence presence) =>
{
if (!presence.IsOnCampus)
{
ids.Add(identity.Id);
}
});
return ids;
}
private static SchoolClass? ClassOf(School school, Person person)
{
if (person.ClassId is null)
{
return null;
}
return school.Roster?.Classes.FirstOrDefault(row => row.Id.Equals(person.ClassId, StringComparison.Ordinal));
}
}
+8
View File
@@ -38,6 +38,14 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type,
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelReplaced"), name);
}
if (Type.Equals(PersonLogTypes.ApparelChanged, StringComparison.Ordinal))
{
var name = catalog.Things.TryGetValue(ThingDef, out var def)
? catalog.Label(locale, def)
: ThingDef;
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelChanged"), name);
}
if (Type.Equals(PersonLogTypes.ActionStarted, StringComparison.Ordinal)
|| Type.Equals(PersonLogTypes.ActionEnded, StringComparison.Ordinal))
{
+3 -1
View File
@@ -378,6 +378,7 @@ internal static class PresenceSystem
var frame = school.Catalog!.DayFrame;
var lunchOpen = frame is not null
&& SchoolDay.IsLunchWindow(frame, slot, ClassOf(school, person)?.Year);
var apparel = ApparelPresence.Build(school, person, ClassOf(school, person));
var state = new ActorState(
presence.NodeId,
presence.DestinationId,
@@ -390,7 +391,8 @@ internal static class PresenceSystem
duty,
needs.Values,
intent,
lunchOpen);
lunchOpen,
apparel);
var decision = DecisionPlanner.Decide(
school.Catalog!,
school.Map!,
+8
View File
@@ -85,6 +85,9 @@ public sealed class School : IDisposable
/// <summary>Street temperature and precipitation last committed for the clock and warmth.</summary>
public OutdoorWeather Weather { get; private set; } = OutdoorWeather.None;
/// <summary>Student and staff dress rules. Pending pair applies on the next work morning.</summary>
public SchoolDressRules DressRules { get; set; } = new();
/// <summary>Skill everyone generated for this school speaks natively.</summary>
public string? NativeLanguage { get; private set; }
@@ -339,7 +342,12 @@ public sealed class School : IDisposable
var next = EvaluateWeather();
if (force || next.Tenths != Weather.Tenths || next.Precipitation != Weather.Precipitation)
{
var before = Weather;
Weather = next;
if (Roster is not null && Catalog is not null)
{
ApparelPresence.EnqueueOnWeatherChange(this, before, next);
}
}
}