Merge branch 'phase/35-appropriateness'
ci / server (push) Failing after 3m44s
ci / client (push) Failing after 14s

# Conflicts:
#	docs/phases/README.md
This commit is contained in:
Leonid Pershin
2026-08-20 05:19:33 +03:00
33 changed files with 1895 additions and 26 deletions
@@ -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;
}
}
+72
View File
@@ -317,6 +317,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,
@@ -683,6 +730,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>
+10
View File
@@ -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;
}
+24 -2
View File
@@ -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,
+5
View File
@@ -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,
};
}
+40
View File
@@ -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)
+8
View File
@@ -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 },
],
},
],