using HSchool.Content;
using HSchool.People;
using HSchool.Server.Game;
using HSchool.Simulation;
namespace HSchool.Server.Api;
///
/// The main menu talks to these: list, create, delete. People list and staffing read published
/// snapshots; the person card, the personal log, hire and subject changes go through the school's mailbox.
/// The timetable is a published snapshot; pin/unpin go through the mailbox.
///
internal static class SchoolEndpoints
{
/// How long a request waits for the supervisor before giving up.
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
public static void MapSchoolEndpoints(this IEndpointRouteBuilder builder)
{
var schools = builder.MapGroup("/api/schools");
schools.MapGet("/", (GameLoopService loop) =>
{
var state = loop.SchoolsState;
var options = loop.Options;
return new SchoolsResponse(
state.MaxSchools,
options.DefaultStartDate,
options.GameMinutesPerRealSecond,
options.SchoolWeekDays,
[.. state.Schools.Select(SchoolResponse.From)]);
})
.WithName("GetSchools");
schools.MapGet("/random-name", async (string? lang, GameCommandQueue commands, CancellationToken cancellationToken) =>
{
var command = new GameCommand.SuggestName(ParseNameLanguage(lang), NewCompletion());
commands.Enqueue(command);
var name = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return new RandomNameResponse(name);
})
.WithName("GetRandomSchoolName");
schools.MapPost("/", async (
CreateSchoolRequest request,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
var command = new GameCommand.CreateSchool(
request.Name ?? string.Empty,
DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
request.ModIds,
request.Map,
request.CountryId,
request.NativeLanguage,
request.Seed,
NewCompletion());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return outcome.Error switch
{
SchoolCreationError.None =>
Results.Created($"/api/schools/{outcome.School!.Id}", SchoolResponse.From(outcome.School)),
SchoolCreationError.LimitReached =>
Problem(StatusCodes.Status409Conflict, "school-limit-reached", "The school limit is already reached."),
SchoolCreationError.InvalidName =>
Problem(StatusCodes.Status400BadRequest, "invalid-name", $"A name must be 1 to {School.MaxNameLength} characters."),
SchoolCreationError.InvalidStartDate =>
Problem(StatusCodes.Status400BadRequest, "invalid-start-date", "The start date is outside the supported range."),
SchoolCreationError.InvalidMap =>
Problem(StatusCodes.Status400BadRequest, "invalid-map", "The map is not a connected yard-and-rooms graph."),
SchoolCreationError.UnknownMod =>
Problem(StatusCodes.Status400BadRequest, "unknown-mod", "A selected mod is missing."),
SchoolCreationError.InvalidCatalog =>
Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."),
SchoolCreationError.UnknownCountry =>
Problem(StatusCodes.Status400BadRequest, "unknown-country", "The selected country is not in the catalog."),
SchoolCreationError.UnknownNativeLanguage =>
Problem(StatusCodes.Status400BadRequest, "unknown-native-language", "The selected native language is not in that country."),
SchoolCreationError.MissingMod =>
Problem(
StatusCodes.Status400BadRequest,
"missing-mod",
$"Mod '{outcome.MissingPackId}' is required but was not selected.",
missing: outcome.MissingPackId),
SchoolCreationError.ModCycle =>
Problem(StatusCodes.Status400BadRequest, "mod-cycle", "Selected mods have a cyclic dependency."),
_ => Results.Problem("Unknown error."),
};
})
.WithName("CreateSchool");
schools.MapDelete("/{id:int}", async (
int id,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
var command = new GameCommand.DeleteSchool(id, NewCompletion());
commands.Enqueue(command);
var deleted = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return deleted ? Results.NoContent() : Results.NotFound();
})
.WithName("DeleteSchool");
schools.MapGet("/{id:int}/people", (
int id,
string? role,
int? year,
string? letter,
string? position,
string? sex,
int? ageMin,
int? ageMax,
string? sort,
string? dir,
int? page,
int? pageSize,
string? lang,
GameLoopService loop) =>
{
var published = loop.FindPeople(id);
if (published is null)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
if (!TryParsePeopleQuery(role, year, letter, position, sex, ageMin, ageMax, sort, dir, page, pageSize, out var query, out var error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var roster = published.Roster;
if (roster is null)
{
return Results.Ok(new PeopleListResponse(0, query.Page, query.PageSize, [], new PeopleFilterOptionsResponse([], [], [])));
}
var locale = ParseLocale(lang);
var slice = RosterBrowser.Apply(roster, published.School.GameTime, query);
return Results.Ok(PeopleListMapper.From(roster, slice, published.School.GameTime, published.Catalog, locale, query));
})
.WithName("GetSchoolPeople");
schools.MapGet("/{id:int}/directory", (int id, string? lang, GameLoopService loop) =>
{
var published = loop.FindPeople(id);
if (published is null)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
_ = ParseLocale(lang);
var roster = published.Roster;
if (roster is null)
{
return Results.Ok(new DirectoryResponse([]));
}
var people = roster.People
.OrderBy(person => person.Id, StringComparer.Ordinal)
.Select(person => new DirectoryPersonResponse(person.Id, person.Name.Full))
.ToArray();
return Results.Ok(new DirectoryResponse(people));
})
.WithName("GetSchoolDirectory");
schools.MapGet("/{id:int}/people/{personId}", async (
int id,
string personId,
string? lang,
GameCommandQueue commands,
PortraitService portraits,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
}
var command = new GameCommand.GetPerson(id, personId, ParseLocale(lang), NewCompletion());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return outcome.Error switch
{
PersonLookupError.None when outcome.Card is not null =>
Results.Ok(portraits.WithPortraitFlags(id, outcome.Card)),
PersonLookupError.UnknownPerson => Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
};
})
.WithName("GetSchoolPerson");
schools.MapGet("/{id:int}/people/{personId}/log", async (
int id,
string personId,
string? q,
string? sort,
string? dir,
int? page,
int? pageSize,
string? lang,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
}
if (!TryParsePersonLogQuery(q, sort, dir, page, pageSize, out var query, out var error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var command = new GameCommand.GetPersonLog(id, personId, ParseLocale(lang), query, NewCompletion());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return outcome.Error switch
{
PersonLookupError.None when outcome.Page is not null => Results.Ok(outcome.Page),
PersonLookupError.UnknownPerson => Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
};
})
.WithName("GetSchoolPersonLog");
schools.MapGet("/{id:int}/people/{personId}/portrait", async (
int id,
string personId,
string? kind,
PortraitService portraits,
SchoolStore store,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
}
if (!PortraitKindParser.TryParse(kind, out var portraitKind))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar or full.");
}
var lookup = await portraits.EnsurePersonAsync(id, personId, cancellationToken);
if (lookup == PersonLookupError.UnknownPerson)
{
return Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school.");
}
if (lookup != PersonLookupError.None)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
var path = store.PortraitPath(id, personId, portraitKind);
if (!File.Exists(path))
{
return Problem(StatusCodes.Status404NotFound, "portrait-missing", "That portrait has not been generated yet.");
}
return Results.File(path, "image/png");
})
.WithName("GetSchoolPersonPortrait");
schools.MapPost("/{id:int}/people/{personId}/portrait", async (
int id,
string personId,
string? kind,
PortraitService portraits,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
}
if (!PortraitKindParser.TryParse(kind, out var portraitKind))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar or full.");
}
if (!portraits.IsGenerationEnabled)
{
return Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured.");
}
var result = await portraits.GenerateAsync(id, personId, portraitKind, cancellationToken);
return result.Outcome switch
{
PortraitGenerationOutcome.Succeeded => Results.Created(
$"/api/schools/{id}/people/{Uri.EscapeDataString(personId)}/portrait?kind={(portraitKind == PortraitKind.Avatar ? "avatar" : "full")}",
new PortraitResponse(
portraitKind == PortraitKind.Avatar ? "avatar" : "full",
result.HasAvatar,
result.HasFullBody)),
PortraitGenerationOutcome.UnknownPerson =>
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
PortraitGenerationOutcome.NotConfigured =>
Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured."),
PortraitGenerationOutcome.TimedOut =>
Problem(StatusCodes.Status504GatewayTimeout, "swarmui-timeout", "SwarmUI did not finish in time."),
_ => Problem(StatusCodes.Status502BadGateway, "swarmui-unavailable", "SwarmUI could not generate the portrait."),
};
})
.WithName("GenerateSchoolPersonPortrait");
schools.MapGet("/{id:int}/dress-rules", async (
int id,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
var command = new GameCommand.GetDressRules(id, NewCompletion());
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());
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,
GameLoopService loop) =>
{
var published = loop.FindPeople(id);
if (published is null)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, ParseLocale(lang)));
})
.WithName("GetSchoolStaffing");
schools.MapPost("/{id:int}/staff/hire", async (
int id,
HireStaffRequest request,
string? lang,
GameCommandQueue commands,
GameLoopService loop,
CancellationToken cancellationToken) =>
{
if (!TryPersonId(request.PersonId, out var personId, out var error)
|| !TryDefName(request.Position, "position", out var position, out error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var command = new GameCommand.HireStaff(id, personId, position, NewCompletion());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return StaffingResult(id, outcome, loop, ParseLocale(lang));
})
.WithName("HireSchoolStaff");
schools.MapPost("/{id:int}/staff/{personId}/subjects", async (
int id,
string personId,
AssignSubjectRequest request,
string? lang,
GameCommandQueue commands,
GameLoopService loop,
CancellationToken cancellationToken) =>
{
if (!TryPersonId(personId, out var idValue, out var error)
|| !TryDefName(request.Subject, "subject", out var subject, out error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var command = new GameCommand.AssignSubject(id, idValue, subject, NewCompletion());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return StaffingResult(id, outcome, loop, ParseLocale(lang));
})
.WithName("AssignSchoolSubject");
schools.MapDelete("/{id:int}/staff/{personId}/subjects/{subject}", async (
int id,
string personId,
string subject,
string? lang,
GameCommandQueue commands,
GameLoopService loop,
CancellationToken cancellationToken) =>
{
if (!TryPersonId(personId, out var idValue, out var error)
|| !TryDefName(subject, "subject", out var subjectName, out error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var command = new GameCommand.UnassignSubject(id, idValue, subjectName, NewCompletion());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return StaffingResult(id, outcome, loop, ParseLocale(lang));
})
.WithName("UnassignSchoolSubject");
}
/// The supervisor must never be blocked by a continuation of a waiting request.
private static TaskCompletionSource NewCompletion() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
private static SchoolNameLanguage ParseNameLanguage(string? lang) =>
string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase)
? SchoolNameLanguage.English
: SchoolNameLanguage.Russian;
private static string ParseLocale(string? lang) =>
string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru";
private static bool TryParsePeopleQuery(
string? role,
int? year,
string? letter,
string? position,
string? sex,
int? ageMin,
int? ageMax,
string? sort,
string? dir,
int? page,
int? pageSize,
out RosterQuery query,
out string error)
{
query = default;
error = string.Empty;
if (!string.IsNullOrWhiteSpace(role) && !HSchool.Content.PersonRoles.IsKnown(role))
{
error = "role must be student, staff or parent.";
return false;
}
if (year is < 1)
{
error = "year must be a positive integer.";
return false;
}
bool? female = null;
if (!string.IsNullOrWhiteSpace(sex))
{
if (sex.Equals("female", StringComparison.OrdinalIgnoreCase))
{
female = true;
}
else if (sex.Equals("male", StringComparison.OrdinalIgnoreCase))
{
female = false;
}
else
{
error = "sex must be male or female.";
return false;
}
}
if (ageMin is < 0 || ageMax is < 0)
{
error = "ageMin and ageMax must be zero or greater.";
return false;
}
if (ageMin is int min && ageMax is int max && min > max)
{
error = "ageMin cannot be greater than ageMax.";
return false;
}
if (!RosterBrowser.TryParseSort(sort, out var parsedSort))
{
error = "sort must be surname, age, year or position.";
return false;
}
var descending = false;
if (!string.IsNullOrWhiteSpace(dir))
{
if (dir.Equals("desc", StringComparison.OrdinalIgnoreCase))
{
descending = true;
}
else if (!dir.Equals("asc", StringComparison.OrdinalIgnoreCase))
{
error = "dir must be asc or desc.";
return false;
}
}
var parsedPage = page ?? 1;
if (parsedPage < 1)
{
error = "page must be 1 or greater.";
return false;
}
var parsedPageSize = pageSize ?? RosterBrowser.DefaultPageSize;
if (parsedPageSize < 1 || parsedPageSize > RosterBrowser.MaxPageSize)
{
error = $"pageSize must be between 1 and {RosterBrowser.MaxPageSize}.";
return false;
}
query = new RosterQuery(
string.IsNullOrWhiteSpace(role) ? null : role,
year,
string.IsNullOrWhiteSpace(letter) ? null : letter,
string.IsNullOrWhiteSpace(position) ? null : position,
female,
ageMin,
ageMax,
parsedSort,
descending,
parsedPage,
parsedPageSize);
return true;
}
private static bool TryParsePersonLogQuery(
string? search,
string? sort,
string? dir,
int? page,
int? pageSize,
out PersonLogQuery query,
out string error)
{
query = default;
error = string.Empty;
if (search is { Length: > 128 })
{
error = "q must be at most 128 characters.";
return false;
}
if (!string.IsNullOrWhiteSpace(sort) && !sort.Equals("time", StringComparison.OrdinalIgnoreCase))
{
error = "sort must be time.";
return false;
}
var descending = true;
if (!string.IsNullOrWhiteSpace(dir))
{
if (dir.Equals("asc", StringComparison.OrdinalIgnoreCase))
{
descending = false;
}
else if (!dir.Equals("desc", StringComparison.OrdinalIgnoreCase))
{
error = "dir must be asc or desc.";
return false;
}
}
var parsedPage = page ?? 1;
if (parsedPage < 1)
{
error = "page must be 1 or greater.";
return false;
}
var parsedPageSize = pageSize ?? PersonLogBrowser.DefaultPageSize;
if (parsedPageSize < 1 || parsedPageSize > PersonLogBrowser.MaxPageSize)
{
error = $"pageSize must be between 1 and {PersonLogBrowser.MaxPageSize}.";
return false;
}
query = new PersonLogQuery(
string.IsNullOrWhiteSpace(search) ? null : search.Trim(),
descending,
parsedPage,
parsedPageSize);
return true;
}
private static StaffingResponse MapStaffing(PublishedSchoolPeople published, float allocated, string locale) =>
StaffingMapper.From(
published.Roster,
published.Applicants,
published.Catalog,
published.School.GameTime,
allocated,
locale);
private static IResult StaffingResult(int schoolId, StaffingOutcome outcome, GameLoopService loop, string locale)
{
if (outcome.Error != StaffingError.None)
{
return StaffingProblem(outcome);
}
var published = loop.FindPeople(schoolId);
if (published is null)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, locale));
}
private static IResult StaffingProblem(StaffingOutcome outcome) =>
outcome.Error switch
{
StaffingError.UnknownApplicant =>
Problem(StatusCodes.Status404NotFound, "unknown-applicant", "That person is not in the applicant pool."),
StaffingError.AlreadyHired =>
Problem(StatusCodes.Status409Conflict, "already-hired", "That person is already on staff."),
StaffingError.UnknownPosition =>
Problem(StatusCodes.Status400BadRequest, "unknown-position", "That position is not in the catalog."),
StaffingError.UnknownSubject =>
Problem(StatusCodes.Status400BadRequest, "unknown-subject", "That subject is not in the catalog."),
StaffingError.NotStaff =>
Problem(StatusCodes.Status400BadRequest, "not-staff", "That person is not on staff."),
StaffingError.NotTeacher =>
Problem(StatusCodes.Status400BadRequest, "not-teacher", "Only a teacher can be assigned a subject."),
StaffingError.AlreadyAssigned =>
Problem(StatusCodes.Status409Conflict, "already-assigned", "That subject is already assigned to this person."),
StaffingError.SubjectNotAssigned =>
Problem(StatusCodes.Status404NotFound, "unknown-assignment", "That subject is not assigned to this person."),
StaffingError.PayrollExceeded =>
Problem(
StatusCodes.Status409Conflict,
"payroll-exceeded",
$"That change would take payroll from {outcome.Payroll} to {outcome.Attempted} against an allocation of {outcome.Allocated}.",
outcome),
StaffingError.NoOpening =>
Problem(StatusCodes.Status409Conflict, "no-opening", "There is no free opening for that position."),
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
};
private static bool TryPersonId(string? value, out string personId, out string error)
{
personId = value?.Trim() ?? string.Empty;
if (personId.Length is < 1 or > 64)
{
error = "The person id is not valid.";
return false;
}
error = string.Empty;
return true;
}
private static bool TryDefName(string? value, string field, out string name, out string error)
{
name = value?.Trim() ?? string.Empty;
if (name.Length is < 1 or > 64)
{
error = $"{field} is not valid.";
return false;
}
error = string.Empty;
return true;
}
private static IResult Problem(
int statusCode,
string code,
string detail,
StaffingOutcome? staffing = null,
string? missing = null)
{
var extensions = new Dictionary { ["code"] = code };
if (staffing is not null)
{
extensions["allocated"] = staffing.Allocated;
extensions["payroll"] = staffing.Payroll;
extensions["remaining"] = staffing.Remaining;
extensions["attempted"] = staffing.Attempted;
}
if (missing is not null)
{
extensions["missing"] = missing;
}
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."),
};
}
/// Body of POST /api/schools. The start date is a game calendar date, not a real one.
internal sealed record CreateSchoolRequest(
string? Name,
DateTime StartDate,
IReadOnlyList? ModIds,
MapLayout? Map,
string? CountryId,
string? NativeLanguage,
int? Seed);
internal sealed record SchoolResponse(
int Id,
string Name,
DateTime GameTime,
bool Running,
byte SpeedIndex,
IReadOnlyList ModIds,
int Seed)
{
public static SchoolResponse From(SchoolState school) =>
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex, school.ModIds, school.Seed);
}
/// Everything the main menu needs in one request.
internal sealed record SchoolsResponse(
int MaxSchools,
DateTime DefaultStartDate,
double GameMinutesPerRealSecond,
int SchoolWeekDays,
IReadOnlyList Schools);
internal sealed record RandomNameResponse(string Name);