Files
h-school/src/HSchool.Content/CatalogLoader.cs
T
Leonid Pershin 4137400621
ci / server (push) Failing after 3m39s
ci / client (push) Successful in 14s
Enhance AI and simulation components with presence management and routing capabilities
- 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.
2026-08-19 16:33:52 +03:00

491 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}