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))
{