Vanilla catalogs stay without crushes; a content pack ships the overlay so later mods can reuse it.
192 lines
6.1 KiB
C#
192 lines
6.1 KiB
C#
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 countryId,
|
|
DateTime asOf,
|
|
string? nativeLanguage = null)
|
|
{
|
|
var week = ApplicantWeeks.Id(asOf);
|
|
return Fill(catalog, roster, schoolSeed, countryId, asOf, new ApplicantPool(week, 0, []), nativeLanguage: nativeLanguage);
|
|
}
|
|
|
|
/// <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 countryId,
|
|
DateTime asOf,
|
|
string? nativeLanguage = null)
|
|
{
|
|
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, countryId, asOf, week, nativeLanguage);
|
|
}
|
|
|
|
return pool;
|
|
}
|
|
|
|
public ApplicantPool Without(string personId) =>
|
|
this with
|
|
{
|
|
Applicants = Applicants
|
|
.Where(applicant => !applicant.Person.Id.Equals(personId, StringComparison.Ordinal))
|
|
.ToArray(),
|
|
};
|
|
|
|
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 countryId,
|
|
DateTime asOf,
|
|
int week,
|
|
string? nativeLanguage)
|
|
{
|
|
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,
|
|
countryId,
|
|
asOf,
|
|
new ApplicantPool(week, current.NextIndex, staying),
|
|
rng,
|
|
nativeLanguage);
|
|
}
|
|
|
|
private static ApplicantPool Fill(
|
|
DefCatalog catalog,
|
|
Roster roster,
|
|
int schoolSeed,
|
|
string countryId,
|
|
DateTime asOf,
|
|
ApplicantPool current,
|
|
Random? rng = null,
|
|
string? nativeLanguage = null)
|
|
{
|
|
var rules = RequireRules(catalog);
|
|
if (!catalog.TryGetCountryNames(countryId, out var names))
|
|
{
|
|
throw new ArgumentException($"Unknown country '{countryId}'.", nameof(countryId));
|
|
}
|
|
|
|
var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: false);
|
|
|
|
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, nativeLanguage: native);
|
|
nextIndex++;
|
|
taken.Add(person.Id);
|
|
next = new Applicant(person, HourlyAsk(catalog, person));
|
|
}
|
|
|
|
applicants.Add(next);
|
|
}
|
|
|
|
return OrientationGenerator.AssignPool(
|
|
catalog,
|
|
new ApplicantPool(current.Week, nextIndex, applicants),
|
|
schoolSeed);
|
|
}
|
|
|
|
private static StaffingDef RequireRules(DefCatalog catalog) =>
|
|
catalog.StaffingRules
|
|
?? throw new InvalidOperationException("The catalog has no StaffingDef.");
|
|
}
|