- Introduced the `HSchool.Ai` project, responsible for routing, day plans, and presence management. - Updated the `HSchool.Simulation` project to integrate with the new AI functionalities, improving decision-making and presence tracking. - Added `travelMinutes` to room and territory definitions, ensuring accurate movement calculations within the simulation. - Enhanced the `School` class to manage presence and implement empty-time skipping functionality. - Updated documentation to reflect the new AI features and their impact on school simulation. - Added tests for presence management and routing to ensure robust functionality and reliability.
491 lines
18 KiB
C#
491 lines
18 KiB
C#
using System.Text.Json.Nodes;
|
||
|
||
namespace HSchool.Content;
|
||
|
||
/// <summary>
|
||
/// Turns pack documents into a frozen <see cref="DefCatalog"/>. Callers supply already-read
|
||
/// files; this type never looks at the disk.
|
||
/// </summary>
|
||
public sealed class CatalogLoader
|
||
{
|
||
public const string CorePackId = "core";
|
||
|
||
public DefCatalog Load(
|
||
IReadOnlyList<string> packOrder,
|
||
IReadOnlyList<ContentDocument> documents,
|
||
IContentLog? log = null)
|
||
{
|
||
log ??= NullContentLog.Instance;
|
||
var order = NormalizePackOrder(packOrder);
|
||
|
||
var defs = new Dictionary<(DefKind Kind, string Name), RawDef>();
|
||
var localesRu = new Dictionary<string, string>(StringComparer.Ordinal);
|
||
var localesEn = new Dictionary<string, string>(StringComparer.Ordinal);
|
||
var patches = new List<(string PackId, PatchDocument Patch, string Source)>();
|
||
|
||
foreach (var packId in order)
|
||
{
|
||
foreach (var document in documents.Where(candidate => candidate.PackId == packId))
|
||
{
|
||
ReadDocument(document, defs, localesRu, localesEn, patches, log);
|
||
}
|
||
}
|
||
|
||
var resolved = ResolveInheritance(defs);
|
||
ApplyPatches(resolved, patches);
|
||
var catalog = Materialize(order, resolved, localesRu, localesEn);
|
||
ResolveReferences(catalog);
|
||
return catalog;
|
||
}
|
||
|
||
public static IReadOnlyList<string> NormalizePackOrder(IReadOnlyList<string> packOrder)
|
||
{
|
||
var order = new List<string> { CorePackId };
|
||
foreach (var packId in packOrder)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(packId) || packId.Equals(CorePackId, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!order.Contains(packId, StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
order.Add(packId);
|
||
}
|
||
}
|
||
|
||
return order;
|
||
}
|
||
|
||
public static MapLayout? LastDefaultMap(IReadOnlyList<string> packOrder, IReadOnlyList<ContentDocument> documents)
|
||
{
|
||
var order = NormalizePackOrder(packOrder);
|
||
MapLayout? map = null;
|
||
foreach (var packId in order)
|
||
{
|
||
foreach (var document in documents.Where(candidate => candidate.PackId == packId && PackPaths.IsDefaultMap(candidate.RelativePath)))
|
||
{
|
||
map = MapLayout.Parse(document.Text, $"{packId}:{document.RelativePath}");
|
||
}
|
||
}
|
||
|
||
return map;
|
||
}
|
||
|
||
private static void ReadDocument(
|
||
ContentDocument document,
|
||
Dictionary<(DefKind Kind, string Name), RawDef> defs,
|
||
Dictionary<string, string> localesRu,
|
||
Dictionary<string, string> localesEn,
|
||
List<(string PackId, PatchDocument Patch, string Source)> patches,
|
||
IContentLog log)
|
||
{
|
||
var source = $"{document.PackId}:{document.RelativePath}";
|
||
|
||
if (PackPaths.TryGetDefKind(document.RelativePath, out var kind))
|
||
{
|
||
foreach (var json in EnumerateObjects(document.Text, source))
|
||
{
|
||
var defName = ReadDefName(json, source);
|
||
var key = (kind, defName);
|
||
if (defs.TryGetValue(key, out var previous))
|
||
{
|
||
log.Warning($"Def {kind}:{defName} from pack '{document.PackId}' replaces '{previous.PackId}'.");
|
||
}
|
||
|
||
defs[key] = new RawDef(document.PackId, kind, defName, json, source);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (PackPaths.IsPatch(document.RelativePath))
|
||
{
|
||
foreach (var json in EnumerateObjects(document.Text, source))
|
||
{
|
||
var patch = Jsonc.Deserialize<PatchDocument>(json);
|
||
if (string.IsNullOrWhiteSpace(patch.Target))
|
||
{
|
||
throw new ContentLoadException($"Patch in {source} has no target.");
|
||
}
|
||
|
||
patches.Add((document.PackId, patch, source));
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (PackPaths.TryGetLocaleLanguage(document.RelativePath, out var language))
|
||
{
|
||
var table = language == "en" ? localesEn : localesRu;
|
||
MergeLocale(Jsonc.Parse(document.Text, source), table, document.PackId, log);
|
||
}
|
||
}
|
||
|
||
private static IEnumerable<JsonObject> EnumerateObjects(string text, string source)
|
||
{
|
||
var node = Jsonc.Parse(text, source);
|
||
switch (node)
|
||
{
|
||
case JsonObject obj:
|
||
yield return obj;
|
||
break;
|
||
|
||
case JsonArray array:
|
||
foreach (var item in array)
|
||
{
|
||
if (item is not JsonObject obj)
|
||
{
|
||
throw new ContentLoadException($"{source} has a non-object entry in an array.");
|
||
}
|
||
|
||
yield return obj;
|
||
}
|
||
|
||
break;
|
||
|
||
default:
|
||
throw new ContentLoadException($"{source} must be an object or an array of objects.");
|
||
}
|
||
}
|
||
|
||
private static string ReadDefName(JsonObject json, string source)
|
||
{
|
||
if (json["defName"] is JsonValue value && value.TryGetValue<string>(out var name) && !string.IsNullOrWhiteSpace(name))
|
||
{
|
||
return name;
|
||
}
|
||
|
||
throw new ContentLoadException($"A def in {source} is missing defName.");
|
||
}
|
||
|
||
private static void MergeLocale(JsonNode node, Dictionary<string, string> table, string packId, IContentLog log)
|
||
{
|
||
if (node is not JsonObject obj)
|
||
{
|
||
throw new ContentLoadException($"Localization in pack '{packId}' must be an object of strings.");
|
||
}
|
||
|
||
foreach (var property in obj)
|
||
{
|
||
if (property.Value is not JsonValue value || !value.TryGetValue<string>(out var text))
|
||
{
|
||
throw new ContentLoadException($"Localization key '{property.Key}' in pack '{packId}' is not a string.");
|
||
}
|
||
|
||
if (table.ContainsKey(property.Key))
|
||
{
|
||
log.Warning($"Locale key '{property.Key}' from pack '{packId}' replaces an earlier pack.");
|
||
}
|
||
|
||
table[property.Key] = text;
|
||
}
|
||
}
|
||
|
||
private static Dictionary<(DefKind Kind, string Name), JsonObject> ResolveInheritance(
|
||
Dictionary<(DefKind Kind, string Name), RawDef> defs)
|
||
{
|
||
var resolved = new Dictionary<(DefKind Kind, string Name), JsonObject>();
|
||
var visiting = new HashSet<(DefKind Kind, string Name)>();
|
||
|
||
foreach (var key in defs.Keys)
|
||
{
|
||
ResolveOne(key, defs, resolved, visiting);
|
||
}
|
||
|
||
return resolved;
|
||
}
|
||
|
||
private static JsonObject ResolveOne(
|
||
(DefKind Kind, string Name) key,
|
||
Dictionary<(DefKind Kind, string Name), RawDef> defs,
|
||
Dictionary<(DefKind Kind, string Name), JsonObject> resolved,
|
||
HashSet<(DefKind Kind, string Name)> visiting)
|
||
{
|
||
if (resolved.TryGetValue(key, out var already))
|
||
{
|
||
return already;
|
||
}
|
||
|
||
if (!defs.TryGetValue(key, out var raw))
|
||
{
|
||
throw new ContentLoadException($"Def {key.Kind}:{key.Name} was referenced as a parent but does not exist.");
|
||
}
|
||
|
||
if (!visiting.Add(key))
|
||
{
|
||
throw new ContentLoadException($"Def {key.Kind}:{key.Name} has a cyclic parent chain.");
|
||
}
|
||
|
||
JsonObject merged;
|
||
var parentName = raw.Json["parent"] is JsonValue parentValue && parentValue.TryGetValue<string>(out var name)
|
||
? name
|
||
: null;
|
||
|
||
if (string.IsNullOrWhiteSpace(parentName))
|
||
{
|
||
merged = (JsonObject)raw.Json.DeepClone();
|
||
}
|
||
else
|
||
{
|
||
var parentCandidates = defs.Keys.Where(candidate => candidate.Name.Equals(parentName, StringComparison.Ordinal)).ToList();
|
||
if (parentCandidates.Count == 0)
|
||
{
|
||
throw new ContentLoadException($"Def {raw.Kind}:{raw.DefName} parent '{parentName}' does not exist.");
|
||
}
|
||
|
||
if (!parentCandidates.Any(candidate => candidate.Kind == raw.Kind))
|
||
{
|
||
throw new ContentLoadException($"Def {raw.Kind}:{raw.DefName} cannot inherit from a different kind '{parentName}'.");
|
||
}
|
||
|
||
var parentJson = ResolveOne((raw.Kind, parentName), defs, resolved, visiting);
|
||
merged = Merge(parentJson, raw.Json);
|
||
}
|
||
|
||
visiting.Remove(key);
|
||
resolved[key] = merged;
|
||
return merged;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Child fields replace parent fields wholesale, including arrays.
|
||
/// <c>abstract</c> is a flag of this def, not inherited: a child of an abstract parent is
|
||
/// concrete unless it also says <c>abstract: true</c>.
|
||
/// </summary>
|
||
private static JsonObject Merge(JsonObject parent, JsonObject child)
|
||
{
|
||
var result = (JsonObject)parent.DeepClone();
|
||
foreach (var property in child)
|
||
{
|
||
result[property.Key] = property.Value?.DeepClone();
|
||
}
|
||
|
||
if (!child.ContainsKey("abstract"))
|
||
{
|
||
result.Remove("abstract");
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static void ApplyPatches(
|
||
Dictionary<(DefKind Kind, string Name), JsonObject> resolved,
|
||
List<(string PackId, PatchDocument Patch, string Source)> patches)
|
||
{
|
||
foreach (var (_, patch, source) in patches)
|
||
{
|
||
var matches = resolved.Where(pair => pair.Key.Name.Equals(patch.Target, StringComparison.Ordinal)).ToList();
|
||
if (matches.Count == 0)
|
||
{
|
||
throw new ContentLoadException($"Patch target '{patch.Target}' was not found ({source}).");
|
||
}
|
||
|
||
foreach (var match in matches)
|
||
{
|
||
PatchApplier.Apply(match.Value, patch);
|
||
}
|
||
}
|
||
}
|
||
|
||
private static DefCatalog Materialize(
|
||
IReadOnlyList<string> packIds,
|
||
Dictionary<(DefKind Kind, string Name), JsonObject> resolved,
|
||
Dictionary<string, string> ru,
|
||
Dictionary<string, string> en)
|
||
{
|
||
var actions = new Dictionary<string, ActionDef>(StringComparer.Ordinal);
|
||
var things = new Dictionary<string, ThingDef>(StringComparer.Ordinal);
|
||
var positions = new Dictionary<string, PositionDef>(StringComparer.Ordinal);
|
||
var works = new Dictionary<string, WorkDef>(StringComparer.Ordinal);
|
||
var rooms = new Dictionary<string, RoomDef>(StringComparer.Ordinal);
|
||
var buildings = new Dictionary<string, BuildingDef>(StringComparer.Ordinal);
|
||
var floors = new Dictionary<string, FloorDef>(StringComparer.Ordinal);
|
||
var territories = new Dictionary<string, TerritoryDef>(StringComparer.Ordinal);
|
||
var skills = new Dictionary<string, SkillDef>(StringComparer.Ordinal);
|
||
var traits = new Dictionary<string, TraitDef>(StringComparer.Ordinal);
|
||
var bodyAttributes = new Dictionary<string, BodyAttributeDef>(StringComparer.Ordinal);
|
||
var needs = new Dictionary<string, NeedDef>(StringComparer.Ordinal);
|
||
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal);
|
||
var subjects = new Dictionary<string, SubjectDef>(StringComparer.Ordinal);
|
||
var staffing = new Dictionary<string, StaffingDef>(StringComparer.Ordinal);
|
||
var dayFrames = new Dictionary<string, DayFrameDef>(StringComparer.Ordinal);
|
||
var holidays = new Dictionary<string, HolidayDef>(StringComparer.Ordinal);
|
||
|
||
foreach (var (key, json) in resolved)
|
||
{
|
||
switch (key.Kind)
|
||
{
|
||
case DefKind.Action:
|
||
actions[key.Name] = Jsonc.Deserialize<ActionDef>(json);
|
||
break;
|
||
case DefKind.Thing:
|
||
things[key.Name] = Jsonc.Deserialize<ThingDef>(json);
|
||
break;
|
||
case DefKind.Position:
|
||
positions[key.Name] = Jsonc.Deserialize<PositionDef>(json);
|
||
break;
|
||
case DefKind.Work:
|
||
works[key.Name] = Jsonc.Deserialize<WorkDef>(json);
|
||
break;
|
||
case DefKind.Room:
|
||
rooms[key.Name] = Jsonc.Deserialize<RoomDef>(json);
|
||
break;
|
||
case DefKind.Building:
|
||
buildings[key.Name] = Jsonc.Deserialize<BuildingDef>(json);
|
||
break;
|
||
case DefKind.Floor:
|
||
floors[key.Name] = Jsonc.Deserialize<FloorDef>(json);
|
||
break;
|
||
case DefKind.Territory:
|
||
territories[key.Name] = Jsonc.Deserialize<TerritoryDef>(json);
|
||
break;
|
||
case DefKind.Skill:
|
||
skills[key.Name] = Jsonc.Deserialize<SkillDef>(json);
|
||
break;
|
||
case DefKind.Trait:
|
||
traits[key.Name] = Jsonc.Deserialize<TraitDef>(json);
|
||
break;
|
||
case DefKind.BodyAttribute:
|
||
bodyAttributes[key.Name] = Jsonc.Deserialize<BodyAttributeDef>(json);
|
||
break;
|
||
case DefKind.Need:
|
||
needs[key.Name] = Jsonc.Deserialize<NeedDef>(json);
|
||
break;
|
||
case DefKind.NameSet:
|
||
nameSets[key.Name] = Jsonc.Deserialize<NameSetDef>(json);
|
||
break;
|
||
case DefKind.Subject:
|
||
subjects[key.Name] = Jsonc.Deserialize<SubjectDef>(json);
|
||
break;
|
||
case DefKind.Staffing:
|
||
staffing[key.Name] = Jsonc.Deserialize<StaffingDef>(json);
|
||
break;
|
||
case DefKind.DayFrame:
|
||
dayFrames[key.Name] = Jsonc.Deserialize<DayFrameDef>(json);
|
||
break;
|
||
case DefKind.Holiday:
|
||
holidays[key.Name] = Jsonc.Deserialize<HolidayDef>(json);
|
||
break;
|
||
}
|
||
}
|
||
|
||
return new DefCatalog(
|
||
packIds,
|
||
actions,
|
||
things,
|
||
positions,
|
||
works,
|
||
rooms,
|
||
buildings,
|
||
floors,
|
||
territories,
|
||
skills,
|
||
traits,
|
||
bodyAttributes,
|
||
needs,
|
||
nameSets,
|
||
subjects,
|
||
staffing,
|
||
dayFrames,
|
||
holidays,
|
||
ru,
|
||
en);
|
||
}
|
||
|
||
private static void ResolveReferences(DefCatalog catalog)
|
||
{
|
||
foreach (var thing in catalog.Things.Values)
|
||
{
|
||
if (thing.PupilSlots < 0 || thing.PupilSlots > byte.MaxValue)
|
||
{
|
||
throw new ContentLoadException($"ThingDef '{thing.DefName}' pupilSlots must be 0–{byte.MaxValue}.");
|
||
}
|
||
|
||
foreach (var action in thing.Actions)
|
||
{
|
||
if (!catalog.Actions.ContainsKey(action))
|
||
{
|
||
throw new ContentLoadException($"ThingDef '{thing.DefName}' references unknown ActionDef '{action}'.");
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (var room in catalog.Rooms.Values)
|
||
{
|
||
foreach (var slot in room.Slots)
|
||
{
|
||
if (!catalog.Things.ContainsKey(slot.Thing))
|
||
{
|
||
throw new ContentLoadException($"RoomDef '{room.DefName}' slot '{slot.Key}' references unknown ThingDef '{slot.Thing}'.");
|
||
}
|
||
|
||
if (slot.Count < 0 || slot.Count > byte.MaxValue)
|
||
{
|
||
throw new ContentLoadException($"RoomDef '{room.DefName}' slot '{slot.Key}' count must be 0–{byte.MaxValue}.");
|
||
}
|
||
}
|
||
|
||
foreach (var position in room.Positions)
|
||
{
|
||
if (!catalog.Positions.ContainsKey(position))
|
||
{
|
||
throw new ContentLoadException($"RoomDef '{room.DefName}' references unknown PositionDef '{position}'.");
|
||
}
|
||
}
|
||
|
||
foreach (var work in room.Works)
|
||
{
|
||
if (!catalog.Works.ContainsKey(work))
|
||
{
|
||
throw new ContentLoadException($"RoomDef '{room.DefName}' references unknown WorkDef '{work}'.");
|
||
}
|
||
}
|
||
|
||
if (room.Homeroom)
|
||
{
|
||
if (room.Slots.Count > 0)
|
||
{
|
||
throw new ContentLoadException($"RoomDef '{room.DefName}' is a homeroom and cannot declare named slots.");
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(room.SeatThing) || !catalog.Things.TryGetValue(room.SeatThing, out var seat) || seat.Abstract)
|
||
{
|
||
throw new ContentLoadException($"RoomDef '{room.DefName}' seatThing is missing or unknown.");
|
||
}
|
||
|
||
if (seat.PupilSlots <= 0)
|
||
{
|
||
throw new ContentLoadException($"RoomDef '{room.DefName}' seatThing '{room.SeatThing}' must have pupilSlots.");
|
||
}
|
||
|
||
if (room.DefaultSeats < 1 || room.DefaultSeats > byte.MaxValue)
|
||
{
|
||
throw new ContentLoadException($"RoomDef '{room.DefName}' defaultSeats must be 1–{byte.MaxValue}.");
|
||
}
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(room.SeatThing) || room.DefaultSeats != 0)
|
||
{
|
||
throw new ContentLoadException($"RoomDef '{room.DefName}' is not a homeroom and cannot set seatThing or defaultSeats.");
|
||
}
|
||
|
||
if (!room.Abstract && room.TravelMinutes <= 0)
|
||
{
|
||
throw new ContentLoadException($"RoomDef '{room.DefName}' travelMinutes must be positive.");
|
||
}
|
||
}
|
||
|
||
foreach (var territory in catalog.Territories.Values)
|
||
{
|
||
if (!territory.Abstract && territory.TravelMinutes <= 0)
|
||
{
|
||
throw new ContentLoadException($"TerritoryDef '{territory.DefName}' travelMinutes must be positive.");
|
||
}
|
||
}
|
||
|
||
PeopleDefValidator.Validate(catalog);
|
||
}
|
||
|
||
private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source);
|
||
}
|