This commit is contained in:
Leonid Pershin
2026-08-18 23:51:10 +03:00
parent d8f8db6a48
commit 65feda3756
50 changed files with 1485 additions and 224 deletions
+32
View File
@@ -307,6 +307,7 @@ public sealed class CatalogLoader
var bodyAttributes = new Dictionary<string, BodyAttributeDef>(StringComparer.Ordinal);
var needs = new Dictionary<string, NeedDef>(StringComparer.Ordinal);
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal);
var subjects = new Dictionary<string, SubjectDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -351,6 +352,9 @@ public sealed class CatalogLoader
case DefKind.NameSet:
nameSets[key.Name] = Jsonc.Deserialize<NameSetDef>(json);
break;
case DefKind.Subject:
subjects[key.Name] = Jsonc.Deserialize<SubjectDef>(json);
break;
}
}
@@ -369,6 +373,7 @@ public sealed class CatalogLoader
bodyAttributes,
needs,
nameSets,
subjects,
ru,
en);
}
@@ -421,6 +426,33 @@ public sealed class CatalogLoader
throw new ContentLoadException($"RoomDef '{room.DefName}' references unknown WorkDef '{work}'.");
}
}
if (room.Homeroom)
{
if (room.Slots.Count > 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' is a homeroom and cannot declare named slots.");
}
if (string.IsNullOrWhiteSpace(room.SeatThing) || !catalog.Things.TryGetValue(room.SeatThing, out var seat) || seat.Abstract)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' seatThing is missing or unknown.");
}
if (seat.PupilSlots <= 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' seatThing '{room.SeatThing}' must have pupilSlots.");
}
if (room.DefaultSeats < 1 || room.DefaultSeats > byte.MaxValue)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' defaultSeats must be 1{byte.MaxValue}.");
}
}
else if (!string.IsNullOrWhiteSpace(room.SeatThing) || room.DefaultSeats != 0)
{
throw new ContentLoadException($"RoomDef '{room.DefName}' is not a homeroom and cannot set seatThing or defaultSeats.");
}
}
PeopleDefValidator.Validate(catalog);
+13
View File
@@ -21,6 +21,7 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, BodyAttributeDef> bodyAttributes,
IReadOnlyDictionary<string, NeedDef> needs,
IReadOnlyDictionary<string, NameSetDef> nameSets,
IReadOnlyDictionary<string, SubjectDef> subjects,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -38,6 +39,7 @@ public sealed class DefCatalog
BodyAttributes = bodyAttributes;
Needs = needs;
NameSets = nameSets;
Subjects = subjects;
_ru = ru;
_en = en;
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
@@ -78,6 +80,8 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, NameSetDef> NameSets { get; }
public IReadOnlyDictionary<string, SubjectDef> Subjects { get; }
private readonly IReadOnlyDictionary<string, string> _ru;
private readonly IReadOnlyDictionary<string, string> _en;
@@ -98,6 +102,7 @@ public sealed class DefCatalog
DefKind.BodyAttribute => BodyAttributes.GetValueOrDefault(defName),
DefKind.Need => Needs.GetValueOrDefault(defName),
DefKind.NameSet => NameSets.GetValueOrDefault(defName),
DefKind.Subject => Subjects.GetValueOrDefault(defName),
_ => null,
};
@@ -143,6 +148,13 @@ public sealed class DefCatalog
/// </summary>
public string Text(string locale, string key) => TryText(locale, key, out var text) ? text : key;
/// <summary>
/// Whether this locale actually carries the key. <see cref="Text"/> falls back to the key
/// itself, which reads as a label in English and as debug output in Russian — so a test that
/// wants to prove nothing is unlabelled has to ask this instead of comparing strings.
/// </summary>
public bool HasText(string locale, string key) => TryText(locale, key, out _);
private bool TryText(string locale, string key, out string text)
{
var table = locale.Equals("en", StringComparison.OrdinalIgnoreCase) ? _en : _ru;
@@ -164,6 +176,7 @@ public sealed class DefCatalog
BodyAttributeDef => DefKind.BodyAttribute,
NeedDef => DefKind.Need,
NameSetDef => DefKind.NameSet,
SubjectDef => DefKind.Subject,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
+8
View File
@@ -15,6 +15,7 @@ public enum DefKind
BodyAttribute,
Need,
NameSet,
Subject,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
@@ -64,8 +65,15 @@ public sealed class RoomDef : Def
/// <summary>
/// When true, a map room of this def with pupil slots becomes a roster class.
/// Labs keep <see cref="ThingDef.PupilSlots"/> for lessons without forming a homeroom.
/// Homerooms are a seat count of <see cref="SeatThing"/>, not a table of named slots.
/// </summary>
public bool Homeroom { get; init; }
/// <summary>Thing whose <see cref="ThingDef.PupilSlots"/> × map seats is class capacity.</summary>
public string? SeatThing { get; init; }
/// <summary>Editor default when placing a new homeroom. Vanilla classrooms are 16.</summary>
public int DefaultSeats { get; init; }
}
public sealed class BuildingDef : Def;
+6
View File
@@ -55,6 +55,12 @@ public sealed class RoomNode
/// <summary>Optional designation such as a classroom number. The def label still supplies the noun.</summary>
public string? Label { get; init; }
/// <summary>
/// Homeroom seat count. Capacity is <c>SeatThing.PupilSlots × Seats</c>. Named
/// <see cref="Slots"/> stay on rooms whose furnishing actually varies.
/// </summary>
public int Seats { get; init; }
public IReadOnlyList<SlotFill> Slots { get; init; } = [];
}
+22 -2
View File
@@ -64,7 +64,7 @@ public static class MapValidator
throw new MapValidationException($"Room '{room.Id}' is on floor '{room.Floor}', which belongs to another building.");
}
ValidateSlotFills(room, catalog);
ValidateRoomContents(room, catalog);
rooms[room.Id] = room;
}
@@ -121,13 +121,33 @@ public static class MapValidator
}
}
private static void ValidateSlotFills(RoomNode room, DefCatalog catalog)
private static void ValidateRoomContents(RoomNode room, DefCatalog catalog)
{
if (!catalog.Rooms.TryGetValue(room.Def, out var roomDef))
{
return;
}
if (roomDef.Homeroom)
{
if (room.Slots.Count > 0)
{
throw new MapValidationException($"Homeroom '{room.Id}' cannot fill named slots.");
}
if (room.Seats < 1 || room.Seats > byte.MaxValue)
{
throw new MapValidationException($"Homeroom '{room.Id}' seats must be 1{byte.MaxValue}.");
}
return;
}
if (room.Seats != 0)
{
throw new MapValidationException($"Room '{room.Id}' is not a homeroom and cannot set seats.");
}
var keys = roomDef.Slots.Select(slot => slot.Key).ToHashSet(StringComparer.Ordinal);
foreach (var fill in room.Slots)
{
+14 -13
View File
@@ -121,23 +121,24 @@ public static class MapView
string locale,
RoomNode room)
{
var items = new List<MapViewItem>(room.Slots.Count);
var pupilSlots = 0L;
foreach (var fill in room.Slots)
if (catalog.Rooms.TryGetValue(room.Def, out var def) && def.Homeroom && !string.IsNullOrWhiteSpace(def.SeatThing))
{
var count = SlotQuantity(fill.Count);
items.Add(new MapViewItem(LabelOf(catalog, locale, DefKind.Thing, fill.Thing), count));
if (catalog.Things.TryGetValue(fill.Thing, out var thing) && thing.PupilSlots > 0)
{
pupilSlots += (long)thing.PupilSlots * count;
}
var seats = RoomOccupancy.Quantity(room.Seats > 0 ? room.Seats : def.DefaultSeats);
return (
[new MapViewItem(LabelOf(catalog, locale, DefKind.Thing, def.SeatThing), seats)],
RoomOccupancy.PupilSlots(catalog, room));
}
return (items, (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue));
}
var items = new List<MapViewItem>(room.Slots.Count);
foreach (var fill in room.Slots)
{
items.Add(new MapViewItem(
LabelOf(catalog, locale, DefKind.Thing, fill.Thing),
RoomOccupancy.Quantity(fill.Count)));
}
private static int SlotQuantity(int count) =>
count < 1 ? 1 : Math.Min(count, byte.MaxValue);
return (items, RoomOccupancy.PupilSlots(catalog, room));
}
/// <summary>
/// A floor's <see cref="FloorNode.Label"/> is its designation, not a replacement name — the
+3
View File
@@ -101,6 +101,9 @@ internal static class PackPaths
case "namesets":
kind = DefKind.NameSet;
return true;
case "subjects":
kind = DefKind.Subject;
return true;
default:
kind = default;
return false;
+42
View File
@@ -29,6 +29,11 @@ internal static class PeopleDefValidator
ValidateNameSet(names);
}
foreach (var subject in catalog.Subjects.Values)
{
ValidateSubject(subject, catalog);
}
RequireBuildInputs(catalog);
}
@@ -218,6 +223,43 @@ internal static class PeopleDefValidator
}
}
private static void ValidateSubject(SubjectDef subject, DefCatalog catalog)
{
if (subject.HoursPerWeek < 0)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' hoursPerWeek cannot be negative.");
}
if (subject.Grades.Min < 1 || subject.Grades.Max > 11 || subject.Grades.Min > subject.Grades.Max)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' grades must be 111 with min ≤ max.");
}
if (subject.Skills.Count == 0)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' needs at least one skill.");
}
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var share in subject.Skills)
{
if (string.IsNullOrWhiteSpace(share.Skill) || !seen.Add(share.Skill))
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' has a missing or duplicate skill.");
}
if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' references unknown SkillDef '{share.Skill}'.");
}
if (share.Share < 0)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' skill '{share.Skill}' share cannot be negative.");
}
}
}
private static void ValidateNameSet(NameSetDef names)
{
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
+16
View File
@@ -122,6 +122,22 @@ public sealed class SkillDef : Def
public IReadOnlyList<BodySkillLimit> BodyLimits { get; init; } = [];
}
public sealed class SubjectSkillShare
{
public required string Skill { get; init; }
public float Share { get; init; }
}
public sealed class SubjectDef : Def
{
public IntRange Grades { get; init; } = new() { Min = 1, Max = 11 };
public int HoursPerWeek { get; init; }
public IReadOnlyList<SubjectSkillShare> Skills { get; init; } = [];
}
public sealed class TraitSkillModifier
{
public required string Skill { get; init; }
+38
View File
@@ -0,0 +1,38 @@
namespace HSchool.Content;
/// <summary>
/// How many pupils a map room can host. Homerooms are a seat count of one thing; other rooms
/// still sum <c>ThingDef.PupilSlots × fill count</c> across named slots.
/// </summary>
public static class RoomOccupancy
{
public static int Quantity(int count) =>
count < 1 ? 1 : Math.Min(count, byte.MaxValue);
public static int PupilSlots(DefCatalog catalog, RoomNode room)
{
if (catalog.Rooms.TryGetValue(room.Def, out var def) && def.Homeroom)
{
if (string.IsNullOrWhiteSpace(def.SeatThing)
|| !catalog.Things.TryGetValue(def.SeatThing, out var seat)
|| seat.PupilSlots <= 0)
{
return 0;
}
var seats = room.Seats > 0 ? Quantity(room.Seats) : Quantity(def.DefaultSeats);
return (int)Math.Clamp((long)seat.PupilSlots * seats, 0, ushort.MaxValue);
}
var pupilSlots = 0L;
foreach (var fill in room.Slots)
{
if (catalog.Things.TryGetValue(fill.Thing, out var thing) && thing.PupilSlots > 0)
{
pupilSlots += (long)thing.PupilSlots * Quantity(fill.Count);
}
}
return (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue);
}
}