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.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 1–12.");
|
||||
}
|
||||
|
||||
if (frame.LessonMinutes is < 1 or > 180)
|
||||
{
|
||||
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' lessonMinutes must be 1–180.");
|
||||
}
|
||||
|
||||
if (frame.BreakMinutes is < 0 or > 60)
|
||||
{
|
||||
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' breakMinutes must be 0–60.");
|
||||
}
|
||||
|
||||
if (frame.LongBreakMinutes is < 0 or > 120)
|
||||
{
|
||||
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakMinutes must be 0–120.");
|
||||
}
|
||||
|
||||
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 1–12.");
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 5–7 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 Mon–Fri; 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user