Let the catalog describe clothes and carryables on ThingDef without dressing anyone.
Empty layers keep furniture on the map; a known layer set and ColorDef palette reject unknown ids at load. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
namespace HSchool.Content;
|
||||
|
||||
/// <summary>
|
||||
/// Layers apparel occupies. Code, not a def — new layers are a rebuild, like
|
||||
/// <see cref="PersonRoles"/>.
|
||||
/// </summary>
|
||||
public static class ApparelLayers
|
||||
{
|
||||
public const string Underwear = "Underwear";
|
||||
public const string Socks = "Socks";
|
||||
public const string Bottom = "Bottom";
|
||||
public const string Top = "Top";
|
||||
public const string OverTop = "OverTop";
|
||||
public const string Outer = "Outer";
|
||||
public const string Shoes = "Shoes";
|
||||
public const string Head = "Head";
|
||||
public const string Accessory = "Accessory";
|
||||
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[Underwear, Socks, Bottom, Top, OverTop, Outer, Shoes, Head, Accessory];
|
||||
|
||||
public static bool IsKnown(string layer) =>
|
||||
All.Any(candidate => candidate.Equals(layer, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ColorDef appropriateness tags. School rules filter these, not color names.
|
||||
/// </summary>
|
||||
public static class ColorTags
|
||||
{
|
||||
public const string Neutral = "neutral";
|
||||
public const string Bright = "bright";
|
||||
public const string School = "school";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = [Neutral, Bright, School];
|
||||
|
||||
public static bool IsKnown(string tag) =>
|
||||
All.Any(candidate => candidate.Equals(tag, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Bottom length for the short-form dress code. Omit on things that have no hem.</summary>
|
||||
public static class SkirtLengths
|
||||
{
|
||||
public const string Regular = "regular";
|
||||
public const string Short = "short";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = [Regular, Short];
|
||||
|
||||
public static bool IsKnown(string length) =>
|
||||
All.Any(candidate => candidate.Equals(length, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public sealed class ColorDef : Def
|
||||
{
|
||||
public IReadOnlyList<string> Tags { get; init; } = [];
|
||||
}
|
||||
@@ -313,6 +313,7 @@ public sealed class CatalogLoader
|
||||
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);
|
||||
|
||||
foreach (var (key, json) in resolved)
|
||||
{
|
||||
@@ -372,6 +373,9 @@ public sealed class CatalogLoader
|
||||
case DefKind.Behavior:
|
||||
behavior[key.Name] = Jsonc.Deserialize<BehaviorDef>(json);
|
||||
break;
|
||||
case DefKind.Color:
|
||||
colors[key.Name] = Jsonc.Deserialize<ColorDef>(json);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +399,7 @@ public sealed class CatalogLoader
|
||||
dayFrames,
|
||||
holidays,
|
||||
behavior,
|
||||
colors,
|
||||
ru,
|
||||
en);
|
||||
}
|
||||
@@ -415,6 +420,13 @@ public sealed class CatalogLoader
|
||||
throw new ContentLoadException($"ThingDef '{thing.DefName}' references unknown ActionDef '{action}'.");
|
||||
}
|
||||
}
|
||||
|
||||
ValidateApparelFields(thing, catalog);
|
||||
}
|
||||
|
||||
foreach (var color in catalog.Colors.Values)
|
||||
{
|
||||
ValidateColor(color);
|
||||
}
|
||||
|
||||
foreach (var room in catalog.Rooms.Values)
|
||||
@@ -426,6 +438,13 @@ public sealed class CatalogLoader
|
||||
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}.");
|
||||
@@ -465,6 +484,11 @@ public sealed class CatalogLoader
|
||||
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}.");
|
||||
@@ -522,10 +546,91 @@ public sealed class CatalogLoader
|
||||
.Concat(Enumerate(catalog.Staffing.Values))
|
||||
.Concat(Enumerate(catalog.DayFrames.Values))
|
||||
.Concat(Enumerate(catalog.Holidays.Values))
|
||||
.Concat(Enumerate(catalog.Behavior.Values));
|
||||
.Concat(Enumerate(catalog.Behavior.Values))
|
||||
.Concat(Enumerate(catalog.Colors.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 0–100.");
|
||||
}
|
||||
|
||||
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}'.");
|
||||
}
|
||||
|
||||
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}'.");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed class DefCatalog
|
||||
IReadOnlyDictionary<string, DayFrameDef> dayFrames,
|
||||
IReadOnlyDictionary<string, HolidayDef> holidays,
|
||||
IReadOnlyDictionary<string, BehaviorDef> behavior,
|
||||
IReadOnlyDictionary<string, ColorDef> colors,
|
||||
IReadOnlyDictionary<string, string> ru,
|
||||
IReadOnlyDictionary<string, string> en)
|
||||
{
|
||||
@@ -48,6 +49,7 @@ public sealed class DefCatalog
|
||||
DayFrames = dayFrames;
|
||||
Holidays = holidays;
|
||||
Behavior = behavior;
|
||||
Colors = colors;
|
||||
_ru = ru;
|
||||
_en = en;
|
||||
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
|
||||
@@ -101,6 +103,8 @@ public sealed class DefCatalog
|
||||
|
||||
public IReadOnlyDictionary<string, BehaviorDef> Behavior { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, ColorDef> Colors { get; }
|
||||
|
||||
/// <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);
|
||||
|
||||
@@ -135,6 +139,7 @@ public sealed class DefCatalog
|
||||
DefKind.DayFrame => DayFrames.GetValueOrDefault(defName),
|
||||
DefKind.Holiday => Holidays.GetValueOrDefault(defName),
|
||||
DefKind.Behavior => Behavior.GetValueOrDefault(defName),
|
||||
DefKind.Color => Colors.GetValueOrDefault(defName),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -213,6 +218,7 @@ public sealed class DefCatalog
|
||||
DayFrameDef => DefKind.DayFrame,
|
||||
HolidayDef => DefKind.Holiday,
|
||||
BehaviorDef => DefKind.Behavior,
|
||||
ColorDef => DefKind.Color,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(def)),
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ public enum DefKind
|
||||
DayFrame,
|
||||
Holiday,
|
||||
Behavior,
|
||||
Color,
|
||||
}
|
||||
|
||||
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
|
||||
@@ -72,6 +73,36 @@ public sealed class ThingDef : Def
|
||||
/// must not inflate class size just because someone sits in it.
|
||||
/// </summary>
|
||||
public int PupilSlots { get; init; }
|
||||
|
||||
/// <summary>Layers this occupies when worn. Empty — not apparel, so it can sit on the map.</summary>
|
||||
public IReadOnlyList<string> Layers { get; init; } = [];
|
||||
|
||||
/// <summary>Can live in a bag, locker or at home. Furniture stays false.</summary>
|
||||
public bool Portable { get; init; }
|
||||
|
||||
/// <summary>Kilograms. Carried mass sums; worn mass does not in this slice.</summary>
|
||||
public float Mass { get; init; }
|
||||
|
||||
/// <summary>How much it warms — and how much it steams in heat.</summary>
|
||||
public float Insulation { get; init; }
|
||||
|
||||
/// <summary>0–100. Dress-code formality, not a fashion score.</summary>
|
||||
public int Formality { get; init; }
|
||||
|
||||
/// <summary>Allowed age window. Omit for any age.</summary>
|
||||
public IntRange? Age { get; init; }
|
||||
|
||||
/// <summary><c>male</c>, <c>female</c>, or omit for any.</summary>
|
||||
public string? Sex { get; init; }
|
||||
|
||||
/// <summary>ColorDef ids this thing may be. Empty — the instance has no colour.</summary>
|
||||
public IReadOnlyList<string> Colors { get; init; } = [];
|
||||
|
||||
/// <summary><see cref="SkirtLengths"/> id, or omit when there is no hem to measure.</summary>
|
||||
public string? SkirtLength { get; init; }
|
||||
|
||||
/// <summary>PE kit. Worn for that lesson; not a second wardrobe of everyday clothes.</summary>
|
||||
public bool Pe { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PositionDef : Def;
|
||||
|
||||
@@ -161,6 +161,11 @@ public static class MapValidator
|
||||
throw new MapValidationException($"Room '{room.Id}' slot '{fill.Key}' uses unknown or abstract ThingDef '{fill.Thing}'.");
|
||||
}
|
||||
|
||||
if (thing.Layers.Count > 0 || thing.Portable)
|
||||
{
|
||||
throw new MapValidationException($"Room '{room.Id}' slot '{fill.Key}' cannot place '{fill.Thing}' on the map.");
|
||||
}
|
||||
|
||||
if (fill.Count > byte.MaxValue)
|
||||
{
|
||||
throw new MapValidationException($"Room '{room.Id}' slot '{fill.Key}' count {fill.Count} is above {byte.MaxValue}.");
|
||||
|
||||
@@ -123,6 +123,9 @@ internal static class PackPaths
|
||||
case "behavior":
|
||||
kind = DefKind.Behavior;
|
||||
return true;
|
||||
case "colors":
|
||||
kind = DefKind.Color;
|
||||
return true;
|
||||
default:
|
||||
kind = default;
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user