Enhance protocol and simulation features with activity tracking and behavior definitions

- Updated protocol documentation to include new `activity` and `activityLabel` fields in the person card response, reflecting real-time activity status.
- Introduced `BehaviorDef` to define behavior rules, including need thresholds and lesson skill gains, enhancing AI decision-making.
- Revised the `DefCatalog` to incorporate behavior definitions and updated validation logic to ensure proper behavior handling.
- Enhanced the simulation to manage presence and activity states, allowing for more dynamic interactions within the school environment.
- Updated tests to validate the new activity tracking and behavior functionalities, ensuring robust performance and reliability.
- Improved localization strings to support new activity and behavior features, enhancing user experience.
This commit is contained in:
Leonid Pershin
2026-08-19 19:49:44 +03:00
parent 3c54f981b7
commit b135a9caad
42 changed files with 1134 additions and 59 deletions
+53
View File
@@ -0,0 +1,53 @@
using HSchool.Content;
namespace HSchool.Ai;
/// <summary>One in-progress action. <see cref="ActionId"/> is null when the person is idle.</summary>
public readonly record struct ActivityProgress(string? ActionId, string? Thing, float RemainingMinutes)
{
public static ActivityProgress Idle { get; } = new(null, null, 0f);
public bool IsActive => ActionId is not null;
}
/// <summary>
/// Start, countdown and refill for one action. Occupancy is a count check — two people cannot
/// take the last chair. Simulation copies this onto components; the library has no world.
/// </summary>
public static class ActionStepper
{
public static ActivityProgress Begin(ActionDef def)
{
ArgumentNullException.ThrowIfNull(def);
var thing = string.IsNullOrWhiteSpace(def.Thing) ? null : def.Thing;
return new ActivityProgress(def.DefName, thing, def.Minutes);
}
public static ActivityProgress Advance(ActivityProgress activity, float minutes, out bool completed)
{
if (!activity.IsActive)
{
completed = false;
return activity;
}
var left = activity.RemainingMinutes - minutes;
if (left <= 0f)
{
completed = true;
return ActivityProgress.Idle;
}
completed = false;
return activity with { RemainingMinutes = left };
}
public static bool CanOccupy(int available, int occupied) => occupied < available;
public static float ApplyNeedGain(float current, ActionDef action, NeedDef need)
{
ArgumentNullException.ThrowIfNull(action);
ArgumentNullException.ThrowIfNull(need);
return Math.Clamp(current + action.NeedGain, need.Min, need.Max);
}
}
+9 -3
View File
@@ -12,8 +12,6 @@ public readonly record struct DayPlan(DateOnly Day, DateTime? AppearAt, DateTime
public static class DayPlans
{
private const int ExtraRollMax = 7;
public static DayPlan Build(
DefCatalog catalog,
WalkGraph walks,
@@ -111,7 +109,15 @@ public static class DayPlans
private static int SlackMinutes(DefCatalog catalog, Person person, int schoolSeed, DateOnly day)
{
var rng = new Random(Seed.Mix(schoolSeed, person.Id, day.DayNumber, Seed.CommuteSalt));
var extra = rng.Next(0, ExtraRollMax);
var min = 0;
var max = 6;
if (catalog.BehaviorRules is { } rules)
{
min = rules.CommuteSlackMin;
max = rules.CommuteSlackMax;
}
var extra = rng.Next(min, max + 1);
foreach (var name in person.Traits)
{
if (catalog.Traits.TryGetValue(name, out var trait))
+2
View File
@@ -264,6 +264,8 @@ export interface PersonCard {
readonly skills: readonly LabeledStat[];
readonly traits: readonly DefLabel[];
readonly needs: readonly NeedStat[];
readonly activity: string | null;
readonly activityLabel: string | null;
readonly family: {
readonly parents: readonly PersonRel[];
readonly children: readonly PersonRel[];
+6
View File
@@ -577,6 +577,12 @@ body {
color: var(--text-muted);
}
.people__card-place,
.people__card-activity {
margin: 4px 0 0;
color: var(--text-muted);
}
.people__section {
margin-top: 10px;
}
+3
View File
@@ -40,6 +40,9 @@ export function renderPersonCard(
if (place !== undefined && place.length > 0) {
parent.append(el('p', { class: 'people__card-place', text: place }));
}
if (card.activityLabel !== null && card.activityLabel.length > 0) {
parent.append(el('p', { class: 'people__card-activity', text: card.activityLabel }));
}
appendPairs(parent, t('peopleBody'), card.body);
appendPairs(parent, t('peopleSkills'), card.skills);
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
+5
View File
@@ -311,6 +311,7 @@ public sealed class CatalogLoader
var staffing = new Dictionary<string, StaffingDef>(StringComparer.Ordinal);
var dayFrames = new Dictionary<string, DayFrameDef>(StringComparer.Ordinal);
var holidays = new Dictionary<string, HolidayDef>(StringComparer.Ordinal);
var behavior = new Dictionary<string, BehaviorDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -367,6 +368,9 @@ public sealed class CatalogLoader
case DefKind.Holiday:
holidays[key.Name] = Jsonc.Deserialize<HolidayDef>(json);
break;
case DefKind.Behavior:
behavior[key.Name] = Jsonc.Deserialize<BehaviorDef>(json);
break;
}
}
@@ -389,6 +393,7 @@ public sealed class CatalogLoader
staffing,
dayFrames,
holidays,
behavior,
ru,
en);
}
+15 -3
View File
@@ -25,6 +25,7 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, StaffingDef> staffing,
IReadOnlyDictionary<string, DayFrameDef> dayFrames,
IReadOnlyDictionary<string, HolidayDef> holidays,
IReadOnlyDictionary<string, BehaviorDef> behavior,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -46,18 +47,22 @@ public sealed class DefCatalog
Staffing = staffing;
DayFrames = dayFrames;
Holidays = holidays;
Behavior = behavior;
_ru = ru;
_en = en;
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
AnyNeedRestoredOffCampus = needs.Values.Any(need => !need.Abstract && need.RestoredOffCampus);
}
/// <summary>
/// Whether any need in these packs actually drains. Core ships every rate at zero until
/// something can refill them, and walking every person twenty times a second to subtract
/// nothing is the kind of work that multiplies by six schools.
/// Whether any need in these packs actually drains. Walking every person twenty times a
/// second to subtract nothing is the kind of work that multiplies by six schools.
/// </summary>
public bool AnyNeedDecays { get; }
/// <summary>Whether any need snaps to max off campus. Sleep does; hunger does not.</summary>
public bool AnyNeedRestoredOffCampus { get; }
public IReadOnlyList<string> PackIds { get; }
public IReadOnlyDictionary<string, ActionDef> Actions { get; }
@@ -94,12 +99,17 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, HolidayDef> Holidays { get; }
public IReadOnlyDictionary<string, BehaviorDef> Behavior { get; }
/// <summary>The one concrete staffing ruleset, or null when a pack has not defined it.</summary>
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
/// <summary>The one concrete day frame, or null when a pack has not defined it.</summary>
public DayFrameDef? DayFrame => DayFrames.Values.FirstOrDefault(def => !def.Abstract);
/// <summary>The one concrete behaviour ruleset, or null when a pack has not defined it.</summary>
public BehaviorDef? BehaviorRules => Behavior.Values.FirstOrDefault(def => !def.Abstract);
private readonly IReadOnlyDictionary<string, string> _ru;
private readonly IReadOnlyDictionary<string, string> _en;
@@ -124,6 +134,7 @@ public sealed class DefCatalog
DefKind.Staffing => Staffing.GetValueOrDefault(defName),
DefKind.DayFrame => DayFrames.GetValueOrDefault(defName),
DefKind.Holiday => Holidays.GetValueOrDefault(defName),
DefKind.Behavior => Behavior.GetValueOrDefault(defName),
_ => null,
};
@@ -201,6 +212,7 @@ public sealed class DefCatalog
StaffingDef => DefKind.Staffing,
DayFrameDef => DefKind.DayFrame,
HolidayDef => DefKind.Holiday,
BehaviorDef => DefKind.Behavior,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
+24 -1
View File
@@ -19,6 +19,7 @@ public enum DefKind
Staffing,
DayFrame,
Holiday,
Behavior,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
@@ -31,7 +32,29 @@ public abstract class Def
public bool Abstract { get; init; }
}
public sealed class ActionDef : Def;
public sealed class ActionDef : Def
{
/// <summary>RoomDef or TerritoryDef where this is done. Required on concrete actions.</summary>
public string? Room { get; init; }
/// <summary>Thing occupied for the duration. Null means the room itself is enough.</summary>
public string? Thing { get; init; }
/// <summary>Game minutes the action takes. Applied in full when it completes.</summary>
public float Minutes { get; init; }
/// <summary>NeedDef refilled on completion. Null for walks and other leisure with no refill.</summary>
public string? Need { get; init; }
/// <summary>Added to the need in one shot when the action finishes, then clamped to minmax.</summary>
public float NeedGain { get; init; }
/// <summary>Empty means every role. Values are <see cref="PersonRoles"/> ids.</summary>
public IReadOnlyList<string> Roles { get; init; } = [];
/// <summary>Leisure weight. Zero means phase 21 will not pick this for fun — only for a need.</summary>
public float Weight { get; init; }
}
public sealed class ThingDef : Def
{
+19
View File
@@ -15,6 +15,25 @@ public sealed class MapLayout
public static MapLayout Parse(string jsonc, string? source = null) =>
Jsonc.Deserialize<MapLayout>(Jsonc.Parse(jsonc, source));
/// <summary>RoomDef or TerritoryDef of this node, or null when the id is not on the map.</summary>
public string? NodeDef(string nodeId)
{
if (Territory is { } territory && territory.Id.Equals(nodeId, StringComparison.Ordinal))
{
return territory.Def;
}
foreach (var room in Rooms)
{
if (room.Id.Equals(nodeId, StringComparison.Ordinal))
{
return room.Def;
}
}
return null;
}
}
public sealed class TerritoryNode
+3
View File
@@ -113,6 +113,9 @@ internal static class PackPaths
case "holidays":
kind = DefKind.Holiday;
return true;
case "behavior":
kind = DefKind.Behavior;
return true;
default:
kind = default;
return false;
+102
View File
@@ -49,6 +49,16 @@ internal static class PeopleDefValidator
ValidateHoliday(holiday);
}
foreach (var action in catalog.Actions.Values)
{
ValidateAction(action, catalog);
}
foreach (var behavior in catalog.Behavior.Values)
{
ValidateBehavior(behavior);
}
if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1)
{
throw new ContentLoadException("A catalog may only have one concrete StaffingDef.");
@@ -59,6 +69,11 @@ internal static class PeopleDefValidator
throw new ContentLoadException("A catalog may only have one concrete DayFrameDef.");
}
if (catalog.Behavior.Values.Count(def => !def.Abstract) > 1)
{
throw new ContentLoadException("A catalog may only have one concrete BehaviorDef.");
}
RequireBuildInputs(catalog);
}
@@ -373,6 +388,93 @@ internal static class PeopleDefValidator
}
}
private static void ValidateAction(ActionDef action, DefCatalog catalog)
{
if (action.Abstract)
{
return;
}
if (string.IsNullOrWhiteSpace(action.Room))
{
throw new ContentLoadException($"ActionDef '{action.DefName}' needs a room.");
}
var roomKnown = catalog.Rooms.TryGetValue(action.Room, out var room) && !room.Abstract;
var territoryKnown = catalog.Territories.TryGetValue(action.Room, out var territory) && !territory.Abstract;
if (!roomKnown && !territoryKnown)
{
throw new ContentLoadException($"ActionDef '{action.DefName}' references unknown room '{action.Room}'.");
}
if (action.Minutes <= 0)
{
throw new ContentLoadException($"ActionDef '{action.DefName}' minutes must be positive.");
}
if (action.Weight < 0)
{
throw new ContentLoadException($"ActionDef '{action.DefName}' weight cannot be negative.");
}
if (!string.IsNullOrWhiteSpace(action.Thing))
{
if (!catalog.Things.TryGetValue(action.Thing, out var thing) || thing.Abstract)
{
throw new ContentLoadException($"ActionDef '{action.DefName}' references unknown ThingDef '{action.Thing}'.");
}
}
if (string.IsNullOrWhiteSpace(action.Need))
{
if (action.NeedGain != 0)
{
throw new ContentLoadException($"ActionDef '{action.DefName}' has needGain without a need.");
}
}
else if (!catalog.Needs.TryGetValue(action.Need, out var need) || need.Abstract)
{
throw new ContentLoadException($"ActionDef '{action.DefName}' references unknown NeedDef '{action.Need}'.");
}
foreach (var role in action.Roles)
{
if (!PersonRoles.IsKnown(role))
{
throw new ContentLoadException($"ActionDef '{action.DefName}' has unknown role '{role}'.");
}
}
}
private static void ValidateBehavior(BehaviorDef behavior)
{
if (behavior.Abstract)
{
return;
}
if (behavior.NeedThreshold < 0f || behavior.NeedThreshold > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' needThreshold must be 01.");
}
if (behavior.LessonSkillPerHour < 0f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonSkillPerHour cannot be negative.");
}
if (behavior.SwitchMargin < 0f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' switchMargin cannot be negative.");
}
if (behavior.CommuteSlackMin < 0 || behavior.CommuteSlackMax < behavior.CommuteSlackMin)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' commute slack must be a non-negative range with min ≤ max.");
}
}
private static void ValidateHoliday(HolidayDef holiday)
{
if (holiday.Abstract)
+28
View File
@@ -201,6 +201,28 @@ public sealed class StaffingDef : Def
public float WeeksPerMonth { get; init; }
}
/// <summary>
/// One school's behaviour numbers: when a need is urgent, how fast lessons teach, commute slack,
/// and how much a new goal must beat the current one before a person switches. A catalog may have
/// only one concrete ruleset.
/// </summary>
public sealed class BehaviorDef : Def
{
/// <summary>A need at or below this value is urgent. Phase 21 turns that into a goal weight.</summary>
public float NeedThreshold { get; init; }
/// <summary>Skill points a lesson adds per game hour, before traits and need state. Unused until phase 21.</summary>
public float LessonSkillPerHour { get; init; }
/// <summary>Inclusive range of extra commute minutes rolled per person per day.</summary>
public int CommuteSlackMin { get; init; }
public int CommuteSlackMax { get; init; }
/// <summary>A new goal must beat the current one by this much before the person switches.</summary>
public float SwitchMargin { get; init; }
}
public enum BodyAttributeKind
{
Number,
@@ -252,6 +274,12 @@ public sealed class NeedDef : Def
public float Min { get; init; }
public float Max { get; init; } = 1;
/// <summary>
/// When true, this need snaps to <see cref="Max"/> off campus instead of draining. Sleep
/// restores overnight; hunger does not keep falling at home.
/// </summary>
public bool RestoredOffCampus { get; init; }
}
public sealed class CaseTable
+39
View File
@@ -35,4 +35,43 @@ public static class RoomOccupancy
return (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue);
}
/// <summary>
/// How many of <paramref name="thing"/> this node holds. Homeroom seats count as that many
/// of <see cref="RoomDef.SeatThing"/>; other rooms sum matching slot fills.
/// </summary>
public static int ThingCount(DefCatalog catalog, MapLayout map, string nodeId, string thing)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(map);
foreach (var room in map.Rooms)
{
if (!room.Id.Equals(nodeId, StringComparison.Ordinal))
{
continue;
}
var count = 0;
if (catalog.Rooms.TryGetValue(room.Def, out var def)
&& def.Homeroom
&& !string.IsNullOrWhiteSpace(def.SeatThing)
&& def.SeatThing.Equals(thing, StringComparison.Ordinal))
{
count += room.Seats > 0 ? Quantity(room.Seats) : Quantity(def.DefaultSeats);
}
foreach (var fill in room.Slots)
{
if (fill.Thing.Equals(thing, StringComparison.Ordinal))
{
count += Quantity(fill.Count);
}
}
return count;
}
return 0;
}
}
+2
View File
@@ -59,6 +59,8 @@ internal sealed record PersonCardResponse(
IReadOnlyList<LabeledStatResponse> Skills,
IReadOnlyList<DefLabelResponse> Traits,
IReadOnlyList<NeedStatResponse> Needs,
string? Activity,
string? ActivityLabel,
PersonFamilyResponse Family);
internal sealed record LabeledStatResponse(string Id, string Label, string Value);
@@ -14,6 +14,9 @@ internal static class PersonCardReader
private static readonly QueryDescription IdentityAndNeeds =
new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
private static readonly QueryDescription IdentityAndActivity =
new QueryDescription().WithAll<PersonIdentity, PersonActivity>();
public static PersonCardResponse? Read(School school, string personId, string locale)
{
var roster = school.Roster;
@@ -42,6 +45,17 @@ internal static class PersonCardReader
}
var needs = LiveNeeds(school.World, personId) ?? person.Needs;
var activityId = LiveActivity(school.World, personId);
string? activityLabel = null;
if (activityId is not null && catalog is not null && catalog.Actions.TryGetValue(activityId, out var action))
{
activityLabel = catalog.Label(locale, action);
}
else if (activityId is not null)
{
activityLabel = activityId;
}
return new PersonCardResponse(
person.Id,
person.Name.Full,
@@ -61,6 +75,8 @@ internal static class PersonCardReader
Skills(person, catalog, locale),
Traits(person, catalog, locale),
Needs(needs, catalog, locale),
activityId,
activityLabel,
Family(roster, person));
}
@@ -77,6 +93,20 @@ internal static class PersonCardReader
return found;
}
private static string? LiveActivity(World world, string personId)
{
string? found = null;
var query = IdentityAndActivity;
world.Query(in query, (ref PersonIdentity identity, ref PersonActivity activity) =>
{
if (identity.Id.Equals(personId, StringComparison.Ordinal) && activity.IsActive)
{
found = activity.ActionId;
}
});
return found;
}
private static IReadOnlyList<LabeledStatResponse> Body(Person person, DefCatalog? catalog, string locale)
{
var rows = new List<LabeledStatResponse>();
@@ -0,0 +1,51 @@
[
{
"defName": "EatLunch",
"room": "Cafeteria",
"thing": "Chair",
"minutes": 15,
"need": "Hunger",
"needGain": 0.5,
"roles": ["student", "staff"],
"weight": 0,
},
{
"defName": "UseToilet",
"room": "Restroom",
"minutes": 4,
"need": "Toilet",
"needGain": 1,
"roles": ["student", "staff"],
"weight": 0,
},
{
"defName": "RecessRest",
"room": "Corridor",
"minutes": 10,
"roles": ["student", "staff"],
"weight": 2,
},
{
"defName": "Chat",
"room": "Corridor",
"minutes": 8,
"need": "Social",
"needGain": 0.4,
"roles": ["student", "staff"],
"weight": 3,
},
{
"defName": "WalkCorridor",
"room": "Corridor",
"minutes": 3,
"roles": ["student", "staff"],
"weight": 1,
},
{
"defName": "WalkYard",
"room": "SchoolYard",
"minutes": 5,
"roles": ["student", "staff"],
"weight": 1,
},
]
@@ -1 +1 @@
{ "defName": "Sit" }
{ "defName": "Sit", "abstract": true }
@@ -0,0 +1,12 @@
{
"defName": "Behavior",
// A need at or below this is urgent. Phase 21 turns that into a goal weight.
"needThreshold": 0.35,
// Skill points a lesson adds per game hour, before traits and need state.
"lessonSkillPerHour": 0.05,
// Inclusive extra commute minutes. 06 matches the previous hardcoded roll so
// the same seed still arrives at the same minute.
"commuteSlackMin": 0,
"commuteSlackMax": 6,
"switchMargin": 0.15,
}
@@ -1,6 +1,6 @@
[
{ "defName": "Sleep", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
{ "defName": "Hunger", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
{ "defName": "Toilet", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
{ "defName": "Social", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
{ "defName": "Sleep", "initial": 1, "decayPerHour": 0.05, "min": 0, "max": 1, "restoredOffCampus": true },
{ "defName": "Hunger", "initial": 1, "decayPerHour": 0.1, "min": 0, "max": 1 },
{ "defName": "Toilet", "initial": 1, "decayPerHour": 0.15, "min": 0, "max": 1 },
{ "defName": "Social", "initial": 1, "decayPerHour": 0.08, "min": 0, "max": 1 },
]
@@ -1,5 +1,12 @@
{
"Sit": "Sit",
"EatLunch": "Lunch",
"UseToilet": "Restroom",
"RecessRest": "Recess",
"Chat": "Chat",
"WalkCorridor": "Walk the corridor",
"WalkYard": "Walk the yard",
"Behavior": "Behavior rules",
"Chair": "Chair",
"DirectorsChair": "Principal's chair",
"Desk": "Desk",
@@ -1,5 +1,12 @@
{
"Sit": "Сесть",
"EatLunch": "Обед",
"UseToilet": "Туалет",
"RecessRest": "Перемена",
"Chat": "Разговор",
"WalkCorridor": "Прогулка по коридору",
"WalkYard": "Прогулка во дворе",
"Behavior": "Правила поведения",
"Chair": "Стул",
"DirectorsChair": "Кресло директора",
"Desk": "Стол",
+160
View File
@@ -0,0 +1,160 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
namespace HSchool.Simulation;
/// <summary>
/// Starts and ticks actions on World entities. Rules live in <see cref="ActionStepper"/>; this
/// adapter copies them onto components and counts occupied things at a node.
/// </summary>
internal static class ActivitySystem
{
private static readonly QueryDescription People =
new QueryDescription().WithAll<PersonIdentity, PersonRoles, PersonNeeds, Presence, PersonActivity>();
public static bool TryStart(School school, string personId, string actionId)
{
if (school.Catalog is null || school.Map is null)
{
return false;
}
if (!school.Catalog.Actions.TryGetValue(actionId, out var action) || action.Abstract)
{
return false;
}
var started = false;
var world = school.World;
world.Query(
in People,
(ref PersonIdentity identity, ref PersonRoles roles, ref Presence presence, ref PersonActivity activity) =>
{
if (started || !identity.Id.Equals(personId, StringComparison.Ordinal))
{
return;
}
if (activity.IsActive || presence.NodeId is null)
{
return;
}
if (!RoleFits(action, roles))
{
return;
}
var location = school.Map.NodeDef(presence.NodeId);
if (location is null || !location.Equals(action.Room, StringComparison.Ordinal))
{
return;
}
if (!string.IsNullOrWhiteSpace(action.Thing))
{
var available = RoomOccupancy.ThingCount(school.Catalog, school.Map, presence.NodeId, action.Thing);
var occupied = Occupied(school, presence.NodeId, action.Thing);
if (!ActionStepper.CanOccupy(available, occupied))
{
return;
}
}
var next = ActionStepper.Begin(action);
activity = new PersonActivity(next.ActionId, next.Thing, next.RemainingMinutes);
started = true;
});
return started;
}
public static void Apply(School school, double gameMinutes)
{
if (school.Catalog is null || gameMinutes <= 0)
{
return;
}
var catalog = school.Catalog;
var minutes = (float)gameMinutes;
var world = school.World;
world.Query(in People, (ref PersonNeeds needs, ref Presence presence, ref PersonActivity activity) =>
{
if (!activity.IsActive)
{
return;
}
if (!presence.IsOnCampus || activity.ActionId is null || !catalog.Actions.TryGetValue(activity.ActionId, out var action))
{
activity = PersonActivity.Idle;
return;
}
var next = ActionStepper.Advance(
new ActivityProgress(activity.ActionId, activity.Thing, activity.RemainingMinutes),
minutes,
out var completed);
if (!completed)
{
activity = new PersonActivity(next.ActionId, next.Thing, next.RemainingMinutes);
return;
}
if (!string.IsNullOrWhiteSpace(action.Need)
&& catalog.Needs.TryGetValue(action.Need, out var need)
&& needs.Values.TryGetValue(action.Need, out var current))
{
needs.Values[action.Need] = ActionStepper.ApplyNeedGain(current, action, need);
}
activity = PersonActivity.Idle;
});
}
private static int Occupied(School school, string nodeId, string thing)
{
var occupied = 0;
var world = school.World;
world.Query(in People, (ref Presence presence, ref PersonActivity activity) =>
{
if (activity.IsActive
&& presence.NodeId is not null
&& presence.NodeId.Equals(nodeId, StringComparison.Ordinal)
&& thing.Equals(activity.Thing, StringComparison.Ordinal))
{
occupied++;
}
});
return occupied;
}
private static bool RoleFits(ActionDef action, PersonRoles roles)
{
if (action.Roles.Count == 0)
{
return true;
}
foreach (var role in action.Roles)
{
if (role.Equals(HSchool.Content.PersonRoles.Student, StringComparison.OrdinalIgnoreCase) && roles.IsStudent)
{
return true;
}
if (role.Equals(HSchool.Content.PersonRoles.Staff, StringComparison.OrdinalIgnoreCase) && roles.IsStaff)
{
return true;
}
if (role.Equals(HSchool.Content.PersonRoles.Parent, StringComparison.OrdinalIgnoreCase) && roles.IsParent)
{
return true;
}
}
return false;
}
}
@@ -22,6 +22,17 @@ public readonly record struct PersonTraits(IReadOnlyList<string> Ids);
/// <summary>Live need values. The dictionary is mutated in place as the clock advances.</summary>
public readonly record struct PersonNeeds(Dictionary<string, float> Values);
/// <summary>
/// The action this person is carrying out. Occupies <see cref="Thing"/> until it finishes.
/// Idle when <see cref="ActionId"/> is null.
/// </summary>
public readonly record struct PersonActivity(string? ActionId, string? Thing, float RemainingMinutes)
{
public static PersonActivity Idle { get; } = new(null, null, 0f);
public bool IsActive => ActionId is not null;
}
public readonly record struct PersonRoles(
bool IsStudent,
bool IsStaff,
+28 -5
View File
@@ -1,26 +1,49 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
namespace HSchool.Simulation;
/// <summary>
/// Drains needs by <see cref="NeedDef.DecayPerHour"/> × simulated hours. Core ships decay at zero
/// so this is a no-op until something exists that can refill them.
/// Drains needs by <see cref="NeedDef.DecayPerHour"/> × simulated hours while the person is on
/// campus. Off campus, needs do not drain and <see cref="NeedDef.RestoredOffCampus"/> snaps to max
/// — sleep comes back overnight, hunger does not keep falling at home.
/// </summary>
public static class NeedDecay
{
private static readonly QueryDescription PeopleWithNeeds = new QueryDescription().WithAll<PersonNeeds>();
private static readonly QueryDescription PeopleWithNeeds =
new QueryDescription().WithAll<PersonNeeds, Presence>();
public static void Apply(World world, DefCatalog catalog, double gameMinutes)
{
if (gameMinutes <= 0 || !catalog.AnyNeedDecays)
if (gameMinutes <= 0 || (!catalog.AnyNeedDecays && !catalog.AnyNeedRestoredOffCampus))
{
return;
}
var hours = gameMinutes / 60d;
world.Query(in PeopleWithNeeds, (ref PersonNeeds needs) =>
world.Query(in PeopleWithNeeds, (ref PersonNeeds needs, ref Presence presence) =>
{
if (!presence.IsOnCampus)
{
foreach (var def in catalog.Needs.Values)
{
if (def.Abstract || !def.RestoredOffCampus || !needs.Values.ContainsKey(def.DefName))
{
continue;
}
needs.Values[def.DefName] = def.Max;
}
return;
}
if (!catalog.AnyNeedDecays)
{
return;
}
foreach (var def in catalog.Needs.Values)
{
if (def.Abstract || !needs.Values.TryGetValue(def.DefName, out var current))
+23 -10
View File
@@ -13,7 +13,7 @@ namespace HSchool.Simulation;
internal static class PresenceSystem
{
private static readonly QueryDescription People =
new QueryDescription().WithAll<PersonIdentity, PersonRoles, PersonTraits, Presence>();
new QueryDescription().WithAll<PersonIdentity, PersonRoles, PersonTraits, Presence, PersonActivity>();
public static void Apply(School school, double gameMinutes)
{
@@ -48,7 +48,7 @@ internal static class PresenceSystem
{
var rows = new List<PresenceSnapshot>();
var world = school.World;
world.Query(in People, (ref PersonIdentity identity, ref Presence presence) =>
world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) =>
{
rows.Add(new PresenceSnapshot(
identity.Id,
@@ -56,7 +56,10 @@ internal static class PresenceSystem
presence.RemainingMinutes,
presence.DestinationId,
presence.HeadingHome,
presence.Path));
presence.Path,
activity.ActionId,
activity.Thing,
activity.RemainingMinutes));
});
rows.Sort((left, right) => StringComparer.Ordinal.Compare(left.PersonId, right.PersonId));
return rows;
@@ -74,17 +77,19 @@ internal static class PresenceSystem
var byId = saved
.Where(row => !string.IsNullOrWhiteSpace(row.PersonId))
.ToDictionary(row => row.PersonId, StringComparer.Ordinal);
ForEachPerson(school, (person, _, ref presence) =>
ForEachPerson(school, (person, _, ref presence, ref activity) =>
{
if (!byId.TryGetValue(person.Id, out var row))
{
presence = PlaceByDuty(school, person);
activity = PersonActivity.Idle;
return;
}
if (row.NodeId is null)
{
presence = Presence.OffCampus;
activity = PersonActivity.Idle;
return;
}
@@ -94,16 +99,20 @@ internal static class PresenceSystem
row.DestinationId,
row.HeadingHome,
row.Path?.ToArray() ?? []);
activity = string.IsNullOrWhiteSpace(row.ActionId)
? PersonActivity.Idle
: new PersonActivity(row.ActionId, row.ActionThing, row.ActionRemaining);
});
}
public static void PlaceMissingByDuty(School school)
{
ForEachPerson(school, (person, _, ref presence) =>
ForEachPerson(school, (person, _, ref presence, ref activity) =>
{
if (!presence.IsOnCampus)
{
presence = PlaceByDuty(school, person);
activity = PersonActivity.Idle;
}
});
}
@@ -323,7 +332,7 @@ internal static class PresenceSystem
}
var world = school.World;
world.Query(in People, (ref Presence presence) =>
world.Query(in People, (ref Presence presence, ref PersonActivity activity) =>
{
if (presence.HeadingHome
&& presence.NodeId is not null
@@ -332,6 +341,7 @@ internal static class PresenceSystem
&& presence.NodeId.Equals(yard, StringComparison.Ordinal))
{
presence = Presence.OffCampus;
activity = PersonActivity.Idle;
}
});
}
@@ -349,17 +359,17 @@ internal static class PresenceSystem
private static IReadOnlyList<Person> OrderedPeople(School school) =>
school.Roster!.People.OrderBy(person => person.Id, StringComparer.Ordinal).ToArray();
private delegate void PersonAction(Person person, PersonIdentity identity, ref Presence presence);
private delegate void PersonAction(Person person, PersonIdentity identity, ref Presence presence, ref PersonActivity activity);
private static void ForEachPerson(School school, PersonAction action)
{
var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var world = school.World;
world.Query(in People, (ref PersonIdentity identity, ref Presence presence) =>
world.Query(in People, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity) =>
{
if (roster.TryGetValue(identity.Id, out var person))
{
action(person, identity, ref presence);
action(person, identity, ref presence, ref activity);
}
});
}
@@ -371,4 +381,7 @@ public sealed record PresenceSnapshot(
float RemainingMinutes,
string? DestinationId,
bool HeadingHome,
IReadOnlyList<string> Path);
IReadOnlyList<string> Path,
string? ActionId = null,
string? ActionThing = null,
float ActionRemaining = 0f);
+2 -1
View File
@@ -38,7 +38,8 @@ public static class RosterSpawner
person.ClassId,
person.Position,
person.WorkplaceRoomId),
Presence.OffCampus);
Presence.OffCampus,
PersonActivity.Idle);
}
}
+11 -1
View File
@@ -120,6 +120,14 @@ public sealed class School : IDisposable
DecisionQueue.Clear();
}
public bool TryStartAction(string personId, string actionId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(personId);
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
return ActivitySystem.TryStart(this, personId, actionId);
}
public void ConfigurePresence(int weekDays = 5, int maxDecisionsPerTick = 64, int maxSkipDays = 400)
{
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -180,6 +188,7 @@ public sealed class School : IDisposable
peopleChanged |= TryApplicantRefresh();
PlanDay = null;
LastDecisionSlot = null;
NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes);
return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged);
}
@@ -214,7 +223,7 @@ public sealed class School : IDisposable
}
}
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay.</summary>
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, presence, actions, then need decay.</summary>
/// <returns><see langword="true"/> when the roster or the applicant pool changed this step.</returns>
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
{
@@ -234,6 +243,7 @@ public sealed class School : IDisposable
}
PresenceSystem.Apply(this, gameMinutes);
ActivitySystem.Apply(this, gameMinutes);
if (Catalog is not null)
{
NeedDecay.Apply(World, Catalog, gameMinutes);