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
+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;
}
}