Enhance school day structure and decision-making for lunch breaks
- Updated `ai.md` to clarify the mechanics of hunger restoration and the importance of lunch breaks in the school schedule. - Revised `schedule.md` to detail the new lunch break structure, allowing for separate sittings for different grade levels. - Enhanced `Decision.cs` and `DecisionPlanner.cs` to incorporate logic for lunch breaks, ensuring that students only leave lessons during their designated lunch windows. - Updated `DayFrameDef` and related classes to support multiple lunch breaks and validate their configurations. - Adjusted tests to validate the new decision-making logic regarding lunch breaks and hunger management, ensuring robust functionality. - Improved localization strings to reflect changes in the school day structure and lunch functionalities.
This commit is contained in:
@@ -38,7 +38,8 @@ public readonly record struct ActorState(
|
||||
bool BoundToLesson,
|
||||
string? DutyRoom,
|
||||
IReadOnlyDictionary<string, float> Needs,
|
||||
Intent Intent);
|
||||
Intent Intent,
|
||||
bool LunchWindowOpen = false);
|
||||
|
||||
/// <summary>
|
||||
/// Picks a goal by weight and plans walk-then-do. No world, no clock — a table of inputs to an
|
||||
@@ -56,6 +57,14 @@ public static class DecisionPlanner
|
||||
/// <summary>Need at zero. Beats a lesson so a desperate toilet trip leaves class.</summary>
|
||||
public const float NeedWeightAtZero = 20f;
|
||||
|
||||
/// <summary>
|
||||
/// A sitting during this parallel's own lunch break. Above <see cref="DutyTravelWeight"/> so
|
||||
/// lunch beats walking on to the next room, below <see cref="DutyLessonWeight"/> so it never
|
||||
/// pulls anybody out of a lesson. Lunch is a timetable, not an urge: waiting for hunger to
|
||||
/// cross the threshold made the juniors miss their sitting and starve all afternoon.
|
||||
/// </summary>
|
||||
public const float LunchWeight = 6f;
|
||||
|
||||
public static Decision Decide(
|
||||
DefCatalog catalog,
|
||||
MapLayout map,
|
||||
@@ -193,19 +202,33 @@ public static class DecisionPlanner
|
||||
NeedDef need,
|
||||
float threshold)
|
||||
{
|
||||
if (need.Abstract || !state.Needs.TryGetValue(need.DefName, out var value) || value >= threshold)
|
||||
if (need.Abstract || !state.Needs.TryGetValue(need.DefName, out var value))
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
var urgent = value < threshold;
|
||||
|
||||
// ActionForNeed already refuses a sitting outside its window, so an action that comes back
|
||||
// Lunch means this person's own break is open right now.
|
||||
var action = ActionForNeed(catalog, state, need.DefName);
|
||||
if (action is null || RoomFor(catalog, map, walks, state, occupied, action) is null)
|
||||
if (action is null || (!urgent && !action.Lunch))
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
if (RoomFor(catalog, map, walks, state, occupied, action) is null)
|
||||
{
|
||||
return Intent.None;
|
||||
}
|
||||
|
||||
var span = Math.Max(threshold, 0.0001f);
|
||||
var weight = (threshold - value) / span * NeedWeightAtZero;
|
||||
var weight = urgent ? (threshold - value) / span * NeedWeightAtZero : 0f;
|
||||
if (action.Lunch)
|
||||
{
|
||||
weight = Math.Max(weight, LunchWeight);
|
||||
}
|
||||
|
||||
return new Intent(GoalKind.Need, need.DefName, weight, action.DefName);
|
||||
}
|
||||
|
||||
@@ -312,6 +335,14 @@ public static class DecisionPlanner
|
||||
continue;
|
||||
}
|
||||
|
||||
// A sitting is only on offer during this person's own lunch break. Outside it hunger
|
||||
// keeps building instead of pulling somebody out of a lesson — that is what keeps the
|
||||
// canteen from filling with the whole school at once.
|
||||
if (action.Lunch && !state.LunchWindowOpen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (best is null || action.NeedGain > best.NeedGain)
|
||||
{
|
||||
best = action;
|
||||
|
||||
@@ -76,8 +76,6 @@ const ru = {
|
||||
resume: 'Продолжить',
|
||||
|
||||
mapTitle: 'Карта',
|
||||
eventsTitle: 'События',
|
||||
eventsEmpty: 'Пока ничего не происходит.',
|
||||
locationName: 'Локация',
|
||||
personTitle: 'Человек',
|
||||
locationItems: 'Предметы',
|
||||
@@ -288,8 +286,6 @@ const en: Messages = {
|
||||
resume: 'Resume',
|
||||
|
||||
mapTitle: 'Map',
|
||||
eventsTitle: 'Events',
|
||||
eventsEmpty: 'Nothing is happening yet.',
|
||||
locationName: 'Location',
|
||||
personTitle: 'Person',
|
||||
locationItems: 'Items',
|
||||
|
||||
@@ -16,6 +16,26 @@ public sealed class DayFrameDef : Def
|
||||
public int LongBreakAfter { get; init; }
|
||||
|
||||
public int LongBreakMinutes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which break each parallel eats at. Empty means one sitting for the whole school, at
|
||||
/// <see cref="LongBreakAfter"/>. Every listed break is a long one, so a school that feeds its
|
||||
/// juniors and seniors separately gets two wide breaks rather than one crowded canteen.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LunchBreakDef> LunchBreaks { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>One lunch sitting: the break it happens at and the parallels it feeds.</summary>
|
||||
public sealed class LunchBreakDef
|
||||
{
|
||||
/// <summary>The sitting fills the break after this 1-based lesson.</summary>
|
||||
public int AfterLesson { get; init; }
|
||||
|
||||
public int GradeMin { get; init; }
|
||||
|
||||
public int GradeMax { get; init; }
|
||||
|
||||
public bool Covers(int year) => year >= GradeMin && year <= GradeMax;
|
||||
}
|
||||
|
||||
public sealed class MonthDay
|
||||
|
||||
@@ -54,6 +54,13 @@ public sealed class ActionDef : Def
|
||||
|
||||
/// <summary>Leisure weight. Zero means phase 21 will not pick this for fun — only for a need.</summary>
|
||||
public float Weight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A sitting rather than something done whenever the need bites: only offered during the
|
||||
/// eater's own lunch break (<see cref="DayFrameDef.LunchBreaks"/>). Without it a hungry class
|
||||
/// would walk out of a lesson to the canteen, and the whole school would arrive at once.
|
||||
/// </summary>
|
||||
public bool Lunch { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ThingDef : Def
|
||||
|
||||
@@ -391,6 +391,31 @@ internal static class PeopleDefValidator
|
||||
{
|
||||
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakAfter must be 0 or a lesson before the last.");
|
||||
}
|
||||
|
||||
var fed = new HashSet<int>();
|
||||
foreach (var sitting in frame.LunchBreaks)
|
||||
{
|
||||
if (sitting.AfterLesson < 1 || sitting.AfterLesson >= frame.LessonCount)
|
||||
{
|
||||
throw new ContentLoadException(
|
||||
$"DayFrameDef '{frame.DefName}' has a lunch break after lesson {sitting.AfterLesson}; it must be a lesson before the last.");
|
||||
}
|
||||
|
||||
if (sitting.GradeMin < 1 || sitting.GradeMax < sitting.GradeMin)
|
||||
{
|
||||
throw new ContentLoadException(
|
||||
$"DayFrameDef '{frame.DefName}' has a lunch break with grades {sitting.GradeMin}–{sitting.GradeMax}.");
|
||||
}
|
||||
|
||||
for (var year = sitting.GradeMin; year <= sitting.GradeMax; year++)
|
||||
{
|
||||
if (!fed.Add(year))
|
||||
{
|
||||
throw new ContentLoadException(
|
||||
$"DayFrameDef '{frame.DefName}' feeds grade {year} at two lunch breaks.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateAction(ActionDef action, DefCatalog catalog)
|
||||
|
||||
@@ -65,7 +65,7 @@ public static class SchoolDay
|
||||
break;
|
||||
}
|
||||
|
||||
var gap = TimeSpan.FromMinutes(i == frame.LongBreakAfter ? frame.LongBreakMinutes : frame.BreakMinutes);
|
||||
var gap = TimeSpan.FromMinutes(IsLongBreakAfter(frame, i) ? frame.LongBreakMinutes : frame.BreakMinutes);
|
||||
var breakEnd = cursor + gap;
|
||||
if (clock >= cursor && clock < breakEnd)
|
||||
{
|
||||
@@ -152,7 +152,7 @@ public static class SchoolDay
|
||||
for (var i = 1; i < period; i++)
|
||||
{
|
||||
cursor += lesson;
|
||||
cursor += TimeSpan.FromMinutes(i == frame.LongBreakAfter ? frame.LongBreakMinutes : frame.BreakMinutes);
|
||||
cursor += TimeSpan.FromMinutes(IsLongBreakAfter(frame, i) ? frame.LongBreakMinutes : frame.BreakMinutes);
|
||||
}
|
||||
|
||||
return TimeOnly.FromTimeSpan(cursor);
|
||||
@@ -225,4 +225,77 @@ public static class SchoolDay
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A break is long when it is the school-wide one or when somebody eats in it. Sittings are
|
||||
/// what make a second break wide: fifteen minutes of lunch do not fit into ten.
|
||||
/// </summary>
|
||||
public static bool IsLongBreakAfter(DayFrameDef frame, int lesson)
|
||||
{
|
||||
if (lesson == frame.LongBreakAfter)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var sitting in frame.LunchBreaks)
|
||||
{
|
||||
if (sitting.AfterLesson == lesson)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The break this parallel eats in. Falls back to the school-wide long break when no sitting
|
||||
/// lists the year — a staff member (<paramref name="year"/> null) eats at any of them.
|
||||
/// </summary>
|
||||
public static int LunchBreakAfter(DayFrameDef frame, int? year)
|
||||
{
|
||||
if (frame.LunchBreaks.Count == 0 || year is null)
|
||||
{
|
||||
return frame.LongBreakAfter;
|
||||
}
|
||||
|
||||
foreach (var sitting in frame.LunchBreaks)
|
||||
{
|
||||
if (sitting.Covers(year.Value))
|
||||
{
|
||||
return sitting.AfterLesson;
|
||||
}
|
||||
}
|
||||
|
||||
return frame.LongBreakAfter;
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="slot"/> is the sitting that feeds this parallel.</summary>
|
||||
public static bool IsLunchWindow(DayFrameDef frame, DaySlot slot, int? year)
|
||||
{
|
||||
if (slot.Kind != DaySlotKind.Break)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (frame.LunchBreaks.Count == 0)
|
||||
{
|
||||
return slot.Index == frame.LongBreakAfter;
|
||||
}
|
||||
|
||||
if (year is null)
|
||||
{
|
||||
foreach (var sitting in frame.LunchBreaks)
|
||||
{
|
||||
if (sitting.AfterLesson == slot.Index)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return slot.Index == LunchBreakAfter(frame, year);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ internal sealed class GameLoopService(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SchoolRegistry.TryNormalizeName(command.Name, out var normalized))
|
||||
if (!SchoolNames.TryNormalize(command.Name, out var normalized))
|
||||
{
|
||||
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidName));
|
||||
return;
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"needGain": 0.5,
|
||||
"roles": ["student", "staff"],
|
||||
"weight": 0,
|
||||
// Only during this parallel's sitting, see the day frame.
|
||||
"lunch": true,
|
||||
},
|
||||
{
|
||||
"defName": "UseToilet",
|
||||
|
||||
@@ -6,4 +6,11 @@
|
||||
"breakMinutes": 10,
|
||||
"longBreakAfter": 3,
|
||||
"longBreakMinutes": 20,
|
||||
// Two sittings so the canteen is not the whole school at once: juniors eat after the
|
||||
// third lesson, seniors after the fourth. Both breaks are long because somebody eats in
|
||||
// them. A pack that wants one sitting just drops this list.
|
||||
"lunchBreaks": [
|
||||
{ "afterLesson": 3, "gradeMin": 1, "gradeMax": 5 },
|
||||
{ "afterLesson": 4, "gradeMin": 6, "gradeMax": 11 },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{ "defName": "Sleep", "initial": 1, "decayPerHour": 0.05, "min": 0, "max": 1, "restoredOffCampus": true },
|
||||
{ "defName": "Hunger", "initial": 1, "decayPerHour": 0.1, "min": 0, "max": 1 },
|
||||
{ "defName": "Hunger", "initial": 1, "decayPerHour": 0.2, "min": 0, "max": 1, "restoredOffCampus": true },
|
||||
{ "defName": "Toilet", "initial": 1, "decayPerHour": 0.15, "min": 0, "max": 1 },
|
||||
{ "defName": "Social", "initial": 1, "decayPerHour": 0.08, "min": 0, "max": 1 },
|
||||
]
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"floor": "floor-1",
|
||||
"slots": [
|
||||
{ "key": "counter", "thing": "DiningTable" },
|
||||
{ "key": "seats", "thing": "Chair", "count": 8 },
|
||||
{ "key": "seats", "thing": "Chair", "count": 112 },
|
||||
],
|
||||
},
|
||||
{ "id": "restroom-1", "def": "Restroom", "building": "main", "floor": "floor-1", "label": "1" },
|
||||
|
||||
@@ -375,6 +375,9 @@ internal static class PresenceSystem
|
||||
var bound = Duty.IsOtherStaff(person)
|
||||
? slot.Kind != DaySlotKind.Outside
|
||||
: slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index);
|
||||
var frame = school.Catalog!.DayFrame;
|
||||
var lunchOpen = frame is not null
|
||||
&& SchoolDay.IsLunchWindow(frame, slot, ClassOf(school, person)?.Year);
|
||||
var state = new ActorState(
|
||||
presence.NodeId,
|
||||
presence.DestinationId,
|
||||
@@ -386,7 +389,8 @@ internal static class PresenceSystem
|
||||
bound,
|
||||
duty,
|
||||
needs.Values,
|
||||
intent);
|
||||
intent,
|
||||
lunchOpen);
|
||||
var decision = DecisionPlanner.Decide(
|
||||
school.Catalog!,
|
||||
school.Map!,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Why a school could not be created. Lives here rather than in the server because the checks
|
||||
/// that produce it — the name rules, the clock range — belong to the simulation.
|
||||
/// </summary>
|
||||
public enum SchoolCreationError
|
||||
{
|
||||
None = 0,
|
||||
LimitReached,
|
||||
InvalidName,
|
||||
InvalidStartDate,
|
||||
InvalidMap,
|
||||
UnknownMod,
|
||||
InvalidCatalog,
|
||||
UnknownNameSet,
|
||||
UnknownNativeLanguage,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// The rules a school name has to pass. A name arrives from a browser, so it is trimmed and
|
||||
/// stripped before anything stores it.
|
||||
/// </summary>
|
||||
public static class SchoolNames
|
||||
{
|
||||
/// <summary>Trims, strips control characters and enforces the length limit.</summary>
|
||||
public static bool TryNormalize(string? name, out string normalized)
|
||||
{
|
||||
normalized = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cleaned = new string(name.Where(character => !char.IsControl(character)).ToArray()).Trim();
|
||||
if (cleaned.Length == 0 || cleaned.Length > School.MaxNameLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = cleaned;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>Why a school could not be created.</summary>
|
||||
public enum SchoolCreationError
|
||||
{
|
||||
None = 0,
|
||||
LimitReached,
|
||||
InvalidName,
|
||||
InvalidStartDate,
|
||||
InvalidMap,
|
||||
UnknownMod,
|
||||
InvalidCatalog,
|
||||
UnknownNameSet,
|
||||
UnknownNativeLanguage,
|
||||
}
|
||||
|
||||
/// <summary>Outcome of <see cref="SchoolRegistry.Create"/>: either the school or the reason there is none.</summary>
|
||||
public readonly record struct SchoolCreationResult(School? School, SchoolCreationError Error)
|
||||
{
|
||||
public bool Succeeded => Error == SchoolCreationError.None && School is not null;
|
||||
|
||||
public static SchoolCreationResult Failed(SchoolCreationError error) => new(null, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory set of schools plus the cap from configuration. Not thread-safe — unit tests and
|
||||
/// name/limit checks use it; the live server gives each school its own worker instead.
|
||||
/// </summary>
|
||||
public sealed class SchoolRegistry : IDisposable
|
||||
{
|
||||
private readonly SimulationOptions _options;
|
||||
private readonly List<School> _schools = [];
|
||||
|
||||
private int _nextId = 1;
|
||||
private bool _disposed;
|
||||
|
||||
public SchoolRegistry(SimulationOptions options)
|
||||
{
|
||||
_options = options;
|
||||
NameGenerator = new SchoolNameGenerator();
|
||||
}
|
||||
|
||||
public SchoolNameGenerator NameGenerator { get; }
|
||||
|
||||
public int MaxSchools => _options.MaxSchools;
|
||||
|
||||
public int Count => _schools.Count;
|
||||
|
||||
public bool IsFull => _schools.Count >= _options.MaxSchools;
|
||||
|
||||
/// <summary>Schools in creation order — the order the menu lists them in.</summary>
|
||||
public IReadOnlyList<School> Schools => _schools;
|
||||
|
||||
public School? Find(int id) => _schools.Find(school => school.Id == id);
|
||||
|
||||
public SchoolCreationResult Create(string name, DateTime startDate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (IsFull)
|
||||
{
|
||||
return SchoolCreationResult.Failed(SchoolCreationError.LimitReached);
|
||||
}
|
||||
|
||||
if (!TryNormalizeName(name, out var normalized))
|
||||
{
|
||||
return SchoolCreationResult.Failed(SchoolCreationError.InvalidName);
|
||||
}
|
||||
|
||||
if (!GameClock.IsValidStartDate(startDate))
|
||||
{
|
||||
return SchoolCreationResult.Failed(SchoolCreationError.InvalidStartDate);
|
||||
}
|
||||
|
||||
var school = School.Create(_nextId++, normalized, startDate);
|
||||
_schools.Add(school);
|
||||
|
||||
return new SchoolCreationResult(school, SchoolCreationError.None);
|
||||
}
|
||||
|
||||
public bool Delete(int id)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var school = Find(id);
|
||||
if (school is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_schools.Remove(school);
|
||||
school.Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Advances every running school by one fixed step.</summary>
|
||||
public void Tick()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
foreach (var school in _schools)
|
||||
{
|
||||
school.Tick(_options.FixedDeltaTime, _options.GameMinutesPerRealSecond);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A name the player has not used yet, for the "random" button in the creation form.</summary>
|
||||
public string SuggestName(SchoolNameLanguage language = SchoolNameLanguage.Russian) =>
|
||||
NameGenerator.Next(_schools.Select(school => school.Name), language);
|
||||
|
||||
/// <summary>Trims, strips control characters and enforces the length limit.</summary>
|
||||
public static bool TryNormalizeName(string? name, out string normalized)
|
||||
{
|
||||
normalized = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cleaned = new string(name.Where(character => !char.IsControl(character)).ToArray()).Trim();
|
||||
if (cleaned.Length == 0 || cleaned.Length > School.MaxNameLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = cleaned;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
foreach (var school in _schools)
|
||||
{
|
||||
school.Dispose();
|
||||
}
|
||||
|
||||
_schools.Clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user