From b135a9caad06088fe348d03f50aa2b713957db1c Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 19 Aug 2026 19:49:44 +0300 Subject: [PATCH] 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. --- docs/phases/20-needs-actions.md | 28 +-- docs/phases/README.md | 2 +- docs/protocol.md | 5 + src/HSchool.Ai/ActionStepper.cs | 53 ++++++ src/HSchool.Ai/DayPlan.cs | 12 +- src/HSchool.Client/src/net/api.ts | 2 + src/HSchool.Client/src/style.css | 6 + src/HSchool.Client/src/ui/personCard.ts | 3 + src/HSchool.Content/CatalogLoader.cs | 5 + src/HSchool.Content/DefCatalog.cs | 18 +- src/HSchool.Content/Defs.cs | 25 ++- src/HSchool.Content/MapLayout.cs | 19 +++ src/HSchool.Content/PackPaths.cs | 3 + src/HSchool.Content/PeopleDefValidator.cs | 102 +++++++++++ src/HSchool.Content/PeopleDefs.cs | 28 +++ src/HSchool.Content/RoomOccupancy.cs | 39 +++++ src/HSchool.Server/Api/PeopleModels.cs | 2 + src/HSchool.Server/Game/PersonCardReader.cs | 30 ++++ .../mods/core/defs/actions/living.jsonc | 51 ++++++ .../mods/core/defs/actions/sit.jsonc | 2 +- .../mods/core/defs/behavior/rules.jsonc | 12 ++ .../mods/core/defs/needs/needs.jsonc | 8 +- .../mods/core/localizations/en.jsonc | 7 + .../mods/core/localizations/ru.jsonc | 7 + src/HSchool.Simulation/ActivitySystem.cs | 160 ++++++++++++++++++ .../Components/PersonComponents.cs | 11 ++ src/HSchool.Simulation/NeedDecay.cs | 33 +++- src/HSchool.Simulation/PresenceSystem.cs | 33 ++-- src/HSchool.Simulation/RosterSpawner.cs | 3 +- src/HSchool.Simulation/School.cs | 12 +- tests/HSchool.Ai.Tests/ActionStepperTests.cs | 73 ++++++++ tests/HSchool.Content.Tests/ActionDefTests.cs | 87 ++++++++++ .../CatalogLoaderTests.cs | 12 +- .../HSchool.Content.Tests/InheritanceTests.cs | 4 +- .../MapValidationTests.cs | 2 +- tests/HSchool.Content.Tests/PatchTests.cs | 4 +- tests/HSchool.Content.Tests/PeopleDefTests.cs | 4 +- .../HSchool.Content.Tests/VanillaCoreTests.cs | 14 ++ .../HSchool.Simulation.Tests/ActivityTests.cs | 153 +++++++++++++++++ .../NeedDecayTests.cs | 110 ++++++++++++ .../PeopleInSchoolTests.cs | 7 +- tests/HSchool.Simulation.Tests/SchoolTests.cs | 2 +- 42 files changed, 1134 insertions(+), 59 deletions(-) create mode 100644 src/HSchool.Ai/ActionStepper.cs create mode 100644 src/HSchool.Server/mods/core/defs/actions/living.jsonc create mode 100644 src/HSchool.Server/mods/core/defs/behavior/rules.jsonc create mode 100644 src/HSchool.Simulation/ActivitySystem.cs create mode 100644 tests/HSchool.Ai.Tests/ActionStepperTests.cs create mode 100644 tests/HSchool.Content.Tests/ActionDefTests.cs create mode 100644 tests/HSchool.Simulation.Tests/ActivityTests.cs create mode 100644 tests/HSchool.Simulation.Tests/NeedDecayTests.cs diff --git a/docs/phases/20-needs-actions.md b/docs/phases/20-needs-actions.md index 4d30f95..431e00e 100644 --- a/docs/phases/20-needs-actions.md +++ b/docs/phases/20-needs-actions.md @@ -11,27 +11,27 @@ ## Задачи -- [ ] `ActionDef` получает поля: комната, вещь, длительность, восполняемая нужда и величина, +- [x] `ActionDef` получает поля: комната, вещь, длительность, восполняемая нужда и величина, роли, вес досуга -- [ ] Действия в `core`: поесть в столовой, санузел, отдохнуть на перемене, пообщаться, +- [x] Действия в `core`: поесть в столовой, санузел, отдохнуть на перемене, пообщаться, прогуляться по коридору и по двору -- [ ] Деф правил поведения — как `StaffingDef` у штата: пороги нужд, скорость обучения на уроке, +- [x] Деф правил поведения — как `StaffingDef` у штата: пороги нужд, скорость обучения на уроке, разброс запаса на дорогу, порог переключения решения -- [ ] Декей нужд включается в `core`. Сон восстанавливается вне школы, а не действием -- [ ] Действие занимает вещь на время: за один стол не садятся вдвоём -- [ ] Выполнение действия: начало, отсчёт минут, восполнение нужды по завершении. Правила — в +- [x] Декей нужд включается в `core`. Сон восстанавливается вне школы, а не действием +- [x] Действие занимает вещь на время: за один стол не садятся вдвоём +- [x] Выполнение действия: начало, отсчёт минут, восполнение нужды по завершении. Правила — в `HSchool.Ai`, компоненты и мир — в `HSchool.Simulation` -- [ ] Валидатор дефов ловит действие без комнаты, с неизвестной нуждой и с неизвестной вещью -- [ ] Карточка человека показывает, что он делает; полоски нужд начинают шевелиться +- [x] Валидатор дефов ловит действие без комнаты, с неизвестной нуждой и с неизвестной вещью +- [x] Карточка человека показывает, что он делает; полоски нужд начинают шевелиться ## Тесты, без которых фаза не закрыта -- [ ] Голод падает за игровой час ровно на `decayPerHour` -- [ ] Обед в столовой поднимает голод на заявленную величину и занимает заявленное время -- [ ] Столов меньше, чем желающих: лишние не начинают действие, а не садятся сквозь друг друга -- [ ] Ночь вне школы возвращает сон к максимуму -- [ ] Действие с неизвестной нуждой роняет загрузку каталога с внятной ошибкой -- [ ] Те же входы дают те же значения нужд на тот же момент +- [x] Голод падает за игровой час ровно на `decayPerHour` +- [x] Обед в столовой поднимает голод на заявленную величину и занимает заявленное время +- [x] Столов меньше, чем желающих: лишние не начинают действие, а не садятся сквозь друг друга +- [x] Ночь вне школы возвращает сон к максимуму +- [x] Действие с неизвестной нуждой роняет загрузку каталога с внятной ошибкой +- [x] Те же входы дают те же значения нужд на тот же момент ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index 3e863fe..9cac064 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -89,5 +89,5 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [20. Нужды и действия](20-needs-actions.md) | ⬜ | `ActionDef` с полями, декей, восполнение | +| [20. Нужды и действия](20-needs-actions.md) | ✅ | `ActionDef` с полями, декей, восполнение | | [21. Выбор действия](21-decisions.md) | ⬜ | Цели и веса, «дойти → сделать», уход с урока, рост навыка | diff --git a/docs/protocol.md b/docs/protocol.md index 395a6ad..a76641e 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -218,6 +218,9 @@ and partners for a parent. The client opens another card by id; this response do cards. `classId` is the homeroom class for a pupil and `null` otherwise — the personal timetable grid fetches `GET .../timetable?classId=` with it. +`activity` is the ActionDef name currently in progress, or `null` when idle. `activityLabel` +is that def in the request locale. HTTP JSON is additive — no protocol version bump. + ```json { "id": "f0.c0", @@ -233,6 +236,8 @@ grid fetches `GET .../timetable?classId=` with it. "skills": [{ "id": "Math", "label": "Математика", "value": "62" }], "traits": [{ "defName": "Diligent", "label": "Усидчивый" }], "needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }], + "activity": null, + "activityLabel": null, "family": { "parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }], "children": [], diff --git a/src/HSchool.Ai/ActionStepper.cs b/src/HSchool.Ai/ActionStepper.cs new file mode 100644 index 0000000..aa69d58 --- /dev/null +++ b/src/HSchool.Ai/ActionStepper.cs @@ -0,0 +1,53 @@ +using HSchool.Content; + +namespace HSchool.Ai; + +/// One in-progress action. is null when the person is idle. +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; +} + +/// +/// 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. +/// +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); + } +} diff --git a/src/HSchool.Ai/DayPlan.cs b/src/HSchool.Ai/DayPlan.cs index 35c6ce5..583db45 100644 --- a/src/HSchool.Ai/DayPlan.cs +++ b/src/HSchool.Ai/DayPlan.cs @@ -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)) diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 5faae53..836c5ad 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -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[]; diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index f5a4d54..25ccff6 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -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; } diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index 8ba9c65..3c78c8b 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -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)); diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index a97e0a0..77366f4 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -311,6 +311,7 @@ public sealed class CatalogLoader var staffing = new Dictionary(StringComparer.Ordinal); var dayFrames = new Dictionary(StringComparer.Ordinal); var holidays = new Dictionary(StringComparer.Ordinal); + var behavior = new Dictionary(StringComparer.Ordinal); foreach (var (key, json) in resolved) { @@ -367,6 +368,9 @@ public sealed class CatalogLoader case DefKind.Holiday: holidays[key.Name] = Jsonc.Deserialize(json); break; + case DefKind.Behavior: + behavior[key.Name] = Jsonc.Deserialize(json); + break; } } @@ -389,6 +393,7 @@ public sealed class CatalogLoader staffing, dayFrames, holidays, + behavior, ru, en); } diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs index 4f82ed2..fd40997 100644 --- a/src/HSchool.Content/DefCatalog.cs +++ b/src/HSchool.Content/DefCatalog.cs @@ -25,6 +25,7 @@ public sealed class DefCatalog IReadOnlyDictionary staffing, IReadOnlyDictionary dayFrames, IReadOnlyDictionary holidays, + IReadOnlyDictionary behavior, IReadOnlyDictionary ru, IReadOnlyDictionary 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); } /// - /// 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. /// public bool AnyNeedDecays { get; } + /// Whether any need snaps to max off campus. Sleep does; hunger does not. + public bool AnyNeedRestoredOffCampus { get; } + public IReadOnlyList PackIds { get; } public IReadOnlyDictionary Actions { get; } @@ -94,12 +99,17 @@ public sealed class DefCatalog public IReadOnlyDictionary Holidays { get; } + public IReadOnlyDictionary Behavior { get; } + /// The one concrete staffing ruleset, or null when a pack has not defined it. public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract); /// The one concrete day frame, or null when a pack has not defined it. public DayFrameDef? DayFrame => DayFrames.Values.FirstOrDefault(def => !def.Abstract); + /// The one concrete behaviour ruleset, or null when a pack has not defined it. + public BehaviorDef? BehaviorRules => Behavior.Values.FirstOrDefault(def => !def.Abstract); + private readonly IReadOnlyDictionary _ru; private readonly IReadOnlyDictionary _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)), }; diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index 155d488..42dd83d 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -19,6 +19,7 @@ public enum DefKind Staffing, DayFrame, Holiday, + Behavior, } /// Shared JSONC fields. Kind comes from the folder under defs/, not from the file. @@ -31,7 +32,29 @@ public abstract class Def public bool Abstract { get; init; } } -public sealed class ActionDef : Def; +public sealed class ActionDef : Def +{ + /// RoomDef or TerritoryDef where this is done. Required on concrete actions. + public string? Room { get; init; } + + /// Thing occupied for the duration. Null means the room itself is enough. + public string? Thing { get; init; } + + /// Game minutes the action takes. Applied in full when it completes. + public float Minutes { get; init; } + + /// NeedDef refilled on completion. Null for walks and other leisure with no refill. + public string? Need { get; init; } + + /// Added to the need in one shot when the action finishes, then clamped to min–max. + public float NeedGain { get; init; } + + /// Empty means every role. Values are ids. + public IReadOnlyList Roles { get; init; } = []; + + /// Leisure weight. Zero means phase 21 will not pick this for fun — only for a need. + public float Weight { get; init; } +} public sealed class ThingDef : Def { diff --git a/src/HSchool.Content/MapLayout.cs b/src/HSchool.Content/MapLayout.cs index 3b145f9..92838b3 100644 --- a/src/HSchool.Content/MapLayout.cs +++ b/src/HSchool.Content/MapLayout.cs @@ -15,6 +15,25 @@ public sealed class MapLayout public static MapLayout Parse(string jsonc, string? source = null) => Jsonc.Deserialize(Jsonc.Parse(jsonc, source)); + + /// RoomDef or TerritoryDef of this node, or null when the id is not on the map. + 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 diff --git a/src/HSchool.Content/PackPaths.cs b/src/HSchool.Content/PackPaths.cs index f2691ec..3376db6 100644 --- a/src/HSchool.Content/PackPaths.cs +++ b/src/HSchool.Content/PackPaths.cs @@ -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; diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 73542e5..1cda408 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -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 0–1."); + } + + 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) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index aaf179c..760b139 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -201,6 +201,28 @@ public sealed class StaffingDef : Def public float WeeksPerMonth { get; init; } } +/// +/// 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. +/// +public sealed class BehaviorDef : Def +{ + /// A need at or below this value is urgent. Phase 21 turns that into a goal weight. + public float NeedThreshold { get; init; } + + /// Skill points a lesson adds per game hour, before traits and need state. Unused until phase 21. + public float LessonSkillPerHour { get; init; } + + /// Inclusive range of extra commute minutes rolled per person per day. + public int CommuteSlackMin { get; init; } + + public int CommuteSlackMax { get; init; } + + /// A new goal must beat the current one by this much before the person switches. + 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; + + /// + /// When true, this need snaps to off campus instead of draining. Sleep + /// restores overnight; hunger does not keep falling at home. + /// + public bool RestoredOffCampus { get; init; } } public sealed class CaseTable diff --git a/src/HSchool.Content/RoomOccupancy.cs b/src/HSchool.Content/RoomOccupancy.cs index 8ae77ca..55c866d 100644 --- a/src/HSchool.Content/RoomOccupancy.cs +++ b/src/HSchool.Content/RoomOccupancy.cs @@ -35,4 +35,43 @@ public static class RoomOccupancy return (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue); } + + /// + /// How many of this node holds. Homeroom seats count as that many + /// of ; other rooms sum matching slot fills. + /// + 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; + } } diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index feea4c1..345a9ed 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -59,6 +59,8 @@ internal sealed record PersonCardResponse( IReadOnlyList Skills, IReadOnlyList Traits, IReadOnlyList Needs, + string? Activity, + string? ActivityLabel, PersonFamilyResponse Family); internal sealed record LabeledStatResponse(string Id, string Label, string Value); diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index 33ca0f2..572c43b 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -14,6 +14,9 @@ internal static class PersonCardReader private static readonly QueryDescription IdentityAndNeeds = new QueryDescription().WithAll(); + private static readonly QueryDescription IdentityAndActivity = + new QueryDescription().WithAll(); + 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 Body(Person person, DefCatalog? catalog, string locale) { var rows = new List(); diff --git a/src/HSchool.Server/mods/core/defs/actions/living.jsonc b/src/HSchool.Server/mods/core/defs/actions/living.jsonc new file mode 100644 index 0000000..f69ab35 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/actions/living.jsonc @@ -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, + }, +] diff --git a/src/HSchool.Server/mods/core/defs/actions/sit.jsonc b/src/HSchool.Server/mods/core/defs/actions/sit.jsonc index cdf0e6f..f9844c2 100644 --- a/src/HSchool.Server/mods/core/defs/actions/sit.jsonc +++ b/src/HSchool.Server/mods/core/defs/actions/sit.jsonc @@ -1 +1 @@ -{ "defName": "Sit" } +{ "defName": "Sit", "abstract": true } diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc new file mode 100644 index 0000000..6dc1066 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -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. 0–6 matches the previous hardcoded roll so + // the same seed still arrives at the same minute. + "commuteSlackMin": 0, + "commuteSlackMax": 6, + "switchMargin": 0.15, +} diff --git a/src/HSchool.Server/mods/core/defs/needs/needs.jsonc b/src/HSchool.Server/mods/core/defs/needs/needs.jsonc index f2edddd..05f80be 100644 --- a/src/HSchool.Server/mods/core/defs/needs/needs.jsonc +++ b/src/HSchool.Server/mods/core/defs/needs/needs.jsonc @@ -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 }, ] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 24651b2..0223575 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -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", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 4779ace..0e7c3a2 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -1,5 +1,12 @@ { "Sit": "Сесть", + "EatLunch": "Обед", + "UseToilet": "Туалет", + "RecessRest": "Перемена", + "Chat": "Разговор", + "WalkCorridor": "Прогулка по коридору", + "WalkYard": "Прогулка во дворе", + "Behavior": "Правила поведения", "Chair": "Стул", "DirectorsChair": "Кресло директора", "Desk": "Стол", diff --git a/src/HSchool.Simulation/ActivitySystem.cs b/src/HSchool.Simulation/ActivitySystem.cs new file mode 100644 index 0000000..041ca2e --- /dev/null +++ b/src/HSchool.Simulation/ActivitySystem.cs @@ -0,0 +1,160 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; + +namespace HSchool.Simulation; + +/// +/// Starts and ticks actions on World entities. Rules live in ; this +/// adapter copies them onto components and counts occupied things at a node. +/// +internal static class ActivitySystem +{ + private static readonly QueryDescription People = + new QueryDescription().WithAll(); + + 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; + } +} diff --git a/src/HSchool.Simulation/Components/PersonComponents.cs b/src/HSchool.Simulation/Components/PersonComponents.cs index d4fb533..58cceff 100644 --- a/src/HSchool.Simulation/Components/PersonComponents.cs +++ b/src/HSchool.Simulation/Components/PersonComponents.cs @@ -22,6 +22,17 @@ public readonly record struct PersonTraits(IReadOnlyList Ids); /// Live need values. The dictionary is mutated in place as the clock advances. public readonly record struct PersonNeeds(Dictionary Values); +/// +/// The action this person is carrying out. Occupies until it finishes. +/// Idle when is null. +/// +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, diff --git a/src/HSchool.Simulation/NeedDecay.cs b/src/HSchool.Simulation/NeedDecay.cs index 6dbc012..0936f27 100644 --- a/src/HSchool.Simulation/NeedDecay.cs +++ b/src/HSchool.Simulation/NeedDecay.cs @@ -1,26 +1,49 @@ using Arch.Core; +using HSchool.Ai; using HSchool.Content; namespace HSchool.Simulation; /// -/// Drains needs by × simulated hours. Core ships decay at zero -/// so this is a no-op until something exists that can refill them. +/// Drains needs by × simulated hours while the person is on +/// campus. Off campus, needs do not drain and snaps to max +/// — sleep comes back overnight, hunger does not keep falling at home. /// public static class NeedDecay { - private static readonly QueryDescription PeopleWithNeeds = new QueryDescription().WithAll(); + private static readonly QueryDescription PeopleWithNeeds = + new QueryDescription().WithAll(); 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)) diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs index bf89d42..b91fa0e 100644 --- a/src/HSchool.Simulation/PresenceSystem.cs +++ b/src/HSchool.Simulation/PresenceSystem.cs @@ -13,7 +13,7 @@ namespace HSchool.Simulation; internal static class PresenceSystem { private static readonly QueryDescription People = - new QueryDescription().WithAll(); + new QueryDescription().WithAll(); public static void Apply(School school, double gameMinutes) { @@ -48,7 +48,7 @@ internal static class PresenceSystem { var rows = new List(); 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 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 Path); + IReadOnlyList Path, + string? ActionId = null, + string? ActionThing = null, + float ActionRemaining = 0f); diff --git a/src/HSchool.Simulation/RosterSpawner.cs b/src/HSchool.Simulation/RosterSpawner.cs index 5d7ff41..998bbae 100644 --- a/src/HSchool.Simulation/RosterSpawner.cs +++ b/src/HSchool.Simulation/RosterSpawner.cs @@ -38,7 +38,8 @@ public static class RosterSpawner person.ClassId, person.Position, person.WorkplaceRoomId), - Presence.OffCampus); + Presence.OffCampus, + PersonActivity.Idle); } } diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 9084a3f..a742d44 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -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 } } - /// Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay. + /// Runs one fixed step of the school: calendar, yearly intake, applicant refresh, presence, actions, then need decay. /// when the roster or the applicant pool changed this step. 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); diff --git a/tests/HSchool.Ai.Tests/ActionStepperTests.cs b/tests/HSchool.Ai.Tests/ActionStepperTests.cs new file mode 100644 index 0000000..1678b8f --- /dev/null +++ b/tests/HSchool.Ai.Tests/ActionStepperTests.cs @@ -0,0 +1,73 @@ +using HSchool.Content; + +namespace HSchool.Ai.Tests; + +public class ActionStepperTests +{ + private static readonly ActionDef Lunch = new() + { + DefName = "EatLunch", + Room = "Cafeteria", + Thing = "Chair", + Minutes = 15, + Need = "Hunger", + NeedGain = 0.5f, + }; + + private static readonly NeedDef Hunger = new() + { + DefName = "Hunger", + Initial = 1, + Min = 0, + Max = 1, + }; + + [Fact] + public void Begin_TakesTheStatedMinutes() + { + var started = ActionStepper.Begin(Lunch); + + Assert.Equal("EatLunch", started.ActionId); + Assert.Equal("Chair", started.Thing); + Assert.Equal(15f, started.RemainingMinutes); + } + + [Fact] + public void Advance_CompletesAfterTheStatedMinutes() + { + var started = ActionStepper.Begin(Lunch); + + var mid = ActionStepper.Advance(started, 14f, out var doneEarly); + var done = ActionStepper.Advance(mid, 1f, out var completed); + + Assert.False(doneEarly); + Assert.Equal(1f, mid.RemainingMinutes); + Assert.True(completed); + Assert.False(done.IsActive); + } + + [Fact] + public void ApplyNeedGain_RaisesHungerByTheStatedAmount() + { + Assert.Equal(0.8f, ActionStepper.ApplyNeedGain(0.3f, Lunch, Hunger)); + Assert.Equal(1f, ActionStepper.ApplyNeedGain(0.8f, Lunch, Hunger)); + } + + [Fact] + public void CanOccupy_RefusesWhenEveryThingIsTaken() + { + Assert.True(ActionStepper.CanOccupy(available: 1, occupied: 0)); + Assert.False(ActionStepper.CanOccupy(available: 1, occupied: 1)); + Assert.False(ActionStepper.CanOccupy(available: 0, occupied: 0)); + } + + [Fact] + public void SameAdvances_LeaveTheSameRemainingMinutes() + { + var first = ActionStepper.Advance(ActionStepper.Begin(Lunch), 7.5f, out _); + var second = ActionStepper.Advance(ActionStepper.Begin(Lunch), 7.5f, out _); + + Assert.Equal(first.RemainingMinutes, second.RemainingMinutes); + Assert.Equal(first.ActionId, second.ActionId); + } +} diff --git a/tests/HSchool.Content.Tests/ActionDefTests.cs b/tests/HSchool.Content.Tests/ActionDefTests.cs new file mode 100644 index 0000000..992bb60 --- /dev/null +++ b/tests/HSchool.Content.Tests/ActionDefTests.cs @@ -0,0 +1,87 @@ +namespace HSchool.Content.Tests; + +public class ActionDefTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void ConcreteAction_WithoutRoom_FailsLoad() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "actions", + "eat", + """{ "defName": "Eat", "minutes": 15, "need": "Hunger", "needGain": 0.5 }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "needs", + "hunger", + """{ "defName": "Hunger", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 }"""), + ])); + + Assert.Contains("Eat", ex.Message, StringComparison.Ordinal); + Assert.Contains("room", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ConcreteAction_UnknownNeed_FailsLoad() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "rooms", + "cafeteria", + """{ "defName": "Cafeteria", "travelMinutes": 1 }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "actions", + "eat", + """ + { "defName": "EatLunch", "room": "Cafeteria", "minutes": 15, "need": "Snacks", "needGain": 0.5 } + """), + ])); + + Assert.Contains("EatLunch", ex.Message, StringComparison.Ordinal); + Assert.Contains("Snacks", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void ConcreteAction_UnknownThing_FailsLoad() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "rooms", + "cafeteria", + """{ "defName": "Cafeteria", "travelMinutes": 1 }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "actions", + "eat", + """ + { "defName": "EatLunch", "room": "Cafeteria", "thing": "Stool", "minutes": 15 } + """), + ])); + + Assert.Contains("EatLunch", ex.Message, StringComparison.Ordinal); + Assert.Contains("Stool", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void AbstractAction_DoesNotNeedARoom() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit", "abstract": true }""")]); + + Assert.True(catalog.Actions["Sit"].Abstract); + Assert.Null(catalog.Actions["Sit"].Room); + } +} diff --git a/tests/HSchool.Content.Tests/CatalogLoaderTests.cs b/tests/HSchool.Content.Tests/CatalogLoaderTests.cs index 2d6c650..93c1ba1 100644 --- a/tests/HSchool.Content.Tests/CatalogLoaderTests.cs +++ b/tests/HSchool.Content.Tests/CatalogLoaderTests.cs @@ -16,7 +16,7 @@ public class CatalogLoaderTests "sit", """ // a verb the Sit system already knows - { "defName": "Sit", } + { "defName": "Sit", "abstract": true, } """), ]); @@ -32,7 +32,7 @@ public class CatalogLoaderTests [ PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": [] }"""), PackDocuments.Def("addon", "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""), - PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit", "abstract": true }"""), ], log); @@ -47,7 +47,7 @@ public class CatalogLoaderTests var catalog = _loader.Load( [CatalogLoader.CorePackId, "addon"], [ - PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit", "abstract": true }"""), PackDocuments.Locale(CatalogLoader.CorePackId, "ru", """{ "Sit": "Сесть" }"""), PackDocuments.Locale("addon", "ru", """{ "Sit": "Присесть" }"""), ], @@ -63,7 +63,7 @@ public class CatalogLoaderTests var catalog = _loader.Load( [CatalogLoader.CorePackId], [ - PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit", "abstract": true }"""), PackDocuments.Locale(CatalogLoader.CorePackId, "ru", """{ "Sit": "Сесть" }"""), ]); @@ -77,8 +77,8 @@ public class CatalogLoaderTests var catalog = _loader.Load( ["addon"], [ - PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), - PackDocuments.Def("addon", "actions", "wave", """{ "defName": "Wave" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit", "abstract": true }"""), + PackDocuments.Def("addon", "actions", "wave", """{ "defName": "Wave", "abstract": true }"""), ]); Assert.Equal([CatalogLoader.CorePackId, "addon"], catalog.PackIds); diff --git a/tests/HSchool.Content.Tests/InheritanceTests.cs b/tests/HSchool.Content.Tests/InheritanceTests.cs index 69d6d7c..28cff98 100644 --- a/tests/HSchool.Content.Tests/InheritanceTests.cs +++ b/tests/HSchool.Content.Tests/InheritanceTests.cs @@ -10,8 +10,8 @@ public class InheritanceTests var catalog = _loader.Load( [CatalogLoader.CorePackId], [ - PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), - PackDocuments.Def(CatalogLoader.CorePackId, "actions", "inspect", """{ "defName": "Inspect" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit", "abstract": true }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "inspect", """{ "defName": "Inspect", "abstract": true }"""), PackDocuments.Def( CatalogLoader.CorePackId, "things", diff --git a/tests/HSchool.Content.Tests/MapValidationTests.cs b/tests/HSchool.Content.Tests/MapValidationTests.cs index 12474ec..b00e018 100644 --- a/tests/HSchool.Content.Tests/MapValidationTests.cs +++ b/tests/HSchool.Content.Tests/MapValidationTests.cs @@ -118,7 +118,7 @@ public class MapValidationTests private static List MiniDefs(bool abstractYard = false) => [ - PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit", "abstract": true }"""), PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""), PackDocuments.Def(CatalogLoader.CorePackId, "positions", "principal", """{ "defName": "Principal" }"""), PackDocuments.Def( diff --git a/tests/HSchool.Content.Tests/PatchTests.cs b/tests/HSchool.Content.Tests/PatchTests.cs index 640da59..20202ae 100644 --- a/tests/HSchool.Content.Tests/PatchTests.cs +++ b/tests/HSchool.Content.Tests/PatchTests.cs @@ -10,8 +10,8 @@ public class PatchTests var catalog = _loader.Load( [CatalogLoader.CorePackId, "addon"], [ - PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), - PackDocuments.Def(CatalogLoader.CorePackId, "actions", "inspect", """{ "defName": "Inspect" }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit", "abstract": true }"""), + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "inspect", """{ "defName": "Inspect", "abstract": true }"""), PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""), PackDocuments.Patch( "addon", diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs index e6e156c..54a16b4 100644 --- a/tests/HSchool.Content.Tests/PeopleDefTests.cs +++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs @@ -16,7 +16,9 @@ public class PeopleDefTests Assert.True(catalog.BodyAttributes.ContainsKey("Height")); Assert.Equal(BodyAttributeKind.Number, catalog.BodyAttributes["Height"].Kind); Assert.Equal(BodyAttributeKind.Choice, catalog.BodyAttributes["HairColor"].Kind); - Assert.All(catalog.Needs.Values, need => Assert.Equal(0, need.DecayPerHour)); + Assert.All(catalog.Needs.Values, need => Assert.True(need.DecayPerHour > 0)); + Assert.True(catalog.Needs["Sleep"].RestoredOffCampus); + Assert.Equal(0.1f, catalog.Needs["Hunger"].DecayPerHour); Assert.Contains(catalog.Skills["Agility"].BodyLimits, limit => limit.Attribute == "Build" && limit.Value == "Obese"); Assert.True(catalog.NameSets.ContainsKey("Slavic")); Assert.True(catalog.NameSets["Slavic"].MaleGiven.Count >= 20); diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index ff8e4f1..1a74987 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -64,6 +64,19 @@ public class VanillaCoreTests Assert.Equal(3f, catalog.Territories["SchoolYard"].TravelMinutes); Assert.Equal(4, catalog.Traits["Diligent"].CommuteMinutes); Assert.Equal(-4, catalog.Traits["Lazy"].CommuteMinutes); + Assert.True(catalog.Actions["Sit"].Abstract); + Assert.Equal("Cafeteria", catalog.Actions["EatLunch"].Room); + Assert.Equal("Chair", catalog.Actions["EatLunch"].Thing); + Assert.Equal(15f, catalog.Actions["EatLunch"].Minutes); + Assert.Equal("Hunger", catalog.Actions["EatLunch"].Need); + Assert.Equal(0.5f, catalog.Actions["EatLunch"].NeedGain); + Assert.Equal("SchoolYard", catalog.Actions["WalkYard"].Room); + Assert.Equal(0.1f, catalog.Needs["Hunger"].DecayPerHour); + Assert.True(catalog.Needs["Sleep"].RestoredOffCampus); + Assert.NotNull(catalog.BehaviorRules); + Assert.Equal(0, catalog.BehaviorRules.CommuteSlackMin); + Assert.Equal(6, catalog.BehaviorRules.CommuteSlackMax); + Assert.Equal(0.35f, catalog.BehaviorRules.NeedThreshold); } /// @@ -97,6 +110,7 @@ public class VanillaCoreTests keys.AddRange(Names(catalog.Staffing.Values)); keys.AddRange(Names(catalog.DayFrames.Values)); keys.AddRange(Names(catalog.Holidays.Values)); + keys.AddRange(Names(catalog.Behavior.Values)); // Derived in code, so no def carries them. keys.Add(BodyBuilds.Attribute); diff --git a/tests/HSchool.Simulation.Tests/ActivityTests.cs b/tests/HSchool.Simulation.Tests/ActivityTests.cs new file mode 100644 index 0000000..eaf84bb --- /dev/null +++ b/tests/HSchool.Simulation.Tests/ActivityTests.cs @@ -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(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(); + 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(); + 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); + } +} diff --git a/tests/HSchool.Simulation.Tests/NeedDecayTests.cs b/tests/HSchool.Simulation.Tests/NeedDecayTests.cs new file mode 100644 index 0000000..10653b2 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/NeedDecayTests.cs @@ -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(StringComparer.Ordinal) { ["Hunger"] = hunger.Initial }), + new Presence("cafeteria", 0f, "cafeteria", false, [])); + NeedDecay.Apply(world, catalog, gameMinutes: 60); + + var query = new QueryDescription().WithAll(); + 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(StringComparer.Ordinal) + { + ["Sleep"] = 0.2f, + ["Hunger"] = 0.4f, + }), + Presence.OffCampus); + NeedDecay.Apply(world, catalog, gameMinutes: 60); + + var query = new QueryDescription().WithAll(); + 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(StringComparer.Ordinal) { ["Hunger"] = 1f }), + new Presence("cafeteria", 0f, "cafeteria", false, [])); + NeedDecay.Apply(world, catalog, gameMinutes: 60); + var value = 0f; + var query = new QueryDescription().WithAll(); + 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(); + 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); + } +} diff --git a/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs b/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs index dd73c0d..3748186 100644 --- a/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs +++ b/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs @@ -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(StringComparer.Ordinal) { ["Hunger"] = 1f })); + world.Create( + new PersonNeeds(new Dictionary(StringComparer.Ordinal) { ["Hunger"] = 1f }), + new Presence("yard", 0f, "yard", false, [])); NeedDecay.Apply(world, catalog, gameMinutes: 60); var query = new QueryDescription().WithAll(); world.Query(in query, (ref PersonNeeds needs) => Assert.Equal(0f, needs.Values["Hunger"])); diff --git a/tests/HSchool.Simulation.Tests/SchoolTests.cs b/tests/HSchool.Simulation.Tests/SchoolTests.cs index ac5ec53..2124070 100644 --- a/tests/HSchool.Simulation.Tests/SchoolTests.cs +++ b/tests/HSchool.Simulation.Tests/SchoolTests.cs @@ -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" }"""),