Files
h-school/src/HSchool.Content/CatalogLoader.cs
T
Leonid PershinandCursor 3c682a2b74 Add scene fragments to portrait prompts after clothing.
Thing, room, yard and climate precipitation can contribute a short prompt when they are in frame, with a budget so chairs stay silent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:35:17 +03:00

688 lines
26 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, log);
WarnMissingLabels(catalog, log);
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 countries = new Dictionary<string, CountryDef>(StringComparer.Ordinal);
var climatePresets = new Dictionary<string, ClimatePresetDef>(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);
var behavior = new Dictionary<string, BehaviorDef>(StringComparer.Ordinal);
var colors = new Dictionary<string, ColorDef>(StringComparer.Ordinal);
var topics = new Dictionary<string, TopicDef>(StringComparer.Ordinal);
var orientations = new Dictionary<string, OrientationDef>(StringComparer.Ordinal);
var affinity = new Dictionary<string, AffinityRulesDef>(StringComparer.Ordinal);
var events = new Dictionary<string, EventDef>(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.Country:
countries[key.Name] = Jsonc.Deserialize<CountryDef>(json);
break;
case DefKind.ClimatePreset:
climatePresets[key.Name] = Jsonc.Deserialize<ClimatePresetDef>(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;
case DefKind.Behavior:
behavior[key.Name] = Jsonc.Deserialize<BehaviorDef>(json);
break;
case DefKind.Color:
colors[key.Name] = Jsonc.Deserialize<ColorDef>(json);
break;
case DefKind.Topic:
topics[key.Name] = Jsonc.Deserialize<TopicDef>(json);
break;
case DefKind.Orientation:
orientations[key.Name] = Jsonc.Deserialize<OrientationDef>(json);
break;
case DefKind.AffinityRules:
affinity[key.Name] = Jsonc.Deserialize<AffinityRulesDef>(json);
break;
case DefKind.Event:
events[key.Name] = Jsonc.Deserialize<EventDef>(json);
break;
}
}
return new DefCatalog(
packIds,
actions,
things,
positions,
works,
rooms,
buildings,
floors,
territories,
skills,
traits,
bodyAttributes,
needs,
countries,
climatePresets,
subjects,
staffing,
dayFrames,
holidays,
behavior,
colors,
topics,
orientations,
affinity,
events,
ru,
en);
}
private static void ResolveReferences(DefCatalog catalog, IContentLog log)
{
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}'.");
}
}
ValidateApparelFields(thing, catalog);
}
foreach (var color in catalog.Colors.Values)
{
ValidateColor(color);
}
foreach (var thing in catalog.Things.Values)
{
PromptContributionValidator.Validate($"ThingDef '{thing.DefName}'", thing.Prompt);
}
foreach (var room in catalog.Rooms.Values)
{
PromptContributionValidator.Validate($"RoomDef '{room.DefName}'", room.Prompt);
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}'.");
}
var slotted = catalog.Things[slot.Thing];
if (slotted.Layers.Count > 0 || slotted.Portable)
{
throw new ContentLoadException(
$"RoomDef '{room.DefName}' slot '{slot.Key}' cannot place '{slot.Thing}' on the map.");
}
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 (seat.Layers.Count > 0 || seat.Portable)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' seatThing '{room.SeatThing}' cannot be apparel or carried.");
}
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)
{
PromptContributionValidator.Validate($"TerritoryDef '{territory.DefName}'", territory.Prompt);
if (!territory.Abstract && territory.TravelMinutes <= 0)
{
throw new ContentLoadException($"TerritoryDef '{territory.DefName}' travelMinutes must be positive.");
}
}
PeopleDefValidator.Validate(catalog, log);
EventDefValidator.Validate(catalog);
}
private static void WarnMissingLabels(DefCatalog catalog, IContentLog log)
{
foreach (var def in ConcreteDefs(catalog))
{
if (!catalog.HasText("ru", def.DefName) && !catalog.HasText("en", def.DefName))
{
log.Warning($"Def {DefCatalog.KindOf(def)}:{def.DefName} has no label in the pack locales.");
}
}
}
private static IEnumerable<Def> ConcreteDefs(DefCatalog catalog)
{
return Enumerate(catalog.Actions.Values)
.Concat(Enumerate(catalog.Things.Values))
.Concat(Enumerate(catalog.Positions.Values))
.Concat(Enumerate(catalog.Works.Values))
.Concat(Enumerate(catalog.Rooms.Values))
.Concat(Enumerate(catalog.Buildings.Values))
.Concat(Enumerate(catalog.Floors.Values))
.Concat(Enumerate(catalog.Territories.Values))
.Concat(Enumerate(catalog.Skills.Values))
.Concat(Enumerate(catalog.Traits.Values))
.Concat(Enumerate(catalog.BodyAttributes.Values))
.Concat(Enumerate(catalog.Needs.Values))
.Concat(Enumerate(catalog.Countries.Values))
.Concat(Enumerate(catalog.ClimatePresets.Values))
.Concat(Enumerate(catalog.Subjects.Values))
.Concat(Enumerate(catalog.Staffing.Values))
.Concat(Enumerate(catalog.DayFrames.Values))
.Concat(Enumerate(catalog.Holidays.Values))
.Concat(Enumerate(catalog.Behavior.Values))
.Concat(Enumerate(catalog.Colors.Values))
.Concat(Enumerate(catalog.Topics.Values))
.Concat(Enumerate(catalog.Orientations.Values))
.Concat(Enumerate(catalog.Affinity.Values))
.Concat(Enumerate(catalog.Events.Values));
static IEnumerable<Def> Enumerate(IEnumerable<Def> defs) => defs.Where(def => !def.Abstract);
}
private static void ValidateColor(ColorDef color)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var tag in color.Tags)
{
if (!ColorTags.IsKnown(tag))
{
throw new ContentLoadException($"ColorDef '{color.DefName}' has unknown tag '{tag}'.");
}
if (!seen.Add(tag))
{
throw new ContentLoadException($"ColorDef '{color.DefName}' repeats tag '{tag}'.");
}
}
}
private static void ValidateApparelFields(ThingDef thing, DefCatalog catalog)
{
if (thing.Mass < 0)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' mass cannot be negative.");
}
if (thing.Insulation < 0)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' insulation cannot be negative.");
}
if (thing.Formality is < 0 or > 100)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' formality must be 0100.");
}
if (thing.Age is { } age && age.Min > age.Max)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' age min is above max.");
}
if (thing.Sex is not null
&& !thing.Sex.Equals("male", StringComparison.OrdinalIgnoreCase)
&& !thing.Sex.Equals("female", StringComparison.OrdinalIgnoreCase))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' has unknown sex '{thing.Sex}'.");
}
if (thing.SkirtLength is not null && !SkirtLengths.IsKnown(thing.SkirtLength))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' has unknown skirtLength '{thing.SkirtLength}'.");
}
if (thing.CarryChance is < 0f or > 1f)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' carryChance must be 01.");
}
var layers = new HashSet<string>(StringComparer.Ordinal);
foreach (var layer in thing.Layers)
{
if (!ApparelLayers.IsKnown(layer))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' has unknown layer '{layer}'.");
}
if (!layers.Add(layer))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' repeats layer '{layer}'.");
}
}
foreach (var covered in thing.FullyCoversLayers)
{
if (!ApparelLayers.IsKnown(covered))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' fullyCoversLayers has unknown layer '{covered}'.");
}
}
var colors = new HashSet<string>(StringComparer.Ordinal);
foreach (var color in thing.Colors)
{
if (!colors.Add(color))
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' repeats color '{color}'.");
}
if (!catalog.Colors.TryGetValue(color, out var def) || def.Abstract)
{
throw new ContentLoadException($"ThingDef '{thing.DefName}' references unknown ColorDef '{color}'.");
}
}
}
private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source);
}