Enhance school simulation management by introducing a new SchoolWeekDays option, allowing configuration of the number of working days in a week. Update the API to include school week days in responses and enhance documentation to reflect these changes. Revise UI components to support the new management features, ensuring a seamless user experience when hiring and assigning staff. Update localization strings for improved clarity and consistency across the application.
ci / server (push) Failing after 3m37s
ci / client (push) Successful in 13s

This commit is contained in:
Leonid Pershin
2026-08-19 00:47:09 +03:00
parent 94842ab192
commit 2ebc783585
38 changed files with 1837 additions and 208 deletions
+37
View File
@@ -0,0 +1,37 @@
namespace HSchool.Content;
/// <summary>Bells and breaks of one school day. A catalog may have only one concrete frame.</summary>
public sealed class DayFrameDef : Def
{
/// <summary>Local game-clock time of the first lesson, <c>HH:mm</c>.</summary>
public string FirstLesson { get; init; } = "08:30";
public int LessonCount { get; init; }
public int LessonMinutes { get; init; }
public int BreakMinutes { get; init; }
/// <summary>The long break follows this 1-based lesson. Zero means every break is short.</summary>
public int LongBreakAfter { get; init; }
public int LongBreakMinutes { get; init; }
}
public sealed class MonthDay
{
public int Month { get; init; }
public int Day { get; init; }
}
/// <summary>
/// A holiday range in month-day, repeating every academic year. When start is after end
/// (winter), the range wraps across 1 January.
/// </summary>
public sealed class HolidayDef : Def
{
public MonthDay Start { get; init; } = new();
public MonthDay End { get; init; } = new();
}
+10
View File
@@ -309,6 +309,8 @@ public sealed class CatalogLoader
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal);
var subjects = new Dictionary<string, SubjectDef>(StringComparer.Ordinal);
var staffing = new Dictionary<string, StaffingDef>(StringComparer.Ordinal);
var dayFrames = new Dictionary<string, DayFrameDef>(StringComparer.Ordinal);
var holidays = new Dictionary<string, HolidayDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -359,6 +361,12 @@ public sealed class CatalogLoader
case DefKind.Staffing:
staffing[key.Name] = Jsonc.Deserialize<StaffingDef>(json);
break;
case DefKind.DayFrame:
dayFrames[key.Name] = Jsonc.Deserialize<DayFrameDef>(json);
break;
case DefKind.Holiday:
holidays[key.Name] = Jsonc.Deserialize<HolidayDef>(json);
break;
}
}
@@ -379,6 +387,8 @@ public sealed class CatalogLoader
nameSets,
subjects,
staffing,
dayFrames,
holidays,
ru,
en);
}
+15
View File
@@ -23,6 +23,8 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, NameSetDef> nameSets,
IReadOnlyDictionary<string, SubjectDef> subjects,
IReadOnlyDictionary<string, StaffingDef> staffing,
IReadOnlyDictionary<string, DayFrameDef> dayFrames,
IReadOnlyDictionary<string, HolidayDef> holidays,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -42,6 +44,8 @@ public sealed class DefCatalog
NameSets = nameSets;
Subjects = subjects;
Staffing = staffing;
DayFrames = dayFrames;
Holidays = holidays;
_ru = ru;
_en = en;
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
@@ -86,9 +90,16 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, StaffingDef> Staffing { get; }
public IReadOnlyDictionary<string, DayFrameDef> DayFrames { get; }
public IReadOnlyDictionary<string, HolidayDef> Holidays { 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);
/// <summary>The one concrete day frame, or null when a pack has not defined it.</summary>
public DayFrameDef? DayFrame => DayFrames.Values.FirstOrDefault(def => !def.Abstract);
private readonly IReadOnlyDictionary<string, string> _ru;
private readonly IReadOnlyDictionary<string, string> _en;
@@ -111,6 +122,8 @@ public sealed class DefCatalog
DefKind.NameSet => NameSets.GetValueOrDefault(defName),
DefKind.Subject => Subjects.GetValueOrDefault(defName),
DefKind.Staffing => Staffing.GetValueOrDefault(defName),
DefKind.DayFrame => DayFrames.GetValueOrDefault(defName),
DefKind.Holiday => Holidays.GetValueOrDefault(defName),
_ => null,
};
@@ -186,6 +199,8 @@ public sealed class DefCatalog
NameSetDef => DefKind.NameSet,
SubjectDef => DefKind.Subject,
StaffingDef => DefKind.Staffing,
DayFrameDef => DefKind.DayFrame,
HolidayDef => DefKind.Holiday,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
+2
View File
@@ -17,6 +17,8 @@ public enum DefKind
NameSet,
Subject,
Staffing,
DayFrame,
Holiday,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
+6
View File
@@ -107,6 +107,12 @@ internal static class PackPaths
case "staffing":
kind = DefKind.Staffing;
return true;
case "dayframe":
kind = DefKind.DayFrame;
return true;
case "holidays":
kind = DefKind.Holiday;
return true;
default:
kind = default;
return false;
+88
View File
@@ -39,11 +39,26 @@ internal static class PeopleDefValidator
ValidateStaffing(staffing);
}
foreach (var frame in catalog.DayFrames.Values)
{
ValidateDayFrame(frame);
}
foreach (var holiday in catalog.Holidays.Values)
{
ValidateHoliday(holiday);
}
if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1)
{
throw new ContentLoadException("A catalog may only have one concrete StaffingDef.");
}
if (catalog.DayFrames.Values.Count(def => !def.Abstract) > 1)
{
throw new ContentLoadException("A catalog may only have one concrete DayFrameDef.");
}
RequireBuildInputs(catalog);
}
@@ -268,6 +283,16 @@ internal static class PeopleDefValidator
throw new ContentLoadException($"SubjectDef '{subject.DefName}' skill '{share.Skill}' share cannot be negative.");
}
}
if (string.IsNullOrWhiteSpace(subject.Room))
{
return;
}
if (!catalog.Rooms.TryGetValue(subject.Room, out var room) || room.Abstract)
{
throw new ContentLoadException($"SubjectDef '{subject.DefName}' references unknown RoomDef '{subject.Room}'.");
}
}
private static void ValidateStaffing(StaffingDef staffing)
@@ -308,6 +333,69 @@ internal static class PeopleDefValidator
}
}
private static void ValidateDayFrame(DayFrameDef frame)
{
if (frame.Abstract)
{
return;
}
if (!SchoolDay.TryParseTime(frame.FirstLesson, out _))
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' firstLesson '{frame.FirstLesson}' is not a time.");
}
if (frame.LessonCount is < 1 or > 12)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' lessonCount must be 112.");
}
if (frame.LessonMinutes is < 1 or > 180)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' lessonMinutes must be 1180.");
}
if (frame.BreakMinutes is < 0 or > 60)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' breakMinutes must be 060.");
}
if (frame.LongBreakMinutes is < 0 or > 120)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakMinutes must be 0120.");
}
if (frame.LongBreakAfter is < 0 || frame.LongBreakAfter >= frame.LessonCount)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakAfter must be 0 or a lesson before the last.");
}
}
private static void ValidateHoliday(HolidayDef holiday)
{
if (holiday.Abstract)
{
return;
}
ValidateMonthDay(holiday.Start, holiday.DefName, "start");
ValidateMonthDay(holiday.End, holiday.DefName, "end");
}
private static void ValidateMonthDay(MonthDay stamp, string defName, string field)
{
if (stamp.Month is < 1 or > 12)
{
throw new ContentLoadException($"HolidayDef '{defName}' {field} month must be 112.");
}
var days = DateTime.DaysInMonth(2000, stamp.Month);
if (stamp.Day < 1 || stamp.Day > days)
{
throw new ContentLoadException($"HolidayDef '{defName}' {field} day is not valid for that month.");
}
}
private static void ValidateNameSet(NameSetDef names)
{
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
+5
View File
@@ -136,6 +136,11 @@ public sealed class SubjectDef : Def
public int HoursPerWeek { get; init; }
public IReadOnlyList<SubjectSkillShare> Skills { get; init; } = [];
/// <summary>
/// RoomDef the lesson needs. Null means the class's homeroom. PE uses a gym, informatics a lab.
/// </summary>
public string? Room { get; init; }
}
public sealed class TraitSkillModifier
+117
View File
@@ -0,0 +1,117 @@
using System.Globalization;
namespace HSchool.Content;
public enum DaySlotKind
{
Outside,
Lesson,
Break,
}
/// <summary>
/// Where the bells put this instant. <see cref="Index"/> is the 1-based lesson number, or the
/// lesson the current break follows. Zero when the school is closed.
/// </summary>
public readonly record struct DaySlot(DaySlotKind Kind, int Index)
{
public static DaySlot Outside { get; } = new(DaySlotKind.Outside, 0);
public static DaySlot Lesson(int number) => new(DaySlotKind.Lesson, number);
public static DaySlot BreakAfter(int lesson) => new(DaySlotKind.Break, lesson);
}
/// <summary>
/// Turns catalog bells, holidays and the configured week length into "which slot is it now".
/// Week length is a school rule, not a def — pass it in from <c>SimulationOptions</c>.
/// </summary>
public static class SchoolDay
{
public static DaySlot At(DefCatalog catalog, DateTime time, int weekDays)
{
ArgumentNullException.ThrowIfNull(catalog);
if (weekDays is < 5 or > 7)
{
throw new ArgumentOutOfRangeException(nameof(weekDays), weekDays, "School week must be 57 days.");
}
var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc);
if (!IsWeekday(utc, weekDays) || IsHoliday(catalog, utc))
{
return DaySlot.Outside;
}
var frame = catalog.DayFrame;
if (frame is null || !TryParseTime(frame.FirstLesson, out var first))
{
return DaySlot.Outside;
}
var clock = utc.TimeOfDay;
var cursor = first.ToTimeSpan();
var lesson = TimeSpan.FromMinutes(frame.LessonMinutes);
for (var i = 1; i <= frame.LessonCount; i++)
{
var lessonEnd = cursor + lesson;
if (clock >= cursor && clock < lessonEnd)
{
return DaySlot.Lesson(i);
}
cursor = lessonEnd;
if (i == frame.LessonCount)
{
break;
}
var gap = TimeSpan.FromMinutes(i == frame.LongBreakAfter ? frame.LongBreakMinutes : frame.BreakMinutes);
var breakEnd = cursor + gap;
if (clock >= cursor && clock < breakEnd)
{
return DaySlot.BreakAfter(i);
}
cursor = breakEnd;
}
return DaySlot.Outside;
}
public static bool TryParseTime(string text, out TimeOnly value) =>
TimeOnly.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.None, out value);
public static bool Contains(HolidayDef holiday, DateTime date)
{
var day = DateTime.SpecifyKind(date, DateTimeKind.Utc).Date;
var stamp = day.Month * 100 + day.Day;
var start = holiday.Start.Month * 100 + holiday.Start.Day;
var end = holiday.End.Month * 100 + holiday.End.Day;
if (start <= end)
{
return stamp >= start && stamp <= end;
}
return stamp >= start || stamp <= end;
}
private static bool IsWeekday(DateTime time, int weekDays)
{
// Monday = 0 … Sunday = 6. A 5-day week is MonFri; 6 adds Saturday; 7 is every day.
var mondayBased = ((int)time.DayOfWeek + 6) % 7;
return mondayBased < weekDays;
}
private static bool IsHoliday(DefCatalog catalog, DateTime time)
{
foreach (var holiday in catalog.Holidays.Values)
{
if (!holiday.Abstract && Contains(holiday, time))
{
return true;
}
}
return false;
}
}