From abcf67eaab948eea451dfadc6f9e7834509c8808 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 02:47:23 +0300 Subject: [PATCH] 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 --- docs/phases/30-apparel-defs.md | 26 +-- src/HSchool.Content/ApparelDefs.cs | 56 +++++ src/HSchool.Content/CatalogLoader.cs | 107 ++++++++- src/HSchool.Content/DefCatalog.cs | 6 + src/HSchool.Content/Defs.cs | 31 +++ src/HSchool.Content/MapValidator.cs | 5 + src/HSchool.Content/PackPaths.cs | 3 + src/HSchool.Server/Api/ModEndpoints.cs | 2 +- .../mods/core/defs/colors/palette.jsonc | 10 + .../mods/core/defs/things/apparel.jsonc | 210 ++++++++++++++++++ .../mods/core/defs/things/carry.jsonc | 6 + .../mods/core/localizations/en.jsonc | 40 ++++ .../mods/core/localizations/ru.jsonc | 40 ++++ tests/HSchool.AppHost.Tests/SchoolApiTests.cs | 3 + .../HSchool.Content.Tests/ApparelDefTests.cs | 169 ++++++++++++++ .../HSchool.Content.Tests/VanillaCoreTests.cs | 17 ++ 16 files changed, 716 insertions(+), 15 deletions(-) create mode 100644 src/HSchool.Content/ApparelDefs.cs create mode 100644 src/HSchool.Server/mods/core/defs/colors/palette.jsonc create mode 100644 src/HSchool.Server/mods/core/defs/things/apparel.jsonc create mode 100644 src/HSchool.Server/mods/core/defs/things/carry.jsonc create mode 100644 tests/HSchool.Content.Tests/ApparelDefTests.cs diff --git a/docs/phases/30-apparel-defs.md b/docs/phases/30-apparel-defs.md index 311b5c6..c82d0c3 100644 --- a/docs/phases/30-apparel-defs.md +++ b/docs/phases/30-apparel-defs.md @@ -10,28 +10,28 @@ ## Задачи -- [ ] Поля `ThingDef`: `layers`, `portable`, `mass`, `insulation`, `formality`, `age`, `sex`, +- [x] Поля `ThingDef`: `layers`, `portable`, `mass`, `insulation`, `formality`, `age`, `sex`, `colors`, `skirtLength`, `pe`. Пустые `layers` — прежняя мебель, карта не ломается -- [ ] Слои — известный набор в коде (`Underwear` … `Accessory`), как роли. Неизвестный слой — +- [x] Слои — известный набор в коде (`Underwear` … `Accessory`), как роли. Неизвестный слой — ошибка загрузки -- [ ] `ColorDef`: id и теги (`neutral`, `bright`, `school`). Ссылка вещи на неизвестный цвет — +- [x] `ColorDef`: id и теги (`neutral`, `bright`, `school`). Ссылка вещи на неизвестный цвет — ошибка -- [ ] Вещь с несколькими `layers` допустима (платье). Пересечение слоёв у двух надетых сразу +- [x] Вещь с несколькими `layers` допустима (платье). Пересечение слоёв у двух надетых сразу валидатор не проверяет — это состояние человека, не каталога -- [ ] Ваниль по [`../design/inventory.md`](../design/inventory.md): бельё, носки, низы, верх, +- [x] Ваниль по [`../design/inventory.md`](../design/inventory.md): бельё, носки, низы, верх, свитер, куртка/пальто/шуба, обувь, шапка, аксессуары, платье, форма физкультуры, портфель, учебник, телефон, бутылка -- [ ] Палитра цветов и локали ru/en на все новые def -- [ ] Учебник — один `ThingDef` без зашитого предмета; предмет живёт на экземпляре в фазе 31 -- [ ] Мебель на карте по-прежнему без `layers`; редактор не предлагает надеть стул +- [x] Палитра цветов и локали ru/en на все новые def +- [x] Учебник — один `ThingDef` без зашитого предмета; предмет живёт на экземпляре в фазе 31 +- [x] Мебель на карте по-прежнему без `layers`; редактор не предлагает надеть стул ## Тесты, без которых фаза не закрыта -- [ ] `Chair` без новых полей грузится как раньше -- [ ] Платье с `layers: ["Bottom", "Top"]` проходит; неизвестный слой — ошибка -- [ ] Футболка ссылается на `Red`; отсутствие `Red` в палитре — ошибка -- [ ] Тест полноты локалей зелёный на новый набор -- [ ] Ванильная карта и каталог предметов (мебель) не изменились по вместимости +- [x] `Chair` без новых полей грузится как раньше +- [x] Платье с `layers: ["Bottom", "Top"]` проходит; неизвестный слой — ошибка +- [x] Футболка ссылается на `Red`; отсутствие `Red` в палитре — ошибка +- [x] Тест полноты локалей зелёный на новый набор +- [x] Ванильная карта и каталог предметов (мебель) не изменились по вместимости ## Критерий готовности diff --git a/src/HSchool.Content/ApparelDefs.cs b/src/HSchool.Content/ApparelDefs.cs new file mode 100644 index 0000000..d3d421e --- /dev/null +++ b/src/HSchool.Content/ApparelDefs.cs @@ -0,0 +1,56 @@ +namespace HSchool.Content; + +/// +/// Layers apparel occupies. Code, not a def — new layers are a rebuild, like +/// . +/// +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 All = + [Underwear, Socks, Bottom, Top, OverTop, Outer, Shoes, Head, Accessory]; + + public static bool IsKnown(string layer) => + All.Any(candidate => candidate.Equals(layer, StringComparison.Ordinal)); +} + +/// +/// ColorDef appropriateness tags. School rules filter these, not color names. +/// +public static class ColorTags +{ + public const string Neutral = "neutral"; + public const string Bright = "bright"; + public const string School = "school"; + + public static readonly IReadOnlyList All = [Neutral, Bright, School]; + + public static bool IsKnown(string tag) => + All.Any(candidate => candidate.Equals(tag, StringComparison.Ordinal)); +} + +/// Bottom length for the short-form dress code. Omit on things that have no hem. +public static class SkirtLengths +{ + public const string Regular = "regular"; + public const string Short = "short"; + + public static readonly IReadOnlyList All = [Regular, Short]; + + public static bool IsKnown(string length) => + All.Any(candidate => candidate.Equals(length, StringComparison.Ordinal)); +} + +public sealed class ColorDef : Def +{ + public IReadOnlyList Tags { get; init; } = []; +} diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index c7f0c38..f5a5c0d 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -313,6 +313,7 @@ public sealed class CatalogLoader var dayFrames = new Dictionary(StringComparer.Ordinal); var holidays = new Dictionary(StringComparer.Ordinal); var behavior = new Dictionary(StringComparer.Ordinal); + var colors = new Dictionary(StringComparer.Ordinal); foreach (var (key, json) in resolved) { @@ -372,6 +373,9 @@ public sealed class CatalogLoader case DefKind.Behavior: behavior[key.Name] = Jsonc.Deserialize(json); break; + case DefKind.Color: + colors[key.Name] = Jsonc.Deserialize(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 Enumerate(IEnumerable defs) => defs.Where(def => !def.Abstract); } + private static void ValidateColor(ColorDef color) + { + var seen = new HashSet(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(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(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); } diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs index fd40997..5a54f9a 100644 --- a/src/HSchool.Content/DefCatalog.cs +++ b/src/HSchool.Content/DefCatalog.cs @@ -26,6 +26,7 @@ public sealed class DefCatalog IReadOnlyDictionary dayFrames, IReadOnlyDictionary holidays, IReadOnlyDictionary behavior, + IReadOnlyDictionary colors, IReadOnlyDictionary ru, IReadOnlyDictionary 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 Behavior { get; } + public IReadOnlyDictionary Colors { get; } + /// The one concrete staffing ruleset, or null when a pack has not defined it. 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)), }; diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index d2905d7..303dc69 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -20,6 +20,7 @@ public enum DefKind DayFrame, Holiday, Behavior, + Color, } /// Shared JSONC fields. Kind comes from the folder under defs/, not from the file. @@ -72,6 +73,36 @@ public sealed class ThingDef : Def /// must not inflate class size just because someone sits in it. /// public int PupilSlots { get; init; } + + /// Layers this occupies when worn. Empty — not apparel, so it can sit on the map. + public IReadOnlyList Layers { get; init; } = []; + + /// Can live in a bag, locker or at home. Furniture stays false. + public bool Portable { get; init; } + + /// Kilograms. Carried mass sums; worn mass does not in this slice. + public float Mass { get; init; } + + /// How much it warms — and how much it steams in heat. + public float Insulation { get; init; } + + /// 0–100. Dress-code formality, not a fashion score. + public int Formality { get; init; } + + /// Allowed age window. Omit for any age. + public IntRange? Age { get; init; } + + /// male, female, or omit for any. + public string? Sex { get; init; } + + /// ColorDef ids this thing may be. Empty — the instance has no colour. + public IReadOnlyList Colors { get; init; } = []; + + /// id, or omit when there is no hem to measure. + public string? SkirtLength { get; init; } + + /// PE kit. Worn for that lesson; not a second wardrobe of everyday clothes. + public bool Pe { get; init; } } public sealed class PositionDef : Def; diff --git a/src/HSchool.Content/MapValidator.cs b/src/HSchool.Content/MapValidator.cs index 65542e8..d05a1f2 100644 --- a/src/HSchool.Content/MapValidator.cs +++ b/src/HSchool.Content/MapValidator.cs @@ -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}."); diff --git a/src/HSchool.Content/PackPaths.cs b/src/HSchool.Content/PackPaths.cs index ae043df..e463898 100644 --- a/src/HSchool.Content/PackPaths.cs +++ b/src/HSchool.Content/PackPaths.cs @@ -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; diff --git a/src/HSchool.Server/Api/ModEndpoints.cs b/src/HSchool.Server/Api/ModEndpoints.cs index 038ff60..de8ae0d 100644 --- a/src/HSchool.Server/Api/ModEndpoints.cs +++ b/src/HSchool.Server/Api/ModEndpoints.cs @@ -167,7 +167,7 @@ internal sealed record CatalogResponse( private static IReadOnlyList 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(); diff --git a/src/HSchool.Server/mods/core/defs/colors/palette.jsonc b/src/HSchool.Server/mods/core/defs/colors/palette.jsonc new file mode 100644 index 0000000..a99323e --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/colors/palette.jsonc @@ -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" }, +] diff --git a/src/HSchool.Server/mods/core/defs/things/apparel.jsonc b/src/HSchool.Server/mods/core/defs/things/apparel.jsonc new file mode 100644 index 0000000..70fe54f --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/things/apparel.jsonc @@ -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"], + }, +] diff --git a/src/HSchool.Server/mods/core/defs/things/carry.jsonc b/src/HSchool.Server/mods/core/defs/things/carry.jsonc new file mode 100644 index 0000000..cdb150f --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/things/carry.jsonc @@ -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"] }, +] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 55b500f..aea211c 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -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", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 066a13d..03e6b97 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -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": "Учитель", diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index cef7a56..fe2c076 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -303,6 +303,9 @@ public class SchoolApiTests(AppHostFixture fixture) Assert.Equal("GymHall", Assert.Single(ru.Subjects, subject => subject.DefName == "PhysicalEducation").Room); Assert.Null(Assert.Single(ru.Subjects, subject => subject.DefName == "Mathematics").Room); Assert.Contains(ru.Holidays, holiday => holiday.DefName == "SpringBreak"); + Assert.Contains(ru.Things, thing => thing.DefName == "Chair"); + Assert.DoesNotContain(ru.Things, thing => thing.DefName == "TShirt"); + Assert.DoesNotContain(ru.Things, thing => thing.DefName == "Textbook"); } [Fact] diff --git a/tests/HSchool.Content.Tests/ApparelDefTests.cs b/tests/HSchool.Content.Tests/ApparelDefTests.cs new file mode 100644 index 0000000..5129e07 --- /dev/null +++ b/tests/HSchool.Content.Tests/ApparelDefTests.cs @@ -0,0 +1,169 @@ +namespace HSchool.Content.Tests; + +public class ApparelDefTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void ChairWithoutApparelFields_LoadsAsFurniture() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "actions", + "sit", + """{ "defName": "Sit", "abstract": true }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "chair", + """{ "defName": "Chair", "actions": ["Sit"] }"""), + ]); + + var chair = catalog.Things["Chair"]; + Assert.Equal(["Sit"], chair.Actions); + Assert.Empty(chair.Layers); + Assert.False(chair.Portable); + Assert.Equal(0, chair.Mass); + Assert.Equal(0, chair.Insulation); + Assert.Equal(0, chair.Formality); + Assert.Null(chair.Age); + Assert.Null(chair.Sex); + Assert.Empty(chair.Colors); + Assert.Null(chair.SkirtLength); + Assert.False(chair.Pe); + Assert.Equal(0, chair.PupilSlots); + } + + [Fact] + public void DressOccupyingTwoLayers_Loads() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "dress", + """{ "defName": "Dress", "layers": ["Bottom", "Top"], "portable": true }"""), + ]); + + Assert.Equal([ApparelLayers.Bottom, ApparelLayers.Top], catalog.Things["Dress"].Layers); + Assert.True(catalog.Things["Dress"].Portable); + } + + [Fact] + public void UnknownLayer_FailsTheCatalog() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "cape", + """{ "defName": "Cape", "layers": ["Cloak"] }"""), + ])); + + Assert.Contains("Cloak", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void TwoTopsInTheCatalog_DoNotFailLoad() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "shirt", + """{ "defName": "Shirt", "layers": ["Top"] }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "tshirt", + """{ "defName": "TShirt", "layers": ["Top"] }"""), + ]); + + Assert.Equal([ApparelLayers.Top], catalog.Things["Shirt"].Layers); + Assert.Equal([ApparelLayers.Top], catalog.Things["TShirt"].Layers); + } + + [Fact] + public void TShirtReferencingRed_LoadsWhenThePaletteHasRed() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "colors", + "red", + """{ "defName": "Red", "tags": ["bright"] }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "tshirt", + """{ "defName": "TShirt", "layers": ["Top"], "colors": ["Red"] }"""), + ]); + + Assert.Equal(["Red"], catalog.Things["TShirt"].Colors); + Assert.Equal([ColorTags.Bright], catalog.Colors["Red"].Tags); + } + + [Fact] + public void TShirtReferencingMissingRed_FailsTheCatalog() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "tshirt", + """{ "defName": "TShirt", "layers": ["Top"], "colors": ["Red"] }"""), + ])); + + Assert.Contains("Red", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void UnknownColorTag_FailsTheCatalog() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "colors", + "neon", + """{ "defName": "Neon", "tags": ["glow"] }"""), + ])); + + Assert.Contains("glow", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void RoomSlotWithApparel_FailsTheCatalog() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "things", + "tshirt", + """{ "defName": "TShirt", "layers": ["Top"] }"""), + PackDocuments.Def( + CatalogLoader.CorePackId, + "rooms", + "office", + """{ "defName": "Office", "slots": [{ "key": "seat", "thing": "TShirt" }], "travelMinutes": 1 }"""), + ])); + + Assert.Contains("TShirt", ex.Message, StringComparison.Ordinal); + } +} diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 488c1c6..7866f3e 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -37,6 +37,18 @@ public class VanillaCoreTests Assert.Equal(1, catalog.Things["Computer"].PupilSlots); Assert.Equal(0, catalog.Things["Desk"].PupilSlots); Assert.Equal(0, catalog.Things["Chair"].PupilSlots); + Assert.Empty(catalog.Things["Chair"].Layers); + Assert.False(catalog.Things["Chair"].Portable); + Assert.Empty(catalog.Things["StudentDesk"].Layers); + Assert.Equal([ApparelLayers.Bottom, ApparelLayers.Top], catalog.Things["Dress"].Layers); + Assert.Contains("Red", catalog.Things["TShirt"].Colors); + Assert.True(catalog.Things["PeShirt"].Pe); + Assert.True(catalog.Things["Textbook"].Portable); + Assert.Empty(catalog.Things["Textbook"].Layers); + Assert.Equal(1, catalog.Things.Values.Count(thing => thing.DefName == "Textbook")); + Assert.Equal([ColorTags.Bright], catalog.Colors["Red"].Tags); + Assert.Equal([ColorTags.Neutral, ColorTags.School], catalog.Colors["White"].Tags); + Assert.Empty(catalog.Colors["Beige"].Tags); Assert.True(catalog.Subjects.ContainsKey("PrimarySchool")); Assert.Equal(1, catalog.Subjects["PrimarySchool"].Grades.Min); Assert.Equal(4, catalog.Subjects["PrimarySchool"].Grades.Max); @@ -57,6 +69,9 @@ public class VanillaCoreTests Assert.DoesNotContain(map.Rooms, room => room.Label is "1A" or "1B" or "2A" or "2B"); Assert.Equal(16, map.Rooms.Single(room => room.Id == "classroom-101").Seats); Assert.Empty(map.Rooms.Single(room => room.Id == "classroom-101").Slots); + Assert.Equal( + 2, + catalog.Things.Values.Count(thing => !thing.Abstract && thing.PupilSlots > 0)); Assert.Equal(0.5f, catalog.Rooms["Classroom"].TravelMinutes); Assert.Equal(1.5f, catalog.Rooms["Corridor"].TravelMinutes); Assert.Equal(1.5f, catalog.Rooms["Stairwell"].TravelMinutes); @@ -119,11 +134,13 @@ public class VanillaCoreTests keys.AddRange(Names(catalog.DayFrames.Values)); keys.AddRange(Names(catalog.Holidays.Values)); keys.AddRange(Names(catalog.Behavior.Values)); + keys.AddRange(Names(catalog.Colors.Values)); keys.AddRange(catalog.PackIds); // Derived in code, so no def carries them. keys.Add(BodyBuilds.Attribute); keys.AddRange(BodyBuilds.Values); + keys.AddRange(ApparelLayers.All); foreach (var value in catalog.BodyAttributes.Values.SelectMany(def => def.Options)) {