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
@@ -0,0 +1,153 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation.Tests;
public class ActivityTests
{
private static readonly DateTime Start = new(2012, 4, 3, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void EatLunch_FillsHungerAfterTheStatedMinutes()
{
using var school = OpenCafeteria(chairs: 8);
Spawn(school, "a", hunger: 0.3f);
Assert.True(school.TryStartAction("a", "EatLunch"));
school.Tick(2.8d, 5d);
Assert.Equal("EatLunch", ActivityOf(school, "a"));
Assert.Equal(0.3f, HungerOf(school, "a"));
school.Tick(0.2d, 5d);
Assert.Null(ActivityOf(school, "a"));
Assert.Equal(0.8f, HungerOf(school, "a"));
}
[Fact]
public void FewerChairsThanPeople_ExtrasDoNotStart()
{
using var school = OpenCafeteria(chairs: 1);
Spawn(school, "a", hunger: 0.3f);
Spawn(school, "b", hunger: 0.3f);
Assert.True(school.TryStartAction("a", "EatLunch"));
Assert.False(school.TryStartAction("b", "EatLunch"));
Assert.Equal("EatLunch", ActivityOf(school, "a"));
Assert.Null(ActivityOf(school, "b"));
}
private static School OpenCafeteria(int chairs)
{
var catalog = new CatalogLoader().Load(
[CatalogLoader.CorePackId],
[
new ContentDocument("core", "defs/actions/sit.jsonc", """{ "defName": "Sit", "abstract": true }"""),
new ContentDocument(
"core",
"defs/actions/eat.jsonc",
"""
{
"defName": "EatLunch",
"room": "Cafeteria",
"thing": "Chair",
"minutes": 15,
"need": "Hunger",
"needGain": 0.5,
"roles": ["student"]
}
"""),
new ContentDocument(
"core",
"defs/needs/hunger.jsonc",
"""{ "defName": "Hunger", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 }"""),
new ContentDocument("core", "defs/things/chair.jsonc", """{ "defName": "Chair", "actions": ["Sit"] }"""),
new ContentDocument("core", "defs/territories/yard.jsonc", """{ "defName": "Yard", "travelMinutes": 1 }"""),
new ContentDocument("core", "defs/buildings/main.jsonc", """{ "defName": "Main" }"""),
new ContentDocument("core", "defs/floors/floor.jsonc", """{ "defName": "Floor" }"""),
new ContentDocument(
"core",
"defs/rooms/cafeteria.jsonc",
"""
{
"defName": "Cafeteria",
"slots": [{ "key": "seats", "thing": "Chair" }],
"travelMinutes": 1
}
"""),
]);
var map = new MapLayout
{
Territory = new TerritoryNode { Id = "yard", Def = "Yard" },
Buildings = [new BuildingNode { Id = "main", Def = "Main" }],
Floors = [new FloorNode { Id = "floor-1", Def = "Floor", Building = "main" }],
Rooms =
[
new RoomNode
{
Id = "cafe",
Def = "Cafeteria",
Building = "main",
Floor = "floor-1",
Slots = [new SlotFill { Key = "seats", Thing = "Chair", Count = chairs }],
},
],
Links = [new MapLink { A = "yard", B = "cafe" }],
};
MapValidator.Validate(map, catalog);
return School.Create(1, "Столовая", Start, catalog, map);
}
private static void Spawn(School school, string id, float hunger)
{
school.World.Create(
new PersonIdentity(id, "f", false, Start, DummyName()),
new PersonRoles(true, false, false, null, null, null),
new PersonNeeds(new Dictionary<string, float>(StringComparer.Ordinal) { ["Hunger"] = hunger }),
new Presence("cafe", 0f, "cafe", false, []),
PersonActivity.Idle);
}
private static string? ActivityOf(School school, string id)
{
string? found = null;
var query = new QueryDescription().WithAll<PersonIdentity, PersonActivity>();
school.World.Query(in query, (ref PersonIdentity identity, ref PersonActivity activity) =>
{
if (identity.Id.Equals(id, StringComparison.Ordinal))
{
found = activity.ActionId;
}
});
return found;
}
private static float HungerOf(School school, string id)
{
var value = float.NaN;
var query = new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
school.World.Query(in query, (ref PersonIdentity identity, ref PersonNeeds needs) =>
{
if (identity.Id.Equals(id, StringComparison.Ordinal))
{
value = needs.Values["Hunger"];
}
});
return value;
}
private static PersonName DummyName()
{
var cases = new CaseTable
{
Nom = "А",
Gen = "А",
Dat = "А",
Acc = "А",
Ins = "А",
Pre = "А",
};
return new PersonName("А", "А", "А", cases, cases, cases);
}
}
@@ -0,0 +1,110 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
namespace HSchool.Simulation.Tests;
public class NeedDecayTests
{
[Fact]
public void Hunger_DropsByDecayPerHourOnCampus()
{
var catalog = VanillaCatalog();
var hunger = catalog.Needs["Hunger"];
var world = World.Create();
try
{
world.Create(
new PersonNeeds(new Dictionary<string, float>(StringComparer.Ordinal) { ["Hunger"] = hunger.Initial }),
new Presence("cafeteria", 0f, "cafeteria", false, []));
NeedDecay.Apply(world, catalog, gameMinutes: 60);
var query = new QueryDescription().WithAll<PersonNeeds>();
world.Query(
in query,
(ref PersonNeeds needs) =>
Assert.Equal(hunger.Initial - hunger.DecayPerHour, needs.Values["Hunger"], precision: 4));
}
finally
{
World.Destroy(world);
}
}
[Fact]
public void Sleep_ReturnsToMaxOffCampus()
{
var catalog = VanillaCatalog();
var world = World.Create();
try
{
world.Create(
new PersonNeeds(new Dictionary<string, float>(StringComparer.Ordinal)
{
["Sleep"] = 0.2f,
["Hunger"] = 0.4f,
}),
Presence.OffCampus);
NeedDecay.Apply(world, catalog, gameMinutes: 60);
var query = new QueryDescription().WithAll<PersonNeeds>();
world.Query(in query, (ref PersonNeeds needs) =>
{
Assert.Equal(catalog.Needs["Sleep"].Max, needs.Values["Sleep"]);
Assert.Equal(0.4f, needs.Values["Hunger"]);
});
}
finally
{
World.Destroy(world);
}
}
[Fact]
public void SameInputs_LeaveTheSameNeedValues()
{
var catalog = VanillaCatalog();
var first = HungerAfterHour(catalog);
var second = HungerAfterHour(catalog);
Assert.Equal(first, second);
}
private static float HungerAfterHour(DefCatalog catalog)
{
var world = World.Create();
try
{
world.Create(
new PersonNeeds(new Dictionary<string, float>(StringComparer.Ordinal) { ["Hunger"] = 1f }),
new Presence("cafeteria", 0f, "cafeteria", false, []));
NeedDecay.Apply(world, catalog, gameMinutes: 60);
var value = 0f;
var query = new QueryDescription().WithAll<PersonNeeds>();
world.Query(in query, (ref PersonNeeds needs) => value = needs.Values["Hunger"]);
return value;
}
finally
{
World.Destroy(world);
}
}
private static DefCatalog VanillaCatalog()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var documents = new List<ContentDocument>();
foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
{
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
}
return new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
}
}
@@ -1,4 +1,5 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
@@ -56,7 +57,7 @@ public class PeopleInSchoolTests
}
[Fact]
public void CoreNeeds_DoNotMoveOnTick()
public void OffCampusNeeds_DoNotMoveOnTick()
{
var (catalog, map) = Vanilla();
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 2, "Slavic", Start);
@@ -90,7 +91,9 @@ public class PeopleInSchoolTests
var world = World.Create();
try
{
world.Create(new PersonNeeds(new Dictionary<string, float>(StringComparer.Ordinal) { ["Hunger"] = 1f }));
world.Create(
new PersonNeeds(new Dictionary<string, float>(StringComparer.Ordinal) { ["Hunger"] = 1f }),
new Presence("yard", 0f, "yard", false, []));
NeedDecay.Apply(world, catalog, gameMinutes: 60);
var query = new QueryDescription().WithAll<PersonNeeds>();
world.Query(in query, (ref PersonNeeds needs) => Assert.Equal(0f, needs.Values["Hunger"]));
@@ -48,7 +48,7 @@ public class SchoolTests
var loader = new CatalogLoader();
var documents = new[]
{
new ContentDocument("core", "defs/actions/sit.jsonc", """{ "defName": "Sit" }"""),
new ContentDocument("core", "defs/actions/sit.jsonc", """{ "defName": "Sit", "abstract": true }"""),
new ContentDocument("core", "defs/things/chair.jsonc", """{ "defName": "Chair", "actions": ["Sit"] }"""),
new ContentDocument("core", "defs/territories/yard.jsonc", """{ "defName": "Yard", "travelMinutes": 1 }"""),
new ContentDocument("core", "defs/buildings/main.jsonc", """{ "defName": "Main" }"""),