Implement phase 35 dress appropriateness and school rules.
Adds outfit evaluation, morning home dressing, locker-room ChangeClothes with day-log events, PE/weather AI goals, dress-rules API, and tests.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.Server.Api;
|
||||
|
||||
internal sealed record DressRulePairDto(string Form, string Color);
|
||||
|
||||
internal sealed record DressRulesResponse(
|
||||
DressRulePairDto Students,
|
||||
DressRulePairDto Staff,
|
||||
DressRulePairDto? PendingStudents,
|
||||
DressRulePairDto? PendingStaff)
|
||||
{
|
||||
public static DressRulesResponse From(SchoolDressRules rules) =>
|
||||
new(
|
||||
Pair(rules.Students),
|
||||
Pair(rules.Staff),
|
||||
rules.PendingStudents is { } students ? Pair(students) : null,
|
||||
rules.PendingStaff is { } staff ? Pair(staff) : null);
|
||||
|
||||
private static DressRulePairDto Pair(DressRulePair pair) => new(pair.Form, pair.Color);
|
||||
}
|
||||
|
||||
internal sealed record SetDressRulesRequest(
|
||||
DressRulePairDto? Students,
|
||||
DressRulePairDto? Staff);
|
||||
|
||||
internal enum DressRulesError
|
||||
{
|
||||
None,
|
||||
UnknownSchool,
|
||||
UnknownForm,
|
||||
UnknownColor,
|
||||
}
|
||||
|
||||
internal sealed record DressRulesOutcome(DressRulesError Error, DressRulesResponse? Rules)
|
||||
{
|
||||
public static DressRulesOutcome Ok(SchoolDressRules rules) =>
|
||||
new(DressRulesError.None, DressRulesResponse.From(rules));
|
||||
|
||||
public static DressRulesOutcome Fail(DressRulesError error) => new(error, null);
|
||||
}
|
||||
|
||||
internal static class DressRulesValidation
|
||||
{
|
||||
public static bool TryParse(DressRulePairDto dto, out DressRulePair pair, out DressRulesError error)
|
||||
{
|
||||
if (!FormPolicies.IsKnown(dto.Form))
|
||||
{
|
||||
pair = default!;
|
||||
error = DressRulesError.UnknownForm;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ColorPolicies.IsKnown(dto.Color))
|
||||
{
|
||||
pair = default!;
|
||||
error = DressRulesError.UnknownColor;
|
||||
return false;
|
||||
}
|
||||
|
||||
pair = new DressRulePair(dto.Form, dto.Color);
|
||||
error = DressRulesError.None;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,53 @@ internal static class SchoolEndpoints
|
||||
})
|
||||
.WithName("GenerateSchoolPersonPortrait");
|
||||
|
||||
schools.MapGet("/{id:int}/dress-rules", async (
|
||||
int id,
|
||||
GameCommandQueue commands,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var command = new GameCommand.GetDressRules(id, NewCompletion<DressRulesOutcome>());
|
||||
commands.Enqueue(command);
|
||||
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
||||
return DressRulesHttp(outcome);
|
||||
})
|
||||
.WithName("GetSchoolDressRules");
|
||||
|
||||
schools.MapPost("/{id:int}/dress-rules", async (
|
||||
int id,
|
||||
SetDressRulesRequest request,
|
||||
GameCommandQueue commands,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
DressRulePair? students = null;
|
||||
if (request.Students is { } studentDto)
|
||||
{
|
||||
if (!DressRulesValidation.TryParse(studentDto, out var parsed, out var studentError))
|
||||
{
|
||||
return DressRulesBadRequest(studentError);
|
||||
}
|
||||
|
||||
students = parsed;
|
||||
}
|
||||
|
||||
DressRulePair? staff = null;
|
||||
if (request.Staff is { } staffDto)
|
||||
{
|
||||
if (!DressRulesValidation.TryParse(staffDto, out var parsed, out var staffError))
|
||||
{
|
||||
return DressRulesBadRequest(staffError);
|
||||
}
|
||||
|
||||
staff = parsed;
|
||||
}
|
||||
|
||||
var command = new GameCommand.SetDressRules(id, students, staff, NewCompletion<DressRulesOutcome>());
|
||||
commands.Enqueue(command);
|
||||
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
||||
return DressRulesHttp(outcome);
|
||||
})
|
||||
.WithName("SetSchoolDressRules");
|
||||
|
||||
schools.MapGet("/{id:int}/staffing", (
|
||||
int id,
|
||||
string? lang,
|
||||
@@ -678,6 +725,31 @@ internal static class SchoolEndpoints
|
||||
|
||||
return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions);
|
||||
}
|
||||
|
||||
private static IResult DressRulesHttp(DressRulesOutcome outcome) =>
|
||||
outcome.Error switch
|
||||
{
|
||||
DressRulesError.None => Results.Ok(outcome.Rules),
|
||||
DressRulesError.UnknownSchool => Problem(
|
||||
StatusCodes.Status404NotFound,
|
||||
"unknown-school",
|
||||
"That school does not exist."),
|
||||
_ => DressRulesBadRequest(outcome.Error),
|
||||
};
|
||||
|
||||
private static IResult DressRulesBadRequest(DressRulesError error) =>
|
||||
error switch
|
||||
{
|
||||
DressRulesError.UnknownForm => Problem(
|
||||
StatusCodes.Status400BadRequest,
|
||||
"unknown-form",
|
||||
"That form policy is not recognized."),
|
||||
DressRulesError.UnknownColor => Problem(
|
||||
StatusCodes.Status400BadRequest,
|
||||
"unknown-color",
|
||||
"That colour policy is not recognized."),
|
||||
_ => Problem(StatusCodes.Status400BadRequest, "invalid-query", "The dress rules request is not valid."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
|
||||
|
||||
@@ -101,4 +101,14 @@ internal abstract record GameCommand
|
||||
int Day,
|
||||
int Period,
|
||||
TaskCompletionSource<TimetableOutcome> Result) : GameCommand;
|
||||
|
||||
internal sealed record GetDressRules(
|
||||
int SchoolId,
|
||||
TaskCompletionSource<DressRulesOutcome> Result) : GameCommand;
|
||||
|
||||
internal sealed record SetDressRules(
|
||||
int SchoolId,
|
||||
DressRulePair? PendingStudents,
|
||||
DressRulePair? PendingStaff,
|
||||
TaskCompletionSource<DressRulesOutcome> Result) : GameCommand;
|
||||
}
|
||||
|
||||
@@ -225,6 +225,17 @@ internal sealed class GameLoopService(
|
||||
new WorkerCommand.UnpinLesson(unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period, unpin.Result),
|
||||
unpin.Result);
|
||||
break;
|
||||
|
||||
case GameCommand.GetDressRules getRules:
|
||||
HandleDressRules(getRules.SchoolId, new WorkerCommand.GetDressRules(getRules.Result), getRules.Result);
|
||||
break;
|
||||
|
||||
case GameCommand.SetDressRules setRules:
|
||||
HandleDressRules(
|
||||
setRules.SchoolId,
|
||||
new WorkerCommand.SetDressRules(setRules.PendingStudents, setRules.PendingStaff, setRules.Result),
|
||||
setRules.Result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +266,14 @@ internal sealed class GameLoopService(
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDressRules(int schoolId, WorkerCommand command, TaskCompletionSource<DressRulesOutcome> result)
|
||||
{
|
||||
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
|
||||
{
|
||||
result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource<StaffingOutcome> result)
|
||||
{
|
||||
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
|
||||
@@ -560,7 +579,8 @@ internal sealed class GameLoopService(
|
||||
save.ClimatePresetId,
|
||||
save.NativeLanguage,
|
||||
createSeed: null,
|
||||
save.Presence);
|
||||
save.Presence,
|
||||
save.DressRules);
|
||||
worker.Start();
|
||||
|
||||
try
|
||||
@@ -611,7 +631,8 @@ internal sealed class GameLoopService(
|
||||
string? climatePresetId,
|
||||
string? nativeLanguage,
|
||||
int? createSeed = null,
|
||||
IReadOnlyList<PresenceSnapshot>? presence = null) =>
|
||||
IReadOnlyList<PresenceSnapshot>? presence = null,
|
||||
SchoolDressRules? dressRules = null) =>
|
||||
new(
|
||||
id,
|
||||
name,
|
||||
@@ -626,6 +647,7 @@ internal sealed class GameLoopService(
|
||||
nativeLanguage,
|
||||
createSeed,
|
||||
presence,
|
||||
dressRules,
|
||||
_options,
|
||||
clients,
|
||||
metrics,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using HSchool.Content;
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Schedule;
|
||||
using HSchool.Simulation;
|
||||
@@ -36,6 +37,8 @@ internal sealed class SchoolSave
|
||||
public string? NativeLanguage { get; init; }
|
||||
|
||||
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
|
||||
|
||||
public SchoolDressRules? DressRules { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Allocates school ids that survive a process restart.</summary>
|
||||
@@ -188,6 +191,7 @@ internal sealed class SchoolStore
|
||||
NameSetId = save.NameSetId,
|
||||
NativeLanguage = save.NativeLanguage,
|
||||
Presence = save.Presence,
|
||||
DressRules = save.DressRules,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -233,6 +237,7 @@ internal sealed class SchoolStore
|
||||
NameSetId = save.NameSetId,
|
||||
NativeLanguage = save.NativeLanguage,
|
||||
Presence = save.Presence,
|
||||
DressRules = save.DressRules,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ internal sealed class SchoolWorker
|
||||
private string? _nativeLanguage;
|
||||
private readonly int? _createSeed;
|
||||
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
||||
private readonly SchoolDressRules? _savedDressRules;
|
||||
private readonly Action<int> _onFailed;
|
||||
|
||||
private readonly int _id;
|
||||
@@ -72,6 +73,7 @@ internal sealed class SchoolWorker
|
||||
string? nativeLanguage,
|
||||
int? createSeed,
|
||||
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
||||
SchoolDressRules? savedDressRules,
|
||||
SimulationOptions options,
|
||||
ClientRegistry clients,
|
||||
GameMetrics metrics,
|
||||
@@ -93,6 +95,7 @@ internal sealed class SchoolWorker
|
||||
_nativeLanguage = nativeLanguage;
|
||||
_createSeed = createSeed;
|
||||
_savedPresence = savedPresence;
|
||||
_savedDressRules = savedDressRules;
|
||||
_options = options;
|
||||
_clients = clients;
|
||||
_metrics = metrics;
|
||||
@@ -248,6 +251,7 @@ internal sealed class SchoolWorker
|
||||
}
|
||||
|
||||
_school = school;
|
||||
school.DressRules = _savedDressRules ?? new SchoolDressRules();
|
||||
PublishSnapshot();
|
||||
|
||||
if (_isNew)
|
||||
@@ -458,6 +462,29 @@ internal sealed class SchoolWorker
|
||||
unpin.Result.TrySetResult(
|
||||
ApplyUnpin(school, unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period));
|
||||
break;
|
||||
|
||||
case WorkerCommand.GetDressRules getRules:
|
||||
getRules.Result.TrySetResult(DressRulesOutcome.Ok(school.DressRules));
|
||||
break;
|
||||
|
||||
case WorkerCommand.SetDressRules setRules:
|
||||
{
|
||||
var next = school.DressRules;
|
||||
if (setRules.PendingStudents is { } students)
|
||||
{
|
||||
next = next with { PendingStudents = students };
|
||||
}
|
||||
|
||||
if (setRules.PendingStaff is { } staff)
|
||||
{
|
||||
next = next with { PendingStaff = staff };
|
||||
}
|
||||
|
||||
school.DressRules = next;
|
||||
setRules.Result.TrySetResult(DressRulesOutcome.Ok(school.DressRules));
|
||||
dirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -511,6 +538,12 @@ internal sealed class SchoolWorker
|
||||
case WorkerCommand.UnpinLesson unpin:
|
||||
unpin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
|
||||
break;
|
||||
case WorkerCommand.GetDressRules getRules:
|
||||
getRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
|
||||
break;
|
||||
case WorkerCommand.SetDressRules setRules:
|
||||
setRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,6 +575,12 @@ internal sealed class SchoolWorker
|
||||
case WorkerCommand.UnpinLesson unpin:
|
||||
unpin.Result.TrySetException(exception);
|
||||
break;
|
||||
case WorkerCommand.GetDressRules getRules:
|
||||
getRules.Result.TrySetException(exception);
|
||||
break;
|
||||
case WorkerCommand.SetDressRules setRules:
|
||||
setRules.Result.TrySetException(exception);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,6 +1053,7 @@ internal sealed class SchoolWorker
|
||||
ClimatePresetId = school.ClimatePresetId,
|
||||
NativeLanguage = _nativeLanguage,
|
||||
Presence = school.CapturePresence(),
|
||||
DressRules = school.DressRules,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Server.Api;
|
||||
using HSchool.Server.Net;
|
||||
@@ -63,4 +64,11 @@ internal abstract record WorkerCommand
|
||||
int Day,
|
||||
int Period,
|
||||
TaskCompletionSource<TimetableOutcome> Result) : WorkerCommand;
|
||||
|
||||
internal sealed record GetDressRules(TaskCompletionSource<DressRulesOutcome> Result) : WorkerCommand;
|
||||
|
||||
internal sealed record SetDressRules(
|
||||
DressRulePair? PendingStudents,
|
||||
DressRulePair? PendingStaff,
|
||||
TaskCompletionSource<DressRulesOutcome> Result) : WorkerCommand;
|
||||
}
|
||||
|
||||
@@ -50,4 +50,20 @@
|
||||
"roles": ["student", "staff"],
|
||||
"weight": 1,
|
||||
},
|
||||
{
|
||||
"defName": "ChangeClothesMale",
|
||||
"room": "MaleChangingRoom",
|
||||
"thing": "ChangingSpot",
|
||||
"minutes": 5,
|
||||
"roles": ["student", "staff"],
|
||||
"weight": 0,
|
||||
},
|
||||
{
|
||||
"defName": "ChangeClothesFemale",
|
||||
"room": "FemaleChangingRoom",
|
||||
"thing": "ChangingSpot",
|
||||
"minutes": 5,
|
||||
"roles": ["student", "staff"],
|
||||
"weight": 0,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -38,4 +38,12 @@
|
||||
{ "min": 0.15, "id": "ApparelConditionTorn" },
|
||||
{ "min": 0, "id": "ApparelConditionRags" },
|
||||
],
|
||||
// Dress code thresholds — axes live in code, numbers here.
|
||||
"shortFormMinAge": 13,
|
||||
"formalityRegularMin": 20,
|
||||
"formalityStrictMin": 60,
|
||||
"outerBelowC": 10,
|
||||
"heavyOuterAboveC": 15,
|
||||
"apparelGoalWeight": 6,
|
||||
"changeClothesMinutes": 5,
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"lockerSex": "male",
|
||||
"slots": [
|
||||
{ "key": "lockers", "thing": "Locker", "count": 12 },
|
||||
{ "key": "spots", "thing": "ChangingSpot", "count": 4 },
|
||||
],
|
||||
"travelMinutes": 0.5,
|
||||
},
|
||||
@@ -64,6 +65,7 @@
|
||||
"lockerSex": "female",
|
||||
"slots": [
|
||||
{ "key": "lockers", "thing": "Locker", "count": 12 },
|
||||
{ "key": "spots", "thing": "ChangingSpot", "count": 4 },
|
||||
],
|
||||
"travelMinutes": 0.5,
|
||||
},
|
||||
|
||||
@@ -7,4 +7,5 @@
|
||||
{ "defName": "Computer", "pupilSlots": 1 },
|
||||
{ "defName": "MedicalCouch" },
|
||||
{ "defName": "Locker" },
|
||||
{ "defName": "ChangingSpot" },
|
||||
]
|
||||
|
||||
@@ -95,6 +95,9 @@
|
||||
"UtilityRoom": "Utility room",
|
||||
"MaleChangingRoom": "Boys' changing room",
|
||||
"FemaleChangingRoom": "Girls' changing room",
|
||||
"ChangingSpot": "Changing spot",
|
||||
"ChangeClothesMale": "Change clothes",
|
||||
"ChangeClothesFemale": "Change clothes",
|
||||
"Mathematics": "Mathematics",
|
||||
"RussianLanguage": "Russian",
|
||||
"Literature": "Literature",
|
||||
@@ -173,5 +176,6 @@
|
||||
"ActionStarted": "started: {0}",
|
||||
"ActionEnded": "finished: {0}",
|
||||
"ApparelReplaced": "got a new {0}",
|
||||
"ApparelChanged": "changed clothes: {0}",
|
||||
"core": "Core",
|
||||
}
|
||||
|
||||
@@ -95,6 +95,9 @@
|
||||
"UtilityRoom": "Теплоузел",
|
||||
"MaleChangingRoom": "Мужская раздевалка",
|
||||
"FemaleChangingRoom": "Женская раздевалка",
|
||||
"ChangingSpot": "Место переодевания",
|
||||
"ChangeClothesMale": "Переодеться",
|
||||
"ChangeClothesFemale": "Переодеться",
|
||||
"Mathematics": "Математика",
|
||||
"RussianLanguage": "Русский язык",
|
||||
"Literature": "Литература",
|
||||
@@ -173,5 +176,6 @@
|
||||
"ActionStarted": "начал: {0}",
|
||||
"ActionEnded": "закончил: {0}",
|
||||
"ApparelReplaced": "получил новую {0}",
|
||||
"ApparelChanged": "переоделся: {0}",
|
||||
"core": "Базовая игра",
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"floor": "gym-floor",
|
||||
"slots": [
|
||||
{ "key": "lockers", "thing": "Locker", "count": 12 },
|
||||
{ "key": "spots", "thing": "ChangingSpot", "count": 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -214,6 +215,7 @@
|
||||
"floor": "gym-floor",
|
||||
"slots": [
|
||||
{ "key": "lockers", "thing": "Locker", "count": 12 },
|
||||
{ "key": "spots", "thing": "ChangingSpot", "count": 4 },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user