Enhance staffing management in school simulation by introducing API endpoints for hiring staff and assigning subjects. Implement payroll cap validation to ensure hiring and subject assignments do not exceed the allocated budget. Update the simulation options to include a monthly payroll cap and revise related classes to support new staffing functionalities. Enhance documentation to reflect these changes and update tests to validate the new features.
This commit is contained in:
@@ -169,3 +169,123 @@ internal static class PeopleListMapper
|
||||
return new PeopleFilterOptionsResponse(years, letters, positions);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record HireStaffRequest(string? PersonId, string? Position);
|
||||
|
||||
internal sealed record AssignSubjectRequest(string? Subject);
|
||||
|
||||
internal sealed record StaffingResponse(
|
||||
float Allocated,
|
||||
float Payroll,
|
||||
float Remaining,
|
||||
IReadOnlyList<UncoveredSubjectResponse> Uncovered,
|
||||
IReadOnlyList<ApplicantResponse> Applicants,
|
||||
IReadOnlyList<StaffMemberResponse> Staff);
|
||||
|
||||
internal sealed record UncoveredSubjectResponse(
|
||||
string DefName,
|
||||
string Label,
|
||||
int GradeMin,
|
||||
int GradeMax,
|
||||
int HoursPerWeek);
|
||||
|
||||
internal sealed record ApplicantResponse(
|
||||
string Id,
|
||||
string FullName,
|
||||
bool Female,
|
||||
int Age,
|
||||
bool IsParent,
|
||||
float HourlyWageAsk,
|
||||
float MonthlyBase);
|
||||
|
||||
internal sealed record StaffMemberResponse(
|
||||
string Id,
|
||||
string FullName,
|
||||
bool Female,
|
||||
int Age,
|
||||
bool IsParent,
|
||||
string Position,
|
||||
string PositionLabel,
|
||||
float HourlyWageAsk,
|
||||
float MonthlyPay,
|
||||
IReadOnlyList<DefLabelResponse> Subjects);
|
||||
|
||||
internal static class StaffingMapper
|
||||
{
|
||||
public static StaffingResponse From(
|
||||
Roster? roster,
|
||||
ApplicantPool? pool,
|
||||
DefCatalog? catalog,
|
||||
DateTime asOf,
|
||||
float allocated,
|
||||
string locale)
|
||||
{
|
||||
roster ??= new Roster([], [], []);
|
||||
pool ??= ApplicantPool.Empty;
|
||||
var rules = catalog?.StaffingRules;
|
||||
var payroll = rules is null ? 0f : Staffing.Payroll(rules, roster.People);
|
||||
var remaining = MathF.Round(MathF.Max(0f, allocated - payroll), 2);
|
||||
|
||||
var uncovered = catalog is null
|
||||
? Array.Empty<UncoveredSubjectResponse>()
|
||||
: Staffing.Uncovered(catalog, roster)
|
||||
.Select(subject => new UncoveredSubjectResponse(
|
||||
subject.DefName,
|
||||
catalog.Label(locale, subject),
|
||||
subject.Grades.Min,
|
||||
subject.Grades.Max,
|
||||
subject.HoursPerWeek))
|
||||
.ToArray();
|
||||
|
||||
var applicants = pool.Applicants
|
||||
.Select(applicant => new ApplicantResponse(
|
||||
applicant.Person.Id,
|
||||
applicant.Person.Name.Full,
|
||||
applicant.Person.Female,
|
||||
applicant.Person.AgeOn(asOf),
|
||||
applicant.Person.IsParent,
|
||||
applicant.HourlyWageAsk,
|
||||
rules is null ? 0f : Staffing.MonthlyBase(rules, applicant.HourlyWageAsk)))
|
||||
.ToArray();
|
||||
|
||||
var staff = roster.People
|
||||
.Where(person => person.IsStaff)
|
||||
.OrderBy(person => person.Name.Surname, StringComparer.Ordinal)
|
||||
.ThenBy(person => person.Id, StringComparer.Ordinal)
|
||||
.Select(person => Member(person, catalog, rules, locale, asOf))
|
||||
.ToArray();
|
||||
|
||||
return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff);
|
||||
}
|
||||
|
||||
private static StaffMemberResponse Member(
|
||||
Person person,
|
||||
DefCatalog? catalog,
|
||||
StaffingDef? rules,
|
||||
string locale,
|
||||
DateTime asOf)
|
||||
{
|
||||
var ask = person.HourlyWageAsk ?? 0f;
|
||||
var pay = rules is null ? 0f : Staffing.MonthlyPay(rules, ask, person.Subjects.Count);
|
||||
var subjects = person.Subjects
|
||||
.Select(name => new DefLabelResponse(
|
||||
name,
|
||||
catalog is not null && catalog.Subjects.TryGetValue(name, out var def)
|
||||
? catalog.Label(locale, def)
|
||||
: name))
|
||||
.ToArray();
|
||||
|
||||
return new StaffMemberResponse(
|
||||
person.Id,
|
||||
person.Name.Full,
|
||||
person.Female,
|
||||
person.AgeOn(asOf),
|
||||
person.IsParent,
|
||||
person.Position ?? string.Empty,
|
||||
PeopleListMapper.PositionLabel(catalog, locale, person.Position) ?? person.Position ?? string.Empty,
|
||||
ask,
|
||||
pay,
|
||||
subjects);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ using HSchool.Simulation;
|
||||
namespace HSchool.Server.Api;
|
||||
|
||||
/// <summary>
|
||||
/// The main menu talks to these: list, create, delete. The people list reads a published roster
|
||||
/// snapshot; the person card goes through the school's mailbox because needs are live.
|
||||
/// The main menu talks to these: list, create, delete. People list and staffing read published
|
||||
/// snapshots; the person card, hire and subject changes go through the school's mailbox.
|
||||
/// </summary>
|
||||
internal static class SchoolEndpoints
|
||||
{
|
||||
@@ -156,6 +156,86 @@ internal static class SchoolEndpoints
|
||||
};
|
||||
})
|
||||
.WithName("GetSchoolPerson");
|
||||
|
||||
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>
|
||||
@@ -279,11 +359,100 @@ internal static class SchoolEndpoints
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IResult Problem(int statusCode, string code, string detail) =>
|
||||
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?>
|
||||
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)
|
||||
{
|
||||
["code"] = code,
|
||||
});
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Server.Api;
|
||||
using HSchool.Simulation;
|
||||
|
||||
@@ -50,4 +51,22 @@ internal abstract record GameCommand
|
||||
string PersonId,
|
||||
string Locale,
|
||||
TaskCompletionSource<PersonCardResult> Result) : GameCommand;
|
||||
|
||||
internal sealed record HireStaff(
|
||||
int SchoolId,
|
||||
string PersonId,
|
||||
string Position,
|
||||
TaskCompletionSource<StaffingOutcome> Result) : GameCommand;
|
||||
|
||||
internal sealed record AssignSubject(
|
||||
int SchoolId,
|
||||
string PersonId,
|
||||
string Subject,
|
||||
TaskCompletionSource<StaffingOutcome> Result) : GameCommand;
|
||||
|
||||
internal sealed record UnassignSubject(
|
||||
int SchoolId,
|
||||
string PersonId,
|
||||
string Subject,
|
||||
TaskCompletionSource<StaffingOutcome> Result) : GameCommand;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,27 @@ internal sealed class GameLoopService(
|
||||
case GameCommand.GetPerson getPerson:
|
||||
HandleGetPerson(getPerson);
|
||||
break;
|
||||
|
||||
case GameCommand.HireStaff hire:
|
||||
HandleStaffing(
|
||||
hire.SchoolId,
|
||||
new WorkerCommand.HireStaff(hire.PersonId, hire.Position, hire.Result),
|
||||
hire.Result);
|
||||
break;
|
||||
|
||||
case GameCommand.AssignSubject assign:
|
||||
HandleStaffing(
|
||||
assign.SchoolId,
|
||||
new WorkerCommand.AssignSubject(assign.PersonId, assign.Subject, assign.Result),
|
||||
assign.Result);
|
||||
break;
|
||||
|
||||
case GameCommand.UnassignSubject unassign:
|
||||
HandleStaffing(
|
||||
unassign.SchoolId,
|
||||
new WorkerCommand.UnassignSubject(unassign.PersonId, unassign.Subject, unassign.Result),
|
||||
unassign.Result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +204,14 @@ internal sealed class GameLoopService(
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource<StaffingOutcome> result)
|
||||
{
|
||||
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
|
||||
{
|
||||
result.TrySetResult(Staffing.UnknownSchool());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A school's thread died. Drop it from the table so the menu stops drawing a card whose clock
|
||||
/// never moves again, and tell anybody watching it to go back to the menu. The save file stays
|
||||
|
||||
@@ -342,10 +342,7 @@ internal sealed class SchoolWorker
|
||||
{
|
||||
while (_mailbox.Reader.TryRead(out var orphan))
|
||||
{
|
||||
if (orphan is WorkerCommand.GetPerson getPerson)
|
||||
{
|
||||
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
|
||||
}
|
||||
CompleteOrphan(orphan);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -393,14 +390,23 @@ internal sealed class SchoolWorker
|
||||
? new PersonCardResult(null, PersonLookupError.UnknownPerson)
|
||||
: new PersonCardResult(card, PersonLookupError.None));
|
||||
break;
|
||||
|
||||
case WorkerCommand.HireStaff hire:
|
||||
hire.Result.TrySetResult(ApplyHire(school, hire.PersonId, hire.Position));
|
||||
break;
|
||||
|
||||
case WorkerCommand.AssignSubject assign:
|
||||
assign.Result.TrySetResult(ApplyAssign(school, assign.PersonId, assign.Subject));
|
||||
break;
|
||||
|
||||
case WorkerCommand.UnassignSubject unassign:
|
||||
unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject));
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (command is WorkerCommand.GetPerson failed)
|
||||
{
|
||||
failed.Result.TrySetException(ex);
|
||||
}
|
||||
FailCommand(command, ex);
|
||||
|
||||
_logger.LogError(
|
||||
ex,
|
||||
@@ -421,6 +427,76 @@ internal sealed class SchoolWorker
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompleteOrphan(WorkerCommand command)
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case WorkerCommand.GetPerson getPerson:
|
||||
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
|
||||
break;
|
||||
case WorkerCommand.HireStaff hire:
|
||||
hire.Result.TrySetResult(Staffing.UnknownSchool());
|
||||
break;
|
||||
case WorkerCommand.AssignSubject assign:
|
||||
assign.Result.TrySetResult(Staffing.UnknownSchool());
|
||||
break;
|
||||
case WorkerCommand.UnassignSubject unassign:
|
||||
unassign.Result.TrySetResult(Staffing.UnknownSchool());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void FailCommand(WorkerCommand command, Exception exception)
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case WorkerCommand.GetPerson getPerson:
|
||||
getPerson.Result.TrySetException(exception);
|
||||
break;
|
||||
case WorkerCommand.HireStaff hire:
|
||||
hire.Result.TrySetException(exception);
|
||||
break;
|
||||
case WorkerCommand.AssignSubject assign:
|
||||
assign.Result.TrySetException(exception);
|
||||
break;
|
||||
case WorkerCommand.UnassignSubject unassign:
|
||||
unassign.Result.TrySetException(exception);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private StaffingOutcome ApplyHire(School school, string personId, string position) =>
|
||||
ApplyStaffingChange(school, (catalog, roster, pool) =>
|
||||
Staffing.Hire(catalog, school.Map, roster, pool, personId, position, _options.MonthlyPayrollCap));
|
||||
|
||||
private StaffingOutcome ApplyAssign(School school, string personId, string subject) =>
|
||||
ApplyStaffingChange(school, (catalog, roster, pool) =>
|
||||
Staffing.AssignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap));
|
||||
|
||||
private StaffingOutcome ApplyUnassign(School school, string personId, string subject) =>
|
||||
ApplyStaffingChange(school, (catalog, roster, pool) =>
|
||||
Staffing.UnassignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap));
|
||||
|
||||
private StaffingOutcome ApplyStaffingChange(
|
||||
School school,
|
||||
Func<DefCatalog, Roster, ApplicantPool, StaffingOutcome> apply)
|
||||
{
|
||||
if (school.Roster is null || school.Applicants is null || school.Catalog is null)
|
||||
{
|
||||
return Staffing.UnknownSchool();
|
||||
}
|
||||
|
||||
var outcome = apply(school.Catalog, school.Roster, school.Applicants);
|
||||
if (outcome.Error == StaffingError.None)
|
||||
{
|
||||
school.ApplyStaffing(outcome.Roster, outcome.Pool);
|
||||
PersistPeople();
|
||||
PublishSnapshot();
|
||||
}
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes pause/speed changes, at most once per <see cref="SimulationOptions.MinSaveInterval"/>.
|
||||
/// A single click still lands within that window; a burst collapses into one write.
|
||||
@@ -459,7 +535,7 @@ internal sealed class SchoolWorker
|
||||
|
||||
/// <summary>
|
||||
/// Writes the composition file. Not called from the 30-second clock save — the roster and
|
||||
/// applicant pool change on create, weekly refresh and yearly intake, not every tick.
|
||||
/// applicant pool change on create, hire, weekly refresh and yearly intake, not every tick.
|
||||
/// </summary>
|
||||
private void PersistPeople()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using HSchool.People;
|
||||
using HSchool.Server.Api;
|
||||
using HSchool.Server.Net;
|
||||
|
||||
@@ -21,4 +22,19 @@ internal abstract record WorkerCommand
|
||||
string PersonId,
|
||||
string Locale,
|
||||
TaskCompletionSource<PersonCardResult> Result) : WorkerCommand;
|
||||
|
||||
internal sealed record HireStaff(
|
||||
string PersonId,
|
||||
string Position,
|
||||
TaskCompletionSource<StaffingOutcome> Result) : WorkerCommand;
|
||||
|
||||
internal sealed record AssignSubject(
|
||||
string PersonId,
|
||||
string Subject,
|
||||
TaskCompletionSource<StaffingOutcome> Result) : WorkerCommand;
|
||||
|
||||
internal sealed record UnassignSubject(
|
||||
string PersonId,
|
||||
string Subject,
|
||||
TaskCompletionSource<StaffingOutcome> Result) : WorkerCommand;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ builder.Services
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.")
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.ModsDirectory), "Simulation:ModsDirectory must be set.")
|
||||
.Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.")
|
||||
.Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.")
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton<GameCommandQueue>();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"DefaultStartDate": "2012-03-31T06:00:00",
|
||||
"SavesDirectory": "saves",
|
||||
"ModsDirectory": "mods",
|
||||
"SaveIntervalSeconds": 30
|
||||
"SaveIntervalSeconds": 30,
|
||||
"MonthlyPayrollCap": 10000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,7 @@
|
||||
"parentChance": 0.35,
|
||||
"hourlyWageBase": 30,
|
||||
"hourlyWagePerSkill": 0.6,
|
||||
"baseWeeklyHours": 20,
|
||||
"weeksPerMonth": 4,
|
||||
"extraSubjectSurcharge": 0.25,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user