Implement applicant pool functionality in school simulation, allowing for the management of job seekers who are not yet part of the school roster. Update the catalog to include staffing definitions and enhance the API to support applicant data retrieval. Revise the school architecture to handle applicant refresh logic and ensure proper integration with existing roster management. Update tests to validate the new applicant functionalities and ensure robustness in handling staffing scenarios.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
namespace HSchool.People;
|
||||
|
||||
/// <summary>One person looking for work at this school, with the hourly rate they named.</summary>
|
||||
public sealed record Applicant(Person Person, float HourlyWageAsk);
|
||||
|
||||
/// <summary>
|
||||
/// School-wide applicant list. Not the roster: a new candidate lives only here until hired.
|
||||
/// A parent already in the roster may appear with the same id — that is the same person.
|
||||
/// </summary>
|
||||
public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applicant> Applicants)
|
||||
{
|
||||
public static ApplicantPool Empty { get; } = new(0, 0, []);
|
||||
|
||||
public static ApplicantPool Create(
|
||||
DefCatalog catalog,
|
||||
Roster roster,
|
||||
int schoolSeed,
|
||||
string nameSetId,
|
||||
DateTime asOf)
|
||||
{
|
||||
var week = ApplicantWeeks.Id(asOf);
|
||||
return Fill(catalog, roster, schoolSeed, nameSetId, asOf, new ApplicantPool(week, 0, []));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks each week from <see cref="Week"/> exclusive to <paramref name="asOf"/> inclusive.
|
||||
/// Same previous list, seed and week always yields the same stays and the same newcomers.
|
||||
/// </summary>
|
||||
public ApplicantPool Advance(
|
||||
DefCatalog catalog,
|
||||
Roster roster,
|
||||
int schoolSeed,
|
||||
string nameSetId,
|
||||
DateTime asOf)
|
||||
{
|
||||
var target = ApplicantWeeks.Id(asOf);
|
||||
if (target <= Week)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
var pool = this;
|
||||
for (var week = Week + 1; week <= target; week++)
|
||||
{
|
||||
pool = Step(pool, catalog, roster, schoolSeed, nameSetId, asOf, week);
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
public static float HourlyAsk(DefCatalog catalog, Person person)
|
||||
{
|
||||
var rules = catalog.StaffingRules
|
||||
?? throw new InvalidOperationException("The catalog has no StaffingDef.");
|
||||
|
||||
var mean = 0d;
|
||||
if (person.Skills.Count > 0)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var value in person.Skills.Values)
|
||||
{
|
||||
total += value;
|
||||
}
|
||||
|
||||
mean = total / (double)person.Skills.Count;
|
||||
}
|
||||
|
||||
var trait = 0f;
|
||||
foreach (var name in person.Traits)
|
||||
{
|
||||
if (catalog.Traits.TryGetValue(name, out var def))
|
||||
{
|
||||
trait += def.WageAsk;
|
||||
}
|
||||
}
|
||||
|
||||
var ask = rules.HourlyWageBase + (float)(rules.HourlyWagePerSkill * mean) + trait;
|
||||
return MathF.Round(MathF.Max(ask, 0f), 2);
|
||||
}
|
||||
|
||||
private static ApplicantPool Step(
|
||||
ApplicantPool current,
|
||||
DefCatalog catalog,
|
||||
Roster roster,
|
||||
int schoolSeed,
|
||||
string nameSetId,
|
||||
DateTime asOf,
|
||||
int week)
|
||||
{
|
||||
var rules = RequireRules(catalog);
|
||||
var rng = new Random(Seed.Mix(schoolSeed, week, Seed.ApplicantSalt));
|
||||
var staying = new List<Applicant>(rules.PoolSize);
|
||||
foreach (var applicant in current.Applicants)
|
||||
{
|
||||
if (rng.NextDouble() < rules.StayChance)
|
||||
{
|
||||
staying.Add(applicant);
|
||||
}
|
||||
}
|
||||
|
||||
return Fill(
|
||||
catalog,
|
||||
roster,
|
||||
schoolSeed,
|
||||
nameSetId,
|
||||
asOf,
|
||||
new ApplicantPool(week, current.NextIndex, staying),
|
||||
rng);
|
||||
}
|
||||
|
||||
private static ApplicantPool Fill(
|
||||
DefCatalog catalog,
|
||||
Roster roster,
|
||||
int schoolSeed,
|
||||
string nameSetId,
|
||||
DateTime asOf,
|
||||
ApplicantPool current,
|
||||
Random? rng = null)
|
||||
{
|
||||
var rules = RequireRules(catalog);
|
||||
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
|
||||
{
|
||||
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
|
||||
}
|
||||
|
||||
rng ??= new Random(Seed.Mix(schoolSeed, current.Week, Seed.ApplicantSalt));
|
||||
var applicants = new List<Applicant>(rules.PoolSize);
|
||||
var taken = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var applicant in current.Applicants)
|
||||
{
|
||||
if (applicants.Count >= rules.PoolSize || !taken.Add(applicant.Person.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
applicants.Add(applicant);
|
||||
}
|
||||
|
||||
var eligible = roster.People
|
||||
.Where(person => person.IsParent && !person.IsStaff && !taken.Contains(person.Id))
|
||||
.OrderBy(person => person.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var nextIndex = current.NextIndex;
|
||||
while (applicants.Count < rules.PoolSize)
|
||||
{
|
||||
Applicant next;
|
||||
if (eligible.Count > 0 && rng.NextDouble() < rules.ParentChance)
|
||||
{
|
||||
var pick = rng.Next(eligible.Count);
|
||||
var parent = eligible[pick];
|
||||
eligible.RemoveAt(pick);
|
||||
taken.Add(parent.Id);
|
||||
next = new Applicant(parent, HourlyAsk(catalog, parent));
|
||||
}
|
||||
else
|
||||
{
|
||||
var (_, person) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextIndex, asOf);
|
||||
nextIndex++;
|
||||
taken.Add(person.Id);
|
||||
next = new Applicant(person, HourlyAsk(catalog, person));
|
||||
}
|
||||
|
||||
applicants.Add(next);
|
||||
}
|
||||
|
||||
return new ApplicantPool(current.Week, nextIndex, applicants);
|
||||
}
|
||||
|
||||
private static StaffingDef RequireRules(DefCatalog catalog) =>
|
||||
catalog.StaffingRules
|
||||
?? throw new InvalidOperationException("The catalog has no StaffingDef.");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace HSchool.People;
|
||||
|
||||
/// <summary>
|
||||
/// Monday-based week index from a fixed UTC epoch. The pool refreshes when this value increases,
|
||||
/// so a school created on Saturday keeps its first list through the weekend.
|
||||
/// </summary>
|
||||
internal static class ApplicantWeeks
|
||||
{
|
||||
private static readonly DateTime EpochMonday = new(2000, 1, 3, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public static int Id(DateTime asOf)
|
||||
{
|
||||
var utc = DateTime.SpecifyKind(asOf, DateTimeKind.Utc).Date;
|
||||
var days = (int)(utc - EpochMonday).TotalDays;
|
||||
return days >= 0 ? days / 7 : (days - 6) / 7;
|
||||
}
|
||||
}
|
||||
@@ -199,19 +199,20 @@ internal static class FamilyFactory
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One adult who lives alone and works at the school. Deliberately not a couple: the top-up
|
||||
/// loop counts openings one at a time, so a two-adult household overshot an odd deficit and
|
||||
/// left behind an adult who was neither staff, parent nor pupil — invisible to every filter.
|
||||
/// One adult who is not on the school roster. Used for the applicant pool: a new candidate
|
||||
/// gets a family id under <paramref name="idPrefix"/> so it can never collide with roster
|
||||
/// households (<c>fN</c>).
|
||||
/// </summary>
|
||||
public static (Family Family, Person Member) CreateStaffOnly(
|
||||
DefCatalog catalog,
|
||||
NameSetDef names,
|
||||
int schoolSeed,
|
||||
int familyIndex,
|
||||
DateTime asOf)
|
||||
DateTime asOf,
|
||||
string idPrefix = "a")
|
||||
{
|
||||
var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.AppearanceSalt));
|
||||
var familyId = $"f{familyIndex}";
|
||||
var familyId = $"{idPrefix}{familyIndex}";
|
||||
var surname = names.Surnames[rng.Next(names.Surnames.Count)];
|
||||
var female = rng.Next(2) == 0;
|
||||
var given = PickGiven(female ? names.FemaleGiven : names.MaleGiven, rng);
|
||||
|
||||
@@ -3,6 +3,7 @@ namespace HSchool.People;
|
||||
/// <summary>
|
||||
/// Whether a generated or loaded roster still fills this map. A mismatch means the file is stale
|
||||
/// relative to the layout — the school must not start and the file must stay untouched.
|
||||
/// Staff openings are the player's to fill; only pupil seats have to be occupied.
|
||||
/// </summary>
|
||||
public static class RosterFit
|
||||
{
|
||||
@@ -24,24 +25,6 @@ public static class RosterFit
|
||||
}
|
||||
}
|
||||
|
||||
var staffed = roster.People
|
||||
.Where(person => person.IsStaff && person.Position is not null && person.WorkplaceRoomId is not null)
|
||||
.Select(person => new StaffOpening(person.WorkplaceRoomId!, person.Position!))
|
||||
.ToHashSet();
|
||||
|
||||
if (staffed.Count != demand.Staff.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var opening in demand.Staff)
|
||||
{
|
||||
if (!staffed.Contains(opening))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,20 +41,8 @@ public static class RosterGenerator
|
||||
people.AddRange(members);
|
||||
}
|
||||
|
||||
var nextFamily = plans.Count;
|
||||
var adults = people.Count(person => !person.IsStudent);
|
||||
while (adults < demand.Staff.Count)
|
||||
{
|
||||
var (family, member) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextFamily, when);
|
||||
families.Add(family);
|
||||
people.Add(member);
|
||||
nextFamily++;
|
||||
adults++;
|
||||
}
|
||||
|
||||
var staffed = AssignStaff(people, demand.Staff);
|
||||
var classes = FillClasses(demand.Classes, staffed);
|
||||
return new Roster(staffed, families, classes);
|
||||
var classes = FillClasses(demand.Classes, people);
|
||||
return new Roster(people, families, classes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -76,48 +64,6 @@ public static class RosterGenerator
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Person> AssignStaff(List<Person> people, IReadOnlyList<StaffOpening> openings)
|
||||
{
|
||||
if (openings.Count == 0)
|
||||
{
|
||||
return people;
|
||||
}
|
||||
|
||||
var jobs = new Dictionary<string, StaffOpening>(StringComparer.Ordinal);
|
||||
var index = 0;
|
||||
foreach (var person in people)
|
||||
{
|
||||
if (person.IsStudent || index >= openings.Count)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
jobs[person.Id] = openings[index];
|
||||
index++;
|
||||
}
|
||||
|
||||
if (jobs.Count == 0)
|
||||
{
|
||||
return people;
|
||||
}
|
||||
|
||||
var result = new Person[people.Count];
|
||||
for (var i = 0; i < people.Count; i++)
|
||||
{
|
||||
var person = people[i];
|
||||
result[i] = jobs.TryGetValue(person.Id, out var job)
|
||||
? person with
|
||||
{
|
||||
IsStaff = true,
|
||||
Position = job.Position,
|
||||
WorkplaceRoomId = job.RoomId,
|
||||
}
|
||||
: person;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SchoolClass> FillClasses(
|
||||
IReadOnlyList<SchoolClass> classes,
|
||||
IReadOnlyList<Person> people)
|
||||
|
||||
@@ -18,9 +18,11 @@ public sealed class RosterDocument
|
||||
|
||||
public required IReadOnlyList<SchoolClass> Classes { get; init; }
|
||||
|
||||
public ApplicantPool? Applicants { get; init; }
|
||||
|
||||
public Roster ToRoster() => new(People, Families, Classes);
|
||||
|
||||
public static RosterDocument From(int seed, Roster roster) =>
|
||||
public static RosterDocument From(int seed, Roster roster, ApplicantPool? applicants = null) =>
|
||||
new()
|
||||
{
|
||||
Format = CurrentFormat,
|
||||
@@ -28,6 +30,7 @@ public sealed class RosterDocument
|
||||
People = roster.People,
|
||||
Families = roster.Families,
|
||||
Classes = roster.Classes,
|
||||
Applicants = applicants,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ internal static class Seed
|
||||
public const int IntakeSalt = 3;
|
||||
public const int SeatShuffleSalt = 4;
|
||||
public const int HouseholdSalt = 5;
|
||||
public const int ApplicantSalt = 6;
|
||||
|
||||
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
||||
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
||||
|
||||
Reference in New Issue
Block a user