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:
@@ -11,27 +11,27 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] `ActionDef` получает поля: комната, вещь, длительность, восполняемая нужда и величина,
|
- [x] `ActionDef` получает поля: комната, вещь, длительность, восполняемая нужда и величина,
|
||||||
роли, вес досуга
|
роли, вес досуга
|
||||||
- [ ] Действия в `core`: поесть в столовой, санузел, отдохнуть на перемене, пообщаться,
|
- [x] Действия в `core`: поесть в столовой, санузел, отдохнуть на перемене, пообщаться,
|
||||||
прогуляться по коридору и по двору
|
прогуляться по коридору и по двору
|
||||||
- [ ] Деф правил поведения — как `StaffingDef` у штата: пороги нужд, скорость обучения на уроке,
|
- [x] Деф правил поведения — как `StaffingDef` у штата: пороги нужд, скорость обучения на уроке,
|
||||||
разброс запаса на дорогу, порог переключения решения
|
разброс запаса на дорогу, порог переключения решения
|
||||||
- [ ] Декей нужд включается в `core`. Сон восстанавливается вне школы, а не действием
|
- [x] Декей нужд включается в `core`. Сон восстанавливается вне школы, а не действием
|
||||||
- [ ] Действие занимает вещь на время: за один стол не садятся вдвоём
|
- [x] Действие занимает вещь на время: за один стол не садятся вдвоём
|
||||||
- [ ] Выполнение действия: начало, отсчёт минут, восполнение нужды по завершении. Правила — в
|
- [x] Выполнение действия: начало, отсчёт минут, восполнение нужды по завершении. Правила — в
|
||||||
`HSchool.Ai`, компоненты и мир — в `HSchool.Simulation`
|
`HSchool.Ai`, компоненты и мир — в `HSchool.Simulation`
|
||||||
- [ ] Валидатор дефов ловит действие без комнаты, с неизвестной нуждой и с неизвестной вещью
|
- [x] Валидатор дефов ловит действие без комнаты, с неизвестной нуждой и с неизвестной вещью
|
||||||
- [ ] Карточка человека показывает, что он делает; полоски нужд начинают шевелиться
|
- [x] Карточка человека показывает, что он делает; полоски нужд начинают шевелиться
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Голод падает за игровой час ровно на `decayPerHour`
|
- [x] Голод падает за игровой час ровно на `decayPerHour`
|
||||||
- [ ] Обед в столовой поднимает голод на заявленную величину и занимает заявленное время
|
- [x] Обед в столовой поднимает голод на заявленную величину и занимает заявленное время
|
||||||
- [ ] Столов меньше, чем желающих: лишние не начинают действие, а не садятся сквозь друг друга
|
- [x] Столов меньше, чем желающих: лишние не начинают действие, а не садятся сквозь друг друга
|
||||||
- [ ] Ночь вне школы возвращает сон к максимуму
|
- [x] Ночь вне школы возвращает сон к максимуму
|
||||||
- [ ] Действие с неизвестной нуждой роняет загрузку каталога с внятной ошибкой
|
- [x] Действие с неизвестной нуждой роняет загрузку каталога с внятной ошибкой
|
||||||
- [ ] Те же входы дают те же значения нужд на тот же момент
|
- [x] Те же входы дают те же значения нужд на тот же момент
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -89,5 +89,5 @@
|
|||||||
|
|
||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [20. Нужды и действия](20-needs-actions.md) | ⬜ | `ActionDef` с полями, декей, восполнение |
|
| [20. Нужды и действия](20-needs-actions.md) | ✅ | `ActionDef` с полями, декей, восполнение |
|
||||||
| [21. Выбор действия](21-decisions.md) | ⬜ | Цели и веса, «дойти → сделать», уход с урока, рост навыка |
|
| [21. Выбор действия](21-decisions.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
|
cards. `classId` is the homeroom class for a pupil and `null` otherwise — the personal timetable
|
||||||
grid fetches `GET .../timetable?classId=` with it.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"id": "f0.c0",
|
"id": "f0.c0",
|
||||||
@@ -233,6 +236,8 @@ grid fetches `GET .../timetable?classId=` with it.
|
|||||||
"skills": [{ "id": "Math", "label": "Математика", "value": "62" }],
|
"skills": [{ "id": "Math", "label": "Математика", "value": "62" }],
|
||||||
"traits": [{ "defName": "Diligent", "label": "Усидчивый" }],
|
"traits": [{ "defName": "Diligent", "label": "Усидчивый" }],
|
||||||
"needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }],
|
"needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }],
|
||||||
|
"activity": null,
|
||||||
|
"activityLabel": null,
|
||||||
"family": {
|
"family": {
|
||||||
"parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }],
|
"parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }],
|
||||||
"children": [],
|
"children": [],
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.Ai;
|
||||||
|
|
||||||
|
/// <summary>One in-progress action. <see cref="ActionId"/> is null when the person is idle.</summary>
|
||||||
|
public readonly record struct ActivityProgress(string? ActionId, string? Thing, float RemainingMinutes)
|
||||||
|
{
|
||||||
|
public static ActivityProgress Idle { get; } = new(null, null, 0f);
|
||||||
|
|
||||||
|
public bool IsActive => ActionId is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Start, countdown and refill for one action. Occupancy is a count check — two people cannot
|
||||||
|
/// take the last chair. Simulation copies this onto components; the library has no world.
|
||||||
|
/// </summary>
|
||||||
|
public static class ActionStepper
|
||||||
|
{
|
||||||
|
public static ActivityProgress Begin(ActionDef def)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(def);
|
||||||
|
var thing = string.IsNullOrWhiteSpace(def.Thing) ? null : def.Thing;
|
||||||
|
return new ActivityProgress(def.DefName, thing, def.Minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ActivityProgress Advance(ActivityProgress activity, float minutes, out bool completed)
|
||||||
|
{
|
||||||
|
if (!activity.IsActive)
|
||||||
|
{
|
||||||
|
completed = false;
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
var left = activity.RemainingMinutes - minutes;
|
||||||
|
if (left <= 0f)
|
||||||
|
{
|
||||||
|
completed = true;
|
||||||
|
return ActivityProgress.Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
completed = false;
|
||||||
|
return activity with { RemainingMinutes = left };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CanOccupy(int available, int occupied) => occupied < available;
|
||||||
|
|
||||||
|
public static float ApplyNeedGain(float current, ActionDef action, NeedDef need)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(action);
|
||||||
|
ArgumentNullException.ThrowIfNull(need);
|
||||||
|
return Math.Clamp(current + action.NeedGain, need.Min, need.Max);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,6 @@ public readonly record struct DayPlan(DateOnly Day, DateTime? AppearAt, DateTime
|
|||||||
|
|
||||||
public static class DayPlans
|
public static class DayPlans
|
||||||
{
|
{
|
||||||
private const int ExtraRollMax = 7;
|
|
||||||
|
|
||||||
public static DayPlan Build(
|
public static DayPlan Build(
|
||||||
DefCatalog catalog,
|
DefCatalog catalog,
|
||||||
WalkGraph walks,
|
WalkGraph walks,
|
||||||
@@ -111,7 +109,15 @@ public static class DayPlans
|
|||||||
private static int SlackMinutes(DefCatalog catalog, Person person, int schoolSeed, DateOnly day)
|
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 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)
|
foreach (var name in person.Traits)
|
||||||
{
|
{
|
||||||
if (catalog.Traits.TryGetValue(name, out var trait))
|
if (catalog.Traits.TryGetValue(name, out var trait))
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ export interface PersonCard {
|
|||||||
readonly skills: readonly LabeledStat[];
|
readonly skills: readonly LabeledStat[];
|
||||||
readonly traits: readonly DefLabel[];
|
readonly traits: readonly DefLabel[];
|
||||||
readonly needs: readonly NeedStat[];
|
readonly needs: readonly NeedStat[];
|
||||||
|
readonly activity: string | null;
|
||||||
|
readonly activityLabel: string | null;
|
||||||
readonly family: {
|
readonly family: {
|
||||||
readonly parents: readonly PersonRel[];
|
readonly parents: readonly PersonRel[];
|
||||||
readonly children: readonly PersonRel[];
|
readonly children: readonly PersonRel[];
|
||||||
|
|||||||
@@ -577,6 +577,12 @@ body {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.people__card-place,
|
||||||
|
.people__card-activity {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.people__section {
|
.people__section {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export function renderPersonCard(
|
|||||||
if (place !== undefined && place.length > 0) {
|
if (place !== undefined && place.length > 0) {
|
||||||
parent.append(el('p', { class: 'people__card-place', text: place }));
|
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('peopleBody'), card.body);
|
||||||
appendPairs(parent, t('peopleSkills'), card.skills);
|
appendPairs(parent, t('peopleSkills'), card.skills);
|
||||||
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
|
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ public sealed class CatalogLoader
|
|||||||
var staffing = new Dictionary<string, StaffingDef>(StringComparer.Ordinal);
|
var staffing = new Dictionary<string, StaffingDef>(StringComparer.Ordinal);
|
||||||
var dayFrames = new Dictionary<string, DayFrameDef>(StringComparer.Ordinal);
|
var dayFrames = new Dictionary<string, DayFrameDef>(StringComparer.Ordinal);
|
||||||
var holidays = new Dictionary<string, HolidayDef>(StringComparer.Ordinal);
|
var holidays = new Dictionary<string, HolidayDef>(StringComparer.Ordinal);
|
||||||
|
var behavior = new Dictionary<string, BehaviorDef>(StringComparer.Ordinal);
|
||||||
|
|
||||||
foreach (var (key, json) in resolved)
|
foreach (var (key, json) in resolved)
|
||||||
{
|
{
|
||||||
@@ -367,6 +368,9 @@ public sealed class CatalogLoader
|
|||||||
case DefKind.Holiday:
|
case DefKind.Holiday:
|
||||||
holidays[key.Name] = Jsonc.Deserialize<HolidayDef>(json);
|
holidays[key.Name] = Jsonc.Deserialize<HolidayDef>(json);
|
||||||
break;
|
break;
|
||||||
|
case DefKind.Behavior:
|
||||||
|
behavior[key.Name] = Jsonc.Deserialize<BehaviorDef>(json);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,6 +393,7 @@ public sealed class CatalogLoader
|
|||||||
staffing,
|
staffing,
|
||||||
dayFrames,
|
dayFrames,
|
||||||
holidays,
|
holidays,
|
||||||
|
behavior,
|
||||||
ru,
|
ru,
|
||||||
en);
|
en);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public sealed class DefCatalog
|
|||||||
IReadOnlyDictionary<string, StaffingDef> staffing,
|
IReadOnlyDictionary<string, StaffingDef> staffing,
|
||||||
IReadOnlyDictionary<string, DayFrameDef> dayFrames,
|
IReadOnlyDictionary<string, DayFrameDef> dayFrames,
|
||||||
IReadOnlyDictionary<string, HolidayDef> holidays,
|
IReadOnlyDictionary<string, HolidayDef> holidays,
|
||||||
|
IReadOnlyDictionary<string, BehaviorDef> behavior,
|
||||||
IReadOnlyDictionary<string, string> ru,
|
IReadOnlyDictionary<string, string> ru,
|
||||||
IReadOnlyDictionary<string, string> en)
|
IReadOnlyDictionary<string, string> en)
|
||||||
{
|
{
|
||||||
@@ -46,18 +47,22 @@ public sealed class DefCatalog
|
|||||||
Staffing = staffing;
|
Staffing = staffing;
|
||||||
DayFrames = dayFrames;
|
DayFrames = dayFrames;
|
||||||
Holidays = holidays;
|
Holidays = holidays;
|
||||||
|
Behavior = behavior;
|
||||||
_ru = ru;
|
_ru = ru;
|
||||||
_en = en;
|
_en = en;
|
||||||
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
|
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
|
||||||
|
AnyNeedRestoredOffCampus = needs.Values.Any(need => !need.Abstract && need.RestoredOffCampus);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether any need in these packs actually drains. Core ships every rate at zero until
|
/// Whether any need in these packs actually drains. Walking every person twenty times a
|
||||||
/// something can refill them, and walking every person twenty times a second to subtract
|
/// second to subtract nothing is the kind of work that multiplies by six schools.
|
||||||
/// nothing is the kind of work that multiplies by six schools.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AnyNeedDecays { get; }
|
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 IReadOnlyList<string> PackIds { get; }
|
||||||
|
|
||||||
public IReadOnlyDictionary<string, ActionDef> Actions { get; }
|
public IReadOnlyDictionary<string, ActionDef> Actions { get; }
|
||||||
@@ -94,12 +99,17 @@ public sealed class DefCatalog
|
|||||||
|
|
||||||
public IReadOnlyDictionary<string, HolidayDef> Holidays { get; }
|
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>
|
/// <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);
|
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>
|
/// <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);
|
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> _ru;
|
||||||
private readonly IReadOnlyDictionary<string, string> _en;
|
private readonly IReadOnlyDictionary<string, string> _en;
|
||||||
|
|
||||||
@@ -124,6 +134,7 @@ public sealed class DefCatalog
|
|||||||
DefKind.Staffing => Staffing.GetValueOrDefault(defName),
|
DefKind.Staffing => Staffing.GetValueOrDefault(defName),
|
||||||
DefKind.DayFrame => DayFrames.GetValueOrDefault(defName),
|
DefKind.DayFrame => DayFrames.GetValueOrDefault(defName),
|
||||||
DefKind.Holiday => Holidays.GetValueOrDefault(defName),
|
DefKind.Holiday => Holidays.GetValueOrDefault(defName),
|
||||||
|
DefKind.Behavior => Behavior.GetValueOrDefault(defName),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,6 +212,7 @@ public sealed class DefCatalog
|
|||||||
StaffingDef => DefKind.Staffing,
|
StaffingDef => DefKind.Staffing,
|
||||||
DayFrameDef => DefKind.DayFrame,
|
DayFrameDef => DefKind.DayFrame,
|
||||||
HolidayDef => DefKind.Holiday,
|
HolidayDef => DefKind.Holiday,
|
||||||
|
BehaviorDef => DefKind.Behavior,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(def)),
|
_ => throw new ArgumentOutOfRangeException(nameof(def)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public enum DefKind
|
|||||||
Staffing,
|
Staffing,
|
||||||
DayFrame,
|
DayFrame,
|
||||||
Holiday,
|
Holiday,
|
||||||
|
Behavior,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
|
/// <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 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 min–max.</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
|
public sealed class ThingDef : Def
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,25 @@ public sealed class MapLayout
|
|||||||
|
|
||||||
public static MapLayout Parse(string jsonc, string? source = null) =>
|
public static MapLayout Parse(string jsonc, string? source = null) =>
|
||||||
Jsonc.Deserialize<MapLayout>(Jsonc.Parse(jsonc, source));
|
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
|
public sealed class TerritoryNode
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ internal static class PackPaths
|
|||||||
case "holidays":
|
case "holidays":
|
||||||
kind = DefKind.Holiday;
|
kind = DefKind.Holiday;
|
||||||
return true;
|
return true;
|
||||||
|
case "behavior":
|
||||||
|
kind = DefKind.Behavior;
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
kind = default;
|
kind = default;
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ internal static class PeopleDefValidator
|
|||||||
ValidateHoliday(holiday);
|
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)
|
if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1)
|
||||||
{
|
{
|
||||||
throw new ContentLoadException("A catalog may only have one concrete StaffingDef.");
|
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.");
|
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);
|
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)
|
private static void ValidateHoliday(HolidayDef holiday)
|
||||||
{
|
{
|
||||||
if (holiday.Abstract)
|
if (holiday.Abstract)
|
||||||
|
|||||||
@@ -201,6 +201,28 @@ public sealed class StaffingDef : Def
|
|||||||
public float WeeksPerMonth { get; init; }
|
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
|
public enum BodyAttributeKind
|
||||||
{
|
{
|
||||||
Number,
|
Number,
|
||||||
@@ -252,6 +274,12 @@ public sealed class NeedDef : Def
|
|||||||
public float Min { get; init; }
|
public float Min { get; init; }
|
||||||
|
|
||||||
public float Max { get; init; } = 1;
|
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
|
public sealed class CaseTable
|
||||||
|
|||||||
@@ -35,4 +35,43 @@ public static class RoomOccupancy
|
|||||||
|
|
||||||
return (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ internal sealed record PersonCardResponse(
|
|||||||
IReadOnlyList<LabeledStatResponse> Skills,
|
IReadOnlyList<LabeledStatResponse> Skills,
|
||||||
IReadOnlyList<DefLabelResponse> Traits,
|
IReadOnlyList<DefLabelResponse> Traits,
|
||||||
IReadOnlyList<NeedStatResponse> Needs,
|
IReadOnlyList<NeedStatResponse> Needs,
|
||||||
|
string? Activity,
|
||||||
|
string? ActivityLabel,
|
||||||
PersonFamilyResponse Family);
|
PersonFamilyResponse Family);
|
||||||
|
|
||||||
internal sealed record LabeledStatResponse(string Id, string Label, string Value);
|
internal sealed record LabeledStatResponse(string Id, string Label, string Value);
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ internal static class PersonCardReader
|
|||||||
private static readonly QueryDescription IdentityAndNeeds =
|
private static readonly QueryDescription IdentityAndNeeds =
|
||||||
new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
|
new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
|
||||||
|
|
||||||
|
private static readonly QueryDescription IdentityAndActivity =
|
||||||
|
new QueryDescription().WithAll<PersonIdentity, PersonActivity>();
|
||||||
|
|
||||||
public static PersonCardResponse? Read(School school, string personId, string locale)
|
public static PersonCardResponse? Read(School school, string personId, string locale)
|
||||||
{
|
{
|
||||||
var roster = school.Roster;
|
var roster = school.Roster;
|
||||||
@@ -42,6 +45,17 @@ internal static class PersonCardReader
|
|||||||
}
|
}
|
||||||
|
|
||||||
var needs = LiveNeeds(school.World, personId) ?? person.Needs;
|
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(
|
return new PersonCardResponse(
|
||||||
person.Id,
|
person.Id,
|
||||||
person.Name.Full,
|
person.Name.Full,
|
||||||
@@ -61,6 +75,8 @@ internal static class PersonCardReader
|
|||||||
Skills(person, catalog, locale),
|
Skills(person, catalog, locale),
|
||||||
Traits(person, catalog, locale),
|
Traits(person, catalog, locale),
|
||||||
Needs(needs, catalog, locale),
|
Needs(needs, catalog, locale),
|
||||||
|
activityId,
|
||||||
|
activityLabel,
|
||||||
Family(roster, person));
|
Family(roster, person));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +93,20 @@ internal static class PersonCardReader
|
|||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? LiveActivity(World world, string personId)
|
||||||
|
{
|
||||||
|
string? found = null;
|
||||||
|
var query = IdentityAndActivity;
|
||||||
|
world.Query(in query, (ref PersonIdentity identity, ref PersonActivity activity) =>
|
||||||
|
{
|
||||||
|
if (identity.Id.Equals(personId, StringComparison.Ordinal) && activity.IsActive)
|
||||||
|
{
|
||||||
|
found = activity.ActionId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<LabeledStatResponse> Body(Person person, DefCatalog? catalog, string locale)
|
private static IReadOnlyList<LabeledStatResponse> Body(Person person, DefCatalog? catalog, string locale)
|
||||||
{
|
{
|
||||||
var rows = new List<LabeledStatResponse>();
|
var rows = new List<LabeledStatResponse>();
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"defName": "EatLunch",
|
||||||
|
"room": "Cafeteria",
|
||||||
|
"thing": "Chair",
|
||||||
|
"minutes": 15,
|
||||||
|
"need": "Hunger",
|
||||||
|
"needGain": 0.5,
|
||||||
|
"roles": ["student", "staff"],
|
||||||
|
"weight": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "UseToilet",
|
||||||
|
"room": "Restroom",
|
||||||
|
"minutes": 4,
|
||||||
|
"need": "Toilet",
|
||||||
|
"needGain": 1,
|
||||||
|
"roles": ["student", "staff"],
|
||||||
|
"weight": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "RecessRest",
|
||||||
|
"room": "Corridor",
|
||||||
|
"minutes": 10,
|
||||||
|
"roles": ["student", "staff"],
|
||||||
|
"weight": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "Chat",
|
||||||
|
"room": "Corridor",
|
||||||
|
"minutes": 8,
|
||||||
|
"need": "Social",
|
||||||
|
"needGain": 0.4,
|
||||||
|
"roles": ["student", "staff"],
|
||||||
|
"weight": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "WalkCorridor",
|
||||||
|
"room": "Corridor",
|
||||||
|
"minutes": 3,
|
||||||
|
"roles": ["student", "staff"],
|
||||||
|
"weight": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "WalkYard",
|
||||||
|
"room": "SchoolYard",
|
||||||
|
"minutes": 5,
|
||||||
|
"roles": ["student", "staff"],
|
||||||
|
"weight": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1 +1 @@
|
|||||||
{ "defName": "Sit" }
|
{ "defName": "Sit", "abstract": true }
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"defName": "Behavior",
|
||||||
|
// A need at or below this is urgent. Phase 21 turns that into a goal weight.
|
||||||
|
"needThreshold": 0.35,
|
||||||
|
// Skill points a lesson adds per game hour, before traits and need state.
|
||||||
|
"lessonSkillPerHour": 0.05,
|
||||||
|
// Inclusive extra commute minutes. 0–6 matches the previous hardcoded roll so
|
||||||
|
// the same seed still arrives at the same minute.
|
||||||
|
"commuteSlackMin": 0,
|
||||||
|
"commuteSlackMax": 6,
|
||||||
|
"switchMargin": 0.15,
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[
|
[
|
||||||
{ "defName": "Sleep", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
|
{ "defName": "Sleep", "initial": 1, "decayPerHour": 0.05, "min": 0, "max": 1, "restoredOffCampus": true },
|
||||||
{ "defName": "Hunger", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
|
{ "defName": "Hunger", "initial": 1, "decayPerHour": 0.1, "min": 0, "max": 1 },
|
||||||
{ "defName": "Toilet", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
|
{ "defName": "Toilet", "initial": 1, "decayPerHour": 0.15, "min": 0, "max": 1 },
|
||||||
{ "defName": "Social", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
|
{ "defName": "Social", "initial": 1, "decayPerHour": 0.08, "min": 0, "max": 1 },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
{
|
{
|
||||||
"Sit": "Sit",
|
"Sit": "Sit",
|
||||||
|
"EatLunch": "Lunch",
|
||||||
|
"UseToilet": "Restroom",
|
||||||
|
"RecessRest": "Recess",
|
||||||
|
"Chat": "Chat",
|
||||||
|
"WalkCorridor": "Walk the corridor",
|
||||||
|
"WalkYard": "Walk the yard",
|
||||||
|
"Behavior": "Behavior rules",
|
||||||
"Chair": "Chair",
|
"Chair": "Chair",
|
||||||
"DirectorsChair": "Principal's chair",
|
"DirectorsChair": "Principal's chair",
|
||||||
"Desk": "Desk",
|
"Desk": "Desk",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
{
|
{
|
||||||
"Sit": "Сесть",
|
"Sit": "Сесть",
|
||||||
|
"EatLunch": "Обед",
|
||||||
|
"UseToilet": "Туалет",
|
||||||
|
"RecessRest": "Перемена",
|
||||||
|
"Chat": "Разговор",
|
||||||
|
"WalkCorridor": "Прогулка по коридору",
|
||||||
|
"WalkYard": "Прогулка во дворе",
|
||||||
|
"Behavior": "Правила поведения",
|
||||||
"Chair": "Стул",
|
"Chair": "Стул",
|
||||||
"DirectorsChair": "Кресло директора",
|
"DirectorsChair": "Кресло директора",
|
||||||
"Desk": "Стол",
|
"Desk": "Стол",
|
||||||
|
|||||||
@@ -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>
|
/// <summary>Live need values. The dictionary is mutated in place as the clock advances.</summary>
|
||||||
public readonly record struct PersonNeeds(Dictionary<string, float> Values);
|
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(
|
public readonly record struct PersonRoles(
|
||||||
bool IsStudent,
|
bool IsStudent,
|
||||||
bool IsStaff,
|
bool IsStaff,
|
||||||
|
|||||||
@@ -1,26 +1,49 @@
|
|||||||
using Arch.Core;
|
using Arch.Core;
|
||||||
|
using HSchool.Ai;
|
||||||
using HSchool.Content;
|
using HSchool.Content;
|
||||||
|
|
||||||
namespace HSchool.Simulation;
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Drains needs by <see cref="NeedDef.DecayPerHour"/> × simulated hours. Core ships decay at zero
|
/// Drains needs by <see cref="NeedDef.DecayPerHour"/> × simulated hours while the person is on
|
||||||
/// so this is a no-op until something exists that can refill them.
|
/// 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>
|
/// </summary>
|
||||||
public static class NeedDecay
|
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)
|
public static void Apply(World world, DefCatalog catalog, double gameMinutes)
|
||||||
{
|
{
|
||||||
if (gameMinutes <= 0 || !catalog.AnyNeedDecays)
|
if (gameMinutes <= 0 || (!catalog.AnyNeedDecays && !catalog.AnyNeedRestoredOffCampus))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hours = gameMinutes / 60d;
|
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)
|
foreach (var def in catalog.Needs.Values)
|
||||||
{
|
{
|
||||||
if (def.Abstract || !needs.Values.TryGetValue(def.DefName, out var current))
|
if (def.Abstract || !needs.Values.TryGetValue(def.DefName, out var current))
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace HSchool.Simulation;
|
|||||||
internal static class PresenceSystem
|
internal static class PresenceSystem
|
||||||
{
|
{
|
||||||
private static readonly QueryDescription People =
|
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)
|
public static void Apply(School school, double gameMinutes)
|
||||||
{
|
{
|
||||||
@@ -48,7 +48,7 @@ internal static class PresenceSystem
|
|||||||
{
|
{
|
||||||
var rows = new List<PresenceSnapshot>();
|
var rows = new List<PresenceSnapshot>();
|
||||||
var world = school.World;
|
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(
|
rows.Add(new PresenceSnapshot(
|
||||||
identity.Id,
|
identity.Id,
|
||||||
@@ -56,7 +56,10 @@ internal static class PresenceSystem
|
|||||||
presence.RemainingMinutes,
|
presence.RemainingMinutes,
|
||||||
presence.DestinationId,
|
presence.DestinationId,
|
||||||
presence.HeadingHome,
|
presence.HeadingHome,
|
||||||
presence.Path));
|
presence.Path,
|
||||||
|
activity.ActionId,
|
||||||
|
activity.Thing,
|
||||||
|
activity.RemainingMinutes));
|
||||||
});
|
});
|
||||||
rows.Sort((left, right) => StringComparer.Ordinal.Compare(left.PersonId, right.PersonId));
|
rows.Sort((left, right) => StringComparer.Ordinal.Compare(left.PersonId, right.PersonId));
|
||||||
return rows;
|
return rows;
|
||||||
@@ -74,17 +77,19 @@ internal static class PresenceSystem
|
|||||||
var byId = saved
|
var byId = saved
|
||||||
.Where(row => !string.IsNullOrWhiteSpace(row.PersonId))
|
.Where(row => !string.IsNullOrWhiteSpace(row.PersonId))
|
||||||
.ToDictionary(row => row.PersonId, StringComparer.Ordinal);
|
.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))
|
if (!byId.TryGetValue(person.Id, out var row))
|
||||||
{
|
{
|
||||||
presence = PlaceByDuty(school, person);
|
presence = PlaceByDuty(school, person);
|
||||||
|
activity = PersonActivity.Idle;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.NodeId is null)
|
if (row.NodeId is null)
|
||||||
{
|
{
|
||||||
presence = Presence.OffCampus;
|
presence = Presence.OffCampus;
|
||||||
|
activity = PersonActivity.Idle;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,16 +99,20 @@ internal static class PresenceSystem
|
|||||||
row.DestinationId,
|
row.DestinationId,
|
||||||
row.HeadingHome,
|
row.HeadingHome,
|
||||||
row.Path?.ToArray() ?? []);
|
row.Path?.ToArray() ?? []);
|
||||||
|
activity = string.IsNullOrWhiteSpace(row.ActionId)
|
||||||
|
? PersonActivity.Idle
|
||||||
|
: new PersonActivity(row.ActionId, row.ActionThing, row.ActionRemaining);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void PlaceMissingByDuty(School school)
|
public static void PlaceMissingByDuty(School school)
|
||||||
{
|
{
|
||||||
ForEachPerson(school, (person, _, ref presence) =>
|
ForEachPerson(school, (person, _, ref presence, ref activity) =>
|
||||||
{
|
{
|
||||||
if (!presence.IsOnCampus)
|
if (!presence.IsOnCampus)
|
||||||
{
|
{
|
||||||
presence = PlaceByDuty(school, person);
|
presence = PlaceByDuty(school, person);
|
||||||
|
activity = PersonActivity.Idle;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -323,7 +332,7 @@ internal static class PresenceSystem
|
|||||||
}
|
}
|
||||||
|
|
||||||
var world = school.World;
|
var world = school.World;
|
||||||
world.Query(in People, (ref Presence presence) =>
|
world.Query(in People, (ref Presence presence, ref PersonActivity activity) =>
|
||||||
{
|
{
|
||||||
if (presence.HeadingHome
|
if (presence.HeadingHome
|
||||||
&& presence.NodeId is not null
|
&& presence.NodeId is not null
|
||||||
@@ -332,6 +341,7 @@ internal static class PresenceSystem
|
|||||||
&& presence.NodeId.Equals(yard, StringComparison.Ordinal))
|
&& presence.NodeId.Equals(yard, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
presence = Presence.OffCampus;
|
presence = Presence.OffCampus;
|
||||||
|
activity = PersonActivity.Idle;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -349,17 +359,17 @@ internal static class PresenceSystem
|
|||||||
private static IReadOnlyList<Person> OrderedPeople(School school) =>
|
private static IReadOnlyList<Person> OrderedPeople(School school) =>
|
||||||
school.Roster!.People.OrderBy(person => person.Id, StringComparer.Ordinal).ToArray();
|
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)
|
private static void ForEachPerson(School school, PersonAction action)
|
||||||
{
|
{
|
||||||
var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
|
var roster = school.Roster!.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
|
||||||
var world = school.World;
|
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))
|
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,
|
float RemainingMinutes,
|
||||||
string? DestinationId,
|
string? DestinationId,
|
||||||
bool HeadingHome,
|
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.ClassId,
|
||||||
person.Position,
|
person.Position,
|
||||||
person.WorkplaceRoomId),
|
person.WorkplaceRoomId),
|
||||||
Presence.OffCampus);
|
Presence.OffCampus,
|
||||||
|
PersonActivity.Idle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,14 @@ public sealed class School : IDisposable
|
|||||||
DecisionQueue.Clear();
|
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)
|
public void ConfigurePresence(int weekDays = 5, int maxDecisionsPerTick = 64, int maxSkipDays = 400)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
@@ -180,6 +188,7 @@ public sealed class School : IDisposable
|
|||||||
peopleChanged |= TryApplicantRefresh();
|
peopleChanged |= TryApplicantRefresh();
|
||||||
PlanDay = null;
|
PlanDay = null;
|
||||||
LastDecisionSlot = null;
|
LastDecisionSlot = null;
|
||||||
|
NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes);
|
||||||
return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged);
|
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>
|
/// <returns><see langword="true"/> when the roster or the applicant pool changed this step.</returns>
|
||||||
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
|
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
|
||||||
{
|
{
|
||||||
@@ -234,6 +243,7 @@ public sealed class School : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
PresenceSystem.Apply(this, gameMinutes);
|
PresenceSystem.Apply(this, gameMinutes);
|
||||||
|
ActivitySystem.Apply(this, gameMinutes);
|
||||||
if (Catalog is not null)
|
if (Catalog is not null)
|
||||||
{
|
{
|
||||||
NeedDecay.Apply(World, Catalog, gameMinutes);
|
NeedDecay.Apply(World, Catalog, gameMinutes);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ContentLoadException>(() => _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<ContentLoadException>(() => _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<ContentLoadException>(() => _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ public class CatalogLoaderTests
|
|||||||
"sit",
|
"sit",
|
||||||
"""
|
"""
|
||||||
// a verb the Sit system already knows
|
// 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(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": [] }"""),
|
||||||
PackDocuments.Def("addon", "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""),
|
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);
|
log);
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ public class CatalogLoaderTests
|
|||||||
var catalog = _loader.Load(
|
var catalog = _loader.Load(
|
||||||
[CatalogLoader.CorePackId, "addon"],
|
[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(CatalogLoader.CorePackId, "ru", """{ "Sit": "Сесть" }"""),
|
||||||
PackDocuments.Locale("addon", "ru", """{ "Sit": "Присесть" }"""),
|
PackDocuments.Locale("addon", "ru", """{ "Sit": "Присесть" }"""),
|
||||||
],
|
],
|
||||||
@@ -63,7 +63,7 @@ public class CatalogLoaderTests
|
|||||||
var catalog = _loader.Load(
|
var catalog = _loader.Load(
|
||||||
[CatalogLoader.CorePackId],
|
[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": "Сесть" }"""),
|
PackDocuments.Locale(CatalogLoader.CorePackId, "ru", """{ "Sit": "Сесть" }"""),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -77,8 +77,8 @@ public class CatalogLoaderTests
|
|||||||
var catalog = _loader.Load(
|
var catalog = _loader.Load(
|
||||||
["addon"],
|
["addon"],
|
||||||
[
|
[
|
||||||
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""),
|
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit", "abstract": true }"""),
|
||||||
PackDocuments.Def("addon", "actions", "wave", """{ "defName": "Wave" }"""),
|
PackDocuments.Def("addon", "actions", "wave", """{ "defName": "Wave", "abstract": true }"""),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Assert.Equal([CatalogLoader.CorePackId, "addon"], catalog.PackIds);
|
Assert.Equal([CatalogLoader.CorePackId, "addon"], catalog.PackIds);
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ public class InheritanceTests
|
|||||||
var catalog = _loader.Load(
|
var catalog = _loader.Load(
|
||||||
[CatalogLoader.CorePackId],
|
[CatalogLoader.CorePackId],
|
||||||
[
|
[
|
||||||
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""),
|
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit", "abstract": true }"""),
|
||||||
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "inspect", """{ "defName": "Inspect" }"""),
|
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "inspect", """{ "defName": "Inspect", "abstract": true }"""),
|
||||||
PackDocuments.Def(
|
PackDocuments.Def(
|
||||||
CatalogLoader.CorePackId,
|
CatalogLoader.CorePackId,
|
||||||
"things",
|
"things",
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ public class MapValidationTests
|
|||||||
|
|
||||||
private static List<ContentDocument> MiniDefs(bool abstractYard = false) =>
|
private static List<ContentDocument> 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, "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""),
|
||||||
PackDocuments.Def(CatalogLoader.CorePackId, "positions", "principal", """{ "defName": "Principal" }"""),
|
PackDocuments.Def(CatalogLoader.CorePackId, "positions", "principal", """{ "defName": "Principal" }"""),
|
||||||
PackDocuments.Def(
|
PackDocuments.Def(
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ public class PatchTests
|
|||||||
var catalog = _loader.Load(
|
var catalog = _loader.Load(
|
||||||
[CatalogLoader.CorePackId, "addon"],
|
[CatalogLoader.CorePackId, "addon"],
|
||||||
[
|
[
|
||||||
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""),
|
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit", "abstract": true }"""),
|
||||||
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "inspect", """{ "defName": "Inspect" }"""),
|
PackDocuments.Def(CatalogLoader.CorePackId, "actions", "inspect", """{ "defName": "Inspect", "abstract": true }"""),
|
||||||
PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""),
|
PackDocuments.Def(CatalogLoader.CorePackId, "things", "chair", """{ "defName": "Chair", "actions": ["Sit"] }"""),
|
||||||
PackDocuments.Patch(
|
PackDocuments.Patch(
|
||||||
"addon",
|
"addon",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ public class PeopleDefTests
|
|||||||
Assert.True(catalog.BodyAttributes.ContainsKey("Height"));
|
Assert.True(catalog.BodyAttributes.ContainsKey("Height"));
|
||||||
Assert.Equal(BodyAttributeKind.Number, catalog.BodyAttributes["Height"].Kind);
|
Assert.Equal(BodyAttributeKind.Number, catalog.BodyAttributes["Height"].Kind);
|
||||||
Assert.Equal(BodyAttributeKind.Choice, catalog.BodyAttributes["HairColor"].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.Contains(catalog.Skills["Agility"].BodyLimits, limit => limit.Attribute == "Build" && limit.Value == "Obese");
|
||||||
Assert.True(catalog.NameSets.ContainsKey("Slavic"));
|
Assert.True(catalog.NameSets.ContainsKey("Slavic"));
|
||||||
Assert.True(catalog.NameSets["Slavic"].MaleGiven.Count >= 20);
|
Assert.True(catalog.NameSets["Slavic"].MaleGiven.Count >= 20);
|
||||||
|
|||||||
@@ -64,6 +64,19 @@ public class VanillaCoreTests
|
|||||||
Assert.Equal(3f, catalog.Territories["SchoolYard"].TravelMinutes);
|
Assert.Equal(3f, catalog.Territories["SchoolYard"].TravelMinutes);
|
||||||
Assert.Equal(4, catalog.Traits["Diligent"].CommuteMinutes);
|
Assert.Equal(4, catalog.Traits["Diligent"].CommuteMinutes);
|
||||||
Assert.Equal(-4, catalog.Traits["Lazy"].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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -97,6 +110,7 @@ public class VanillaCoreTests
|
|||||||
keys.AddRange(Names(catalog.Staffing.Values));
|
keys.AddRange(Names(catalog.Staffing.Values));
|
||||||
keys.AddRange(Names(catalog.DayFrames.Values));
|
keys.AddRange(Names(catalog.DayFrames.Values));
|
||||||
keys.AddRange(Names(catalog.Holidays.Values));
|
keys.AddRange(Names(catalog.Holidays.Values));
|
||||||
|
keys.AddRange(Names(catalog.Behavior.Values));
|
||||||
|
|
||||||
// Derived in code, so no def carries them.
|
// Derived in code, so no def carries them.
|
||||||
keys.Add(BodyBuilds.Attribute);
|
keys.Add(BodyBuilds.Attribute);
|
||||||
|
|||||||
@@ -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 Arch.Core;
|
||||||
|
using HSchool.Ai;
|
||||||
using HSchool.Content;
|
using HSchool.Content;
|
||||||
using HSchool.People;
|
using HSchool.People;
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ public class PeopleInSchoolTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CoreNeeds_DoNotMoveOnTick()
|
public void OffCampusNeeds_DoNotMoveOnTick()
|
||||||
{
|
{
|
||||||
var (catalog, map) = Vanilla();
|
var (catalog, map) = Vanilla();
|
||||||
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 2, "Slavic", Start);
|
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 2, "Slavic", Start);
|
||||||
@@ -90,7 +91,9 @@ public class PeopleInSchoolTests
|
|||||||
var world = World.Create();
|
var world = World.Create();
|
||||||
try
|
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);
|
NeedDecay.Apply(world, catalog, gameMinutes: 60);
|
||||||
var query = new QueryDescription().WithAll<PersonNeeds>();
|
var query = new QueryDescription().WithAll<PersonNeeds>();
|
||||||
world.Query(in query, (ref PersonNeeds needs) => Assert.Equal(0f, needs.Values["Hunger"]));
|
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 loader = new CatalogLoader();
|
||||||
var documents = new[]
|
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/things/chair.jsonc", """{ "defName": "Chair", "actions": ["Sit"] }"""),
|
||||||
new ContentDocument("core", "defs/territories/yard.jsonc", """{ "defName": "Yard", "travelMinutes": 1 }"""),
|
new ContentDocument("core", "defs/territories/yard.jsonc", """{ "defName": "Yard", "travelMinutes": 1 }"""),
|
||||||
new ContentDocument("core", "defs/buildings/main.jsonc", """{ "defName": "Main" }"""),
|
new ContentDocument("core", "defs/buildings/main.jsonc", """{ "defName": "Main" }"""),
|
||||||
|
|||||||
Reference in New Issue
Block a user