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:
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -38,7 +38,8 @@ public static class RosterSpawner
|
||||
person.ClassId,
|
||||
person.Position,
|
||||
person.WorkplaceRoomId),
|
||||
Presence.OffCampus);
|
||||
Presence.OffCampus,
|
||||
PersonActivity.Idle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user