845 lines
33 KiB
C#
845 lines
33 KiB
C#
using HSchool.Content;
|
|
using HSchool.People;
|
|
using HSchool.Server.Game;
|
|
using HSchool.Simulation;
|
|
|
|
namespace HSchool.Server.Api;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal static class SchoolEndpoints
|
|
{
|
|
/// <summary>How long a request waits for the supervisor before giving up.</summary>
|
|
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<string>());
|
|
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) =>
|
|
{
|
|
SwarmUiConfigFile? portraitSettings = null;
|
|
if (request.PortraitSettings is not null)
|
|
{
|
|
try
|
|
{
|
|
request.PortraitSettings.Validate();
|
|
portraitSettings = SwarmUiConfigFile.Clone(request.PortraitSettings);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Problem(StatusCodes.Status400BadRequest, "invalid-portrait-settings", ex.Message);
|
|
}
|
|
}
|
|
|
|
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,
|
|
portraitSettings,
|
|
NewCompletion<SchoolCreationOutcome>());
|
|
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<bool>());
|
|
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<PersonCardResult>());
|
|
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<PersonLogResult>());
|
|
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, custom, 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.MapGet("/{id:int}/people/{personId}/portrait/prompt", async (
|
|
int id,
|
|
string personId,
|
|
string? kind,
|
|
string? promptExtra,
|
|
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, custom, or full.");
|
|
}
|
|
|
|
var result = await portraits.BuildPromptAsync(id, personId, portraitKind, promptExtra, cancellationToken);
|
|
return result.Outcome switch
|
|
{
|
|
PortraitPromptBuildOutcome.Succeeded => Results.Ok(new PortraitPromptResponse(
|
|
PortraitKindParser.ToApiValue(result.Kind),
|
|
result.Positive,
|
|
result.Negative,
|
|
result.PromptExtra,
|
|
result.PresetId,
|
|
result.PresetLabel)),
|
|
PortraitPromptBuildOutcome.InvalidPrompt =>
|
|
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
|
|
PortraitPromptBuildOutcome.UnknownPerson =>
|
|
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
|
|
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
|
|
};
|
|
})
|
|
.WithName("GetSchoolPersonPortraitPrompt");
|
|
|
|
schools.MapPost("/{id:int}/people/{personId}/portrait", async (
|
|
int id,
|
|
string personId,
|
|
string? kind,
|
|
GeneratePortraitRequest? body,
|
|
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, custom, or full.");
|
|
}
|
|
|
|
if (!portraits.IsGenerationEnabled)
|
|
{
|
|
return Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured.");
|
|
}
|
|
|
|
var result = await portraits.GenerateAsync(id, personId, portraitKind, body?.PromptExtra, cancellationToken);
|
|
return result.Outcome switch
|
|
{
|
|
PortraitGenerationOutcome.Succeeded => Results.Created(
|
|
$"/api/schools/{id}/people/{Uri.EscapeDataString(personId)}/portrait?kind={PortraitKindParser.ToApiValue(portraitKind)}",
|
|
new PortraitResponse(
|
|
PortraitKindParser.ToApiValue(portraitKind),
|
|
result.HasAvatar,
|
|
result.HasCustom,
|
|
result.HasFullBody,
|
|
result.CustomPortraitPrompt)),
|
|
PortraitGenerationOutcome.InvalidPrompt =>
|
|
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
|
|
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<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,
|
|
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<StaffingOutcome>());
|
|
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<StaffingOutcome>());
|
|
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<StaffingOutcome>());
|
|
commands.Enqueue(command);
|
|
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
|
return StaffingResult(id, outcome, loop, ParseLocale(lang));
|
|
})
|
|
.WithName("UnassignSchoolSubject");
|
|
}
|
|
|
|
/// <summary>The supervisor must never be blocked by a continuation of a waiting request.</summary>
|
|
private static TaskCompletionSource<T> NewCompletion<T>() =>
|
|
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<string, object?> { ["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."),
|
|
};
|
|
}
|
|
|
|
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
|
|
internal sealed record CreateSchoolRequest(
|
|
string? Name,
|
|
DateTime StartDate,
|
|
IReadOnlyList<string>? ModIds,
|
|
MapLayout? Map,
|
|
string? CountryId,
|
|
string? NativeLanguage,
|
|
int? Seed,
|
|
SwarmUiConfigFile? PortraitSettings);
|
|
|
|
internal sealed record SchoolResponse(
|
|
int Id,
|
|
string Name,
|
|
DateTime GameTime,
|
|
bool Running,
|
|
byte SpeedIndex,
|
|
IReadOnlyList<string> ModIds,
|
|
int Seed)
|
|
{
|
|
public static SchoolResponse From(SchoolState school) =>
|
|
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex, school.ModIds, school.Seed);
|
|
}
|
|
|
|
/// <summary>Everything the main menu needs in one request.</summary>
|
|
internal sealed record SchoolsResponse(
|
|
int MaxSchools,
|
|
DateTime DefaultStartDate,
|
|
double GameMinutesPerRealSecond,
|
|
int SchoolWeekDays,
|
|
IReadOnlyList<SchoolResponse> Schools);
|
|
|
|
internal sealed record RandomNameResponse(string Name);
|