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:
Leonid Pershin
2026-08-20 02:47:23 +03:00
co-authored by Cursor
parent 450495f666
commit abcf67eaab
16 changed files with 716 additions and 15 deletions
+56
View File
@@ -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; } = [];
}
+106 -1
View File
@@ -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 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}'.");
}
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);
}
+6
View File
@@ -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)),
};
+31
View File
@@ -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>0100. 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;
+5
View File
@@ -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}.");
+3
View File
@@ -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;
+1 -1
View File
@@ -167,7 +167,7 @@ internal sealed record CatalogResponse(
private static IReadOnlyList<DefInfoResponse> PlaceableThings(DefCatalog catalog, string locale) =>
catalog.Things.Values
.Where(def => !def.Abstract)
.Where(def => !def.Abstract && def.Layers.Count == 0 && !def.Portable)
.OrderBy(def => def.DefName, StringComparer.Ordinal)
.Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def), def.PupilSlots))
.ToArray();
@@ -0,0 +1,10 @@
[
{ "defName": "White", "tags": ["neutral", "school"] },
{ "defName": "Black", "tags": ["neutral", "school"] },
{ "defName": "Gray", "tags": ["neutral", "school"] },
{ "defName": "Blue", "tags": ["neutral", "school"] },
{ "defName": "LightBlue" },
{ "defName": "Red", "tags": ["bright"] },
{ "defName": "Green", "tags": ["bright"] },
{ "defName": "Beige" },
]
@@ -0,0 +1,210 @@
[
{
"defName": "Underwear",
"layers": ["Underwear"],
"portable": true,
"mass": 0.05,
"insulation": 1,
"colors": ["White", "Black", "Gray"],
},
{
"defName": "Socks",
"layers": ["Socks"],
"portable": true,
"mass": 0.04,
"insulation": 1,
"colors": ["White", "Black", "Gray"],
},
{
"defName": "Skirt",
"layers": ["Bottom"],
"portable": true,
"mass": 0.25,
"insulation": 2,
"formality": 40,
"sex": "female",
"skirtLength": "regular",
"colors": ["Black", "Gray", "Blue", "Beige"],
},
{
"defName": "ShortSkirt",
"layers": ["Bottom"],
"portable": true,
"mass": 0.2,
"insulation": 1,
"formality": 30,
"sex": "female",
"skirtLength": "short",
"colors": ["Black", "Gray", "Blue", "Beige"],
},
{
"defName": "Pants",
"layers": ["Bottom"],
"portable": true,
"mass": 0.4,
"insulation": 3,
"formality": 30,
"skirtLength": "regular",
"colors": ["Black", "Gray", "Blue", "Beige"],
},
{
"defName": "Shorts",
"layers": ["Bottom"],
"portable": true,
"mass": 0.25,
"insulation": 1,
"formality": 20,
"skirtLength": "short",
"colors": ["Black", "Gray", "Blue", "Beige"],
},
{
"defName": "Jeans",
"layers": ["Bottom"],
"portable": true,
"mass": 0.5,
"insulation": 3,
"formality": 20,
"skirtLength": "regular",
"colors": ["Blue", "Black", "Gray"],
},
{
"defName": "Trousers",
"layers": ["Bottom"],
"portable": true,
"mass": 0.4,
"insulation": 3,
"formality": 70,
"skirtLength": "regular",
"colors": ["Black", "Gray", "Blue"],
},
{
"defName": "TShirt",
"layers": ["Top"],
"portable": true,
"mass": 0.15,
"insulation": 2,
"formality": 20,
"colors": ["White", "Black", "Gray", "Blue", "LightBlue", "Red", "Green", "Beige"],
},
{
"defName": "Shirt",
"layers": ["Top"],
"portable": true,
"mass": 0.2,
"insulation": 2,
"formality": 70,
"colors": ["White", "Black", "Blue", "Gray"],
},
{
"defName": "Sweater",
"layers": ["OverTop"],
"portable": true,
"mass": 0.4,
"insulation": 8,
"formality": 30,
"colors": ["Black", "Gray", "Blue", "Red", "Green", "Beige"],
},
{
"defName": "Jacket",
"layers": ["Outer"],
"portable": true,
"mass": 1,
"insulation": 12,
"formality": 40,
"colors": ["Black", "Gray", "Blue", "Beige", "Red", "Green"],
},
{
"defName": "Coat",
"layers": ["Outer"],
"portable": true,
"mass": 1.5,
"insulation": 18,
"formality": 70,
"colors": ["Black", "Gray", "Beige"],
},
{
"defName": "FurCoat",
"layers": ["Outer"],
"portable": true,
"mass": 2.5,
"insulation": 28,
"formality": 50,
"colors": ["Black", "Beige", "Gray"],
},
{
"defName": "Shoes",
"layers": ["Shoes"],
"portable": true,
"mass": 0.6,
"insulation": 2,
"formality": 40,
"colors": ["Black", "Gray", "Beige", "White"],
},
{
"defName": "WinterBoots",
"layers": ["Shoes"],
"portable": true,
"mass": 1,
"insulation": 8,
"formality": 30,
"colors": ["Black", "Gray", "Beige"],
},
{
"defName": "Hat",
"layers": ["Head"],
"portable": true,
"mass": 0.15,
"insulation": 6,
"formality": 20,
"colors": ["Black", "Gray", "Beige", "Red", "Blue"],
},
{
"defName": "Belt",
"layers": ["Accessory"],
"portable": true,
"mass": 0.1,
"formality": 50,
"colors": ["Black", "Gray", "Beige"],
},
{
"defName": "Scarf",
"layers": ["Accessory"],
"portable": true,
"mass": 0.1,
"insulation": 4,
"formality": 30,
"colors": ["Black", "Gray", "Blue", "Red", "Beige"],
},
{
"defName": "Dress",
"layers": ["Bottom", "Top"],
"portable": true,
"mass": 0.4,
"insulation": 3,
"formality": 50,
"sex": "female",
"skirtLength": "regular",
"colors": ["Black", "Blue", "Red", "Beige", "Gray"],
},
{
"defName": "PeShirt",
"layers": ["Top"],
"portable": true,
"mass": 0.15,
"insulation": 2,
"formality": 10,
"pe": true,
"colors": ["Gray", "Blue", "Black", "Green", "Red"],
},
{
"defName": "PeShorts",
"layers": ["Bottom"],
"portable": true,
"mass": 0.2,
"insulation": 1,
"formality": 10,
"pe": true,
"skirtLength": "short",
"colors": ["Gray", "Blue", "Black", "Green", "Red"],
},
]
@@ -0,0 +1,6 @@
[
{ "defName": "SchoolBag", "portable": true, "mass": 0.8 },
{ "defName": "Textbook", "portable": true, "mass": 0.4 },
{ "defName": "Phone", "portable": true, "mass": 0.15, "colors": ["Black", "White", "Gray"] },
{ "defName": "Bottle", "portable": true, "mass": 0.3, "colors": ["Blue", "Green", "White"] },
]
@@ -18,6 +18,46 @@
"Computer": "Computer",
"MedicalCouch": "Exam couch",
"Locker": "Locker",
"Underwear": "Underwear",
"Socks": "Socks",
"Skirt": "Skirt",
"ShortSkirt": "Short skirt",
"Pants": "Pants",
"Shorts": "Shorts",
"Jeans": "Jeans",
"Trousers": "Trousers",
"TShirt": "T-shirt",
"Shirt": "Shirt",
"Sweater": "Sweater",
"Jacket": "Jacket",
"Coat": "Coat",
"FurCoat": "Fur coat",
"Shoes": "Shoes",
"WinterBoots": "Winter boots",
"Hat": "Hat",
"Belt": "Belt",
"Scarf": "Scarf",
"Dress": "Dress",
"PeShirt": "PE shirt",
"PeShorts": "PE shorts",
"SchoolBag": "School bag",
"Textbook": "Textbook",
"Phone": "Phone",
"Bottle": "Bottle",
"White": "White",
"Black": "Black",
"Gray": "Gray",
"Blue": "Blue",
"LightBlue": "Light blue",
"Red": "Red",
"Green": "Green",
"Beige": "Beige",
"Bottom": "Bottom",
"Top": "Top",
"OverTop": "Over top",
"Outer": "Outerwear",
"Head": "Head",
"Accessory": "Accessory",
"Principal": "Principal",
"Secretary": "Secretary",
"Teacher": "Teacher",
@@ -18,6 +18,46 @@
"Computer": "Компьютер",
"MedicalCouch": "Кушетка",
"Locker": "Шкафчик",
"Underwear": "Бельё",
"Socks": "Носки",
"Skirt": "Юбка",
"ShortSkirt": "Короткая юбка",
"Pants": "Штаны",
"Shorts": "Шорты",
"Jeans": "Джинсы",
"Trousers": "Брюки",
"TShirt": "Футболка",
"Shirt": "Рубашка",
"Sweater": "Свитер",
"Jacket": "Куртка",
"Coat": "Пальто",
"FurCoat": "Шуба",
"Shoes": "Обувь",
"WinterBoots": "Зимняя обувь",
"Hat": "Шапка",
"Belt": "Ремень",
"Scarf": "Шарф",
"Dress": "Платье",
"PeShirt": "Футболка для физкультуры",
"PeShorts": "Шорты для физкультуры",
"SchoolBag": "Портфель",
"Textbook": "Учебник",
"Phone": "Телефон",
"Bottle": "Бутылка",
"White": "Белый",
"Black": "Чёрный",
"Gray": "Серый",
"Blue": "Синий",
"LightBlue": "Голубой",
"Red": "Красный",
"Green": "Зелёный",
"Beige": "Бежевый",
"Bottom": "Низ",
"Top": "Верх",
"OverTop": "Поверх",
"Outer": "Верхняя одежда",
"Head": "Голова",
"Accessory": "Аксессуар",
"Principal": "Директор",
"Secretary": "Секретарь",
"Teacher": "Учитель",