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:
@@ -12,26 +12,26 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] `RosterGenerator` больше не закрывает должности: новая школа получает учеников, родителей
|
- [x] `RosterGenerator` больше не закрывает должности: новая школа получает учеников, родителей
|
||||||
и ноль сотрудников
|
и ноль сотрудников
|
||||||
- [ ] Пул соискателей в `HSchool.People`: список полноценных `Person`, детерминированный от сида
|
- [x] Пул соискателей в `HSchool.People`: список полноценных `Person`, детерминированный от сида
|
||||||
школы и номера недели
|
школы и номера недели
|
||||||
- [ ] Соискателем может стать родитель ученика — тот же человек, без второй сущности
|
- [x] Соискателем может стать родитель ученика — тот же человек, без второй сущности
|
||||||
- [ ] Базовый запрос по зарплате считается из навыков соискателя; шкала — в данных, не в коде
|
- [x] Базовый запрос по зарплате считается из навыков соискателя; шкала — в данных, не в коде
|
||||||
- [ ] Еженедельное обновление: часть уходит, часть остаётся с теми же именами и запросом,
|
- [x] Еженедельное обновление: часть уходит, часть остаётся с теми же именами и запросом,
|
||||||
приходят новые. Полная замена запрещена
|
приходят новые. Полная замена запрещена
|
||||||
- [ ] Пул живёт в `saves/{id}.people.json` рядом с ростером и пишется по тем же правилам —
|
- [x] Пул живёт в `saves/{id}.people.json` рядом с ростером и пишется по тем же правилам —
|
||||||
при изменении состава, не по таймеру
|
при изменении состава, не по таймеру
|
||||||
- [ ] Воркер публикует пул вместе со снимком ростера
|
- [x] Воркер публикует пул вместе со снимком ростера
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Тот же сид и та же неделя — тот же пул
|
- [x] Тот же сид и та же неделя — тот же пул
|
||||||
- [ ] После обновления часть людей та же самая, с теми же навыками и тем же запросом
|
- [x] После обновления часть людей та же самая, с теми же навыками и тем же запросом
|
||||||
- [ ] Пул не пустеет и не растёт бесконечно за пятьдесят недель
|
- [x] Пул не пустеет и не растёт бесконечно за пятьдесят недель
|
||||||
- [ ] Запрос по зарплате у сильного кандидата выше, чем у слабого
|
- [x] Запрос по зарплате у сильного кандидата выше, чем у слабого
|
||||||
- [ ] Новая школа не имеет ни одного сотрудника, но все ученические места заняты
|
- [x] Новая школа не имеет ни одного сотрудника, но все ученические места заняты
|
||||||
- [ ] Соискатель не попадает в ростер школы, пока не нанят
|
- [x] Соискатель не попадает в ростер школы, пока не нанят
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [10. Предметы и мебель](10-subjects.md) | ✅ | `SubjectDef`, одна учительская должность, кабинет как число мест |
|
| [10. Предметы и мебель](10-subjects.md) | ✅ | `SubjectDef`, одна учительская должность, кабинет как число мест |
|
||||||
| [11. Пустая школа и пул](11-applicants.md) | ⬜ | Школа без сотрудников, соискатели с запросом по зарплате |
|
| [11. Пустая школа и пул](11-applicants.md) | ✅ | Школа без сотрудников, соискатели с запросом по зарплате |
|
||||||
| [12. Наём и бюджет](12-hiring-budget.md) | ⬜ | Наём, назначение предметов, предел фонда оплаты |
|
| [12. Наём и бюджет](12-hiring-budget.md) | ⬜ | Наём, назначение предметов, предел фонда оплаты |
|
||||||
| [13. Раздел «Управление»](13-management-tab.md) | ⬜ | Деньги, соискатели, штат и назначения на экране |
|
| [13. Раздел «Управление»](13-management-tab.md) | ⬜ | Деньги, соискатели, штат и назначения на экране |
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ are `400` `invalid-query`. `lang` is `ru` or `en`, same as the catalog — anyth
|
|||||||
The list row is identity only — skills, traits and needs stay on the card. Age is computed from
|
The list row is identity only — skills, traits and needs stay on the card. Age is computed from
|
||||||
`birthDate` and the published game time, not from the World.
|
`birthDate` and the published game time, not from the World.
|
||||||
|
|
||||||
|
Applicants live in `saves/{id}.people.json` next to the roster and are **not** in this list.
|
||||||
|
They are not school staff until hired. A parent who is also looking for work keeps the same id
|
||||||
|
in both places.
|
||||||
|
|
||||||
### `GET /api/schools/{id}/people/{personId}`
|
### `GET /api/schools/{id}/people/{personId}`
|
||||||
|
|
||||||
One person's card. Goes through the school's mailbox because need values live on entities and
|
One person's card. Goes through the school's mailbox because need values live on entities and
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ public sealed class CatalogLoader
|
|||||||
var needs = new Dictionary<string, NeedDef>(StringComparer.Ordinal);
|
var needs = new Dictionary<string, NeedDef>(StringComparer.Ordinal);
|
||||||
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal);
|
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal);
|
||||||
var subjects = new Dictionary<string, SubjectDef>(StringComparer.Ordinal);
|
var subjects = new Dictionary<string, SubjectDef>(StringComparer.Ordinal);
|
||||||
|
var staffing = new Dictionary<string, StaffingDef>(StringComparer.Ordinal);
|
||||||
|
|
||||||
foreach (var (key, json) in resolved)
|
foreach (var (key, json) in resolved)
|
||||||
{
|
{
|
||||||
@@ -355,6 +356,9 @@ public sealed class CatalogLoader
|
|||||||
case DefKind.Subject:
|
case DefKind.Subject:
|
||||||
subjects[key.Name] = Jsonc.Deserialize<SubjectDef>(json);
|
subjects[key.Name] = Jsonc.Deserialize<SubjectDef>(json);
|
||||||
break;
|
break;
|
||||||
|
case DefKind.Staffing:
|
||||||
|
staffing[key.Name] = Jsonc.Deserialize<StaffingDef>(json);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +378,7 @@ public sealed class CatalogLoader
|
|||||||
needs,
|
needs,
|
||||||
nameSets,
|
nameSets,
|
||||||
subjects,
|
subjects,
|
||||||
|
staffing,
|
||||||
ru,
|
ru,
|
||||||
en);
|
en);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed class DefCatalog
|
|||||||
IReadOnlyDictionary<string, NeedDef> needs,
|
IReadOnlyDictionary<string, NeedDef> needs,
|
||||||
IReadOnlyDictionary<string, NameSetDef> nameSets,
|
IReadOnlyDictionary<string, NameSetDef> nameSets,
|
||||||
IReadOnlyDictionary<string, SubjectDef> subjects,
|
IReadOnlyDictionary<string, SubjectDef> subjects,
|
||||||
|
IReadOnlyDictionary<string, StaffingDef> staffing,
|
||||||
IReadOnlyDictionary<string, string> ru,
|
IReadOnlyDictionary<string, string> ru,
|
||||||
IReadOnlyDictionary<string, string> en)
|
IReadOnlyDictionary<string, string> en)
|
||||||
{
|
{
|
||||||
@@ -40,6 +41,7 @@ public sealed class DefCatalog
|
|||||||
Needs = needs;
|
Needs = needs;
|
||||||
NameSets = nameSets;
|
NameSets = nameSets;
|
||||||
Subjects = subjects;
|
Subjects = subjects;
|
||||||
|
Staffing = staffing;
|
||||||
_ru = ru;
|
_ru = ru;
|
||||||
_en = en;
|
_en = en;
|
||||||
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
|
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
|
||||||
@@ -82,6 +84,11 @@ public sealed class DefCatalog
|
|||||||
|
|
||||||
public IReadOnlyDictionary<string, SubjectDef> Subjects { get; }
|
public IReadOnlyDictionary<string, SubjectDef> Subjects { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, StaffingDef> Staffing { get; }
|
||||||
|
|
||||||
|
/// <summary>The one concrete staffing ruleset, or null when a pack has not defined it.</summary>
|
||||||
|
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
|
||||||
|
|
||||||
private readonly IReadOnlyDictionary<string, string> _ru;
|
private readonly IReadOnlyDictionary<string, string> _ru;
|
||||||
private readonly IReadOnlyDictionary<string, string> _en;
|
private readonly IReadOnlyDictionary<string, string> _en;
|
||||||
|
|
||||||
@@ -103,6 +110,7 @@ public sealed class DefCatalog
|
|||||||
DefKind.Need => Needs.GetValueOrDefault(defName),
|
DefKind.Need => Needs.GetValueOrDefault(defName),
|
||||||
DefKind.NameSet => NameSets.GetValueOrDefault(defName),
|
DefKind.NameSet => NameSets.GetValueOrDefault(defName),
|
||||||
DefKind.Subject => Subjects.GetValueOrDefault(defName),
|
DefKind.Subject => Subjects.GetValueOrDefault(defName),
|
||||||
|
DefKind.Staffing => Staffing.GetValueOrDefault(defName),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,6 +185,7 @@ public sealed class DefCatalog
|
|||||||
NeedDef => DefKind.Need,
|
NeedDef => DefKind.Need,
|
||||||
NameSetDef => DefKind.NameSet,
|
NameSetDef => DefKind.NameSet,
|
||||||
SubjectDef => DefKind.Subject,
|
SubjectDef => DefKind.Subject,
|
||||||
|
StaffingDef => DefKind.Staffing,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(def)),
|
_ => throw new ArgumentOutOfRangeException(nameof(def)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public enum DefKind
|
|||||||
Need,
|
Need,
|
||||||
NameSet,
|
NameSet,
|
||||||
Subject,
|
Subject,
|
||||||
|
Staffing,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
|
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ internal static class PackPaths
|
|||||||
case "subjects":
|
case "subjects":
|
||||||
kind = DefKind.Subject;
|
kind = DefKind.Subject;
|
||||||
return true;
|
return true;
|
||||||
|
case "staffing":
|
||||||
|
kind = DefKind.Staffing;
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
kind = default;
|
kind = default;
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ internal static class PeopleDefValidator
|
|||||||
ValidateSubject(subject, catalog);
|
ValidateSubject(subject, catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var staffing in catalog.Staffing.Values)
|
||||||
|
{
|
||||||
|
ValidateStaffing(staffing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException("A catalog may only have one concrete StaffingDef.");
|
||||||
|
}
|
||||||
|
|
||||||
RequireBuildInputs(catalog);
|
RequireBuildInputs(catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +270,34 @@ internal static class PeopleDefValidator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ValidateStaffing(StaffingDef staffing)
|
||||||
|
{
|
||||||
|
if (staffing.Abstract)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staffing.PoolSize < 1 || staffing.PoolSize > 64)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' poolSize must be 1–64.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staffing.StayChance <= 0f || staffing.StayChance >= 1f)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' stayChance must be between 0 and 1 exclusive.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staffing.ParentChance < 0f || staffing.ParentChance > 1f)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' parentChance must be 0–1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staffing.HourlyWageBase < 0f || staffing.HourlyWagePerSkill < 0f)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"StaffingDef '{staffing.DefName}' wage scale cannot be negative.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ValidateNameSet(NameSetDef names)
|
private static void ValidateNameSet(NameSetDef names)
|
||||||
{
|
{
|
||||||
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
|
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
|
||||||
|
|||||||
@@ -157,6 +157,24 @@ public sealed class TraitDef : Def
|
|||||||
public IntRange? Age { get; init; }
|
public IntRange? Age { get; init; }
|
||||||
|
|
||||||
public IReadOnlyList<TraitSkillModifier> SkillModifiers { get; init; } = [];
|
public IReadOnlyList<TraitSkillModifier> SkillModifiers { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Added to the hourly wage ask. Positive means the person wants more at the same skills.
|
||||||
|
/// </summary>
|
||||||
|
public float WageAsk { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StaffingDef : Def
|
||||||
|
{
|
||||||
|
public int PoolSize { get; init; }
|
||||||
|
|
||||||
|
public float StayChance { get; init; }
|
||||||
|
|
||||||
|
public float ParentChance { get; init; }
|
||||||
|
|
||||||
|
public float HourlyWageBase { get; init; }
|
||||||
|
|
||||||
|
public float HourlyWagePerSkill { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum BodyAttributeKind
|
public enum BodyAttributeKind
|
||||||
|
|||||||
@@ -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>
|
/// <summary>
|
||||||
/// One adult who lives alone and works at the school. Deliberately not a couple: the top-up
|
/// One adult who is not on the school roster. Used for the applicant pool: a new candidate
|
||||||
/// loop counts openings one at a time, so a two-adult household overshot an odd deficit and
|
/// gets a family id under <paramref name="idPrefix"/> so it can never collide with roster
|
||||||
/// left behind an adult who was neither staff, parent nor pupil — invisible to every filter.
|
/// households (<c>fN</c>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static (Family Family, Person Member) CreateStaffOnly(
|
public static (Family Family, Person Member) CreateStaffOnly(
|
||||||
DefCatalog catalog,
|
DefCatalog catalog,
|
||||||
NameSetDef names,
|
NameSetDef names,
|
||||||
int schoolSeed,
|
int schoolSeed,
|
||||||
int familyIndex,
|
int familyIndex,
|
||||||
DateTime asOf)
|
DateTime asOf,
|
||||||
|
string idPrefix = "a")
|
||||||
{
|
{
|
||||||
var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.AppearanceSalt));
|
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 surname = names.Surnames[rng.Next(names.Surnames.Count)];
|
||||||
var female = rng.Next(2) == 0;
|
var female = rng.Next(2) == 0;
|
||||||
var given = PickGiven(female ? names.FemaleGiven : names.MaleGiven, rng);
|
var given = PickGiven(female ? names.FemaleGiven : names.MaleGiven, rng);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ namespace HSchool.People;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether a generated or loaded roster still fills this map. A mismatch means the file is stale
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
public static class RosterFit
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,20 +41,8 @@ public static class RosterGenerator
|
|||||||
people.AddRange(members);
|
people.AddRange(members);
|
||||||
}
|
}
|
||||||
|
|
||||||
var nextFamily = plans.Count;
|
var classes = FillClasses(demand.Classes, people);
|
||||||
var adults = people.Count(person => !person.IsStudent);
|
return new Roster(people, families, classes);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -76,48 +64,6 @@ public static class RosterGenerator
|
|||||||
return shuffled;
|
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(
|
private static IReadOnlyList<SchoolClass> FillClasses(
|
||||||
IReadOnlyList<SchoolClass> classes,
|
IReadOnlyList<SchoolClass> classes,
|
||||||
IReadOnlyList<Person> people)
|
IReadOnlyList<Person> people)
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ public sealed class RosterDocument
|
|||||||
|
|
||||||
public required IReadOnlyList<SchoolClass> Classes { get; init; }
|
public required IReadOnlyList<SchoolClass> Classes { get; init; }
|
||||||
|
|
||||||
|
public ApplicantPool? Applicants { get; init; }
|
||||||
|
|
||||||
public Roster ToRoster() => new(People, Families, Classes);
|
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()
|
new()
|
||||||
{
|
{
|
||||||
Format = CurrentFormat,
|
Format = CurrentFormat,
|
||||||
@@ -28,6 +30,7 @@ public sealed class RosterDocument
|
|||||||
People = roster.People,
|
People = roster.People,
|
||||||
Families = roster.Families,
|
Families = roster.Families,
|
||||||
Classes = roster.Classes,
|
Classes = roster.Classes,
|
||||||
|
Applicants = applicants,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ internal static class Seed
|
|||||||
public const int IntakeSalt = 3;
|
public const int IntakeSalt = 3;
|
||||||
public const int SeatShuffleSalt = 4;
|
public const int SeatShuffleSalt = 4;
|
||||||
public const int HouseholdSalt = 5;
|
public const int HouseholdSalt = 5;
|
||||||
|
public const int ApplicantSalt = 6;
|
||||||
|
|
||||||
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
/// <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);
|
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ internal sealed class GameLoopService(
|
|||||||
{
|
{
|
||||||
if (worker.Id == schoolId)
|
if (worker.Id == schoolId)
|
||||||
{
|
{
|
||||||
return new PublishedSchoolPeople(worker.Snapshot, worker.RosterSnapshot, worker.CatalogSnapshot);
|
return new PublishedSchoolPeople(worker.Snapshot, worker.RosterSnapshot, worker.ApplicantSnapshot, worker.CatalogSnapshot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,4 +581,4 @@ internal sealed class GameLoopService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record PublishedSchoolPeople(SchoolState School, Roster? Roster, DefCatalog? Catalog);
|
internal sealed record PublishedSchoolPeople(SchoolState School, Roster? Roster, ApplicantPool? Applicants, DefCatalog? Catalog);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ internal sealed class SchoolWorker
|
|||||||
|
|
||||||
private SchoolState _snapshot;
|
private SchoolState _snapshot;
|
||||||
private Roster? _rosterSnapshot;
|
private Roster? _rosterSnapshot;
|
||||||
|
private ApplicantPool? _applicantSnapshot;
|
||||||
private DefCatalog? _catalogSnapshot;
|
private DefCatalog? _catalogSnapshot;
|
||||||
private School? _school;
|
private School? _school;
|
||||||
private Task? _run;
|
private Task? _run;
|
||||||
@@ -96,6 +97,9 @@ internal sealed class SchoolWorker
|
|||||||
/// <summary>Last roster composition. Published like <see cref="Snapshot"/>; needs live on entities.</summary>
|
/// <summary>Last roster composition. Published like <see cref="Snapshot"/>; needs live on entities.</summary>
|
||||||
public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot);
|
public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot);
|
||||||
|
|
||||||
|
/// <summary>Last applicant pool. Same publication rules as the roster — not a live World query.</summary>
|
||||||
|
public ApplicantPool? ApplicantSnapshot => Volatile.Read(ref _applicantSnapshot);
|
||||||
|
|
||||||
/// <summary>Frozen catalog for this school. Safe to read from HTTP; it never mutates after load.</summary>
|
/// <summary>Frozen catalog for this school. Safe to read from HTTP; it never mutates after load.</summary>
|
||||||
public DefCatalog? CatalogSnapshot => Volatile.Read(ref _catalogSnapshot);
|
public DefCatalog? CatalogSnapshot => Volatile.Read(ref _catalogSnapshot);
|
||||||
|
|
||||||
@@ -450,11 +454,12 @@ internal sealed class SchoolWorker
|
|||||||
school.Clock.IsRunning,
|
school.Clock.IsRunning,
|
||||||
(byte)school.Clock.SpeedIndex));
|
(byte)school.Clock.SpeedIndex));
|
||||||
Volatile.Write(ref _rosterSnapshot, school.Roster);
|
Volatile.Write(ref _rosterSnapshot, school.Roster);
|
||||||
|
Volatile.Write(ref _applicantSnapshot, school.Applicants);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writes the composition file. Not called from the 30-second clock save — the roster changes
|
/// Writes the composition file. Not called from the 30-second clock save — the roster and
|
||||||
/// on create, load-migration and (later) yearly intake, not every tick.
|
/// applicant pool change on create, weekly refresh and yearly intake, not every tick.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void PersistPeople()
|
private void PersistPeople()
|
||||||
{
|
{
|
||||||
@@ -466,7 +471,7 @@ internal sealed class SchoolWorker
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_store.SavePeople(school.Id, RosterDocument.From(school.PeopleSeed, school.Roster));
|
_store.SavePeople(school.Id, RosterDocument.From(school.PeopleSeed, school.Roster, school.Applicants));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -484,6 +489,7 @@ internal sealed class SchoolWorker
|
|||||||
|
|
||||||
var demand = SchoolDemand.From(catalog, map);
|
var demand = SchoolDemand.From(catalog, map);
|
||||||
Roster roster;
|
Roster roster;
|
||||||
|
ApplicantPool applicants;
|
||||||
int seed;
|
int seed;
|
||||||
var generated = false;
|
var generated = false;
|
||||||
|
|
||||||
@@ -491,6 +497,7 @@ internal sealed class SchoolWorker
|
|||||||
{
|
{
|
||||||
seed = school.Id;
|
seed = school.Id;
|
||||||
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time);
|
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time);
|
||||||
|
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time);
|
||||||
generated = true;
|
generated = true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -500,12 +507,22 @@ internal sealed class SchoolWorker
|
|||||||
{
|
{
|
||||||
seed = school.Id;
|
seed = school.Id;
|
||||||
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time);
|
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time);
|
||||||
|
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time);
|
||||||
generated = true;
|
generated = true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
seed = loaded.Seed;
|
seed = loaded.Seed;
|
||||||
roster = loaded.ToRoster();
|
roster = loaded.ToRoster();
|
||||||
|
if (loaded.Applicants is { Applicants.Count: > 0 })
|
||||||
|
{
|
||||||
|
applicants = loaded.Applicants;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time);
|
||||||
|
generated = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,7 +532,7 @@ internal sealed class SchoolWorker
|
|||||||
$"School {_id} roster does not match its map; the people file was left untouched.");
|
$"School {_id} roster does not match its map; the people file was left untouched.");
|
||||||
}
|
}
|
||||||
|
|
||||||
school.InstallPeople(roster, seed, nameSetId);
|
school.InstallPeople(roster, seed, nameSetId, applicants);
|
||||||
return generated;
|
return generated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"defName": "Staffing",
|
||||||
|
"poolSize": 12,
|
||||||
|
"stayChance": 0.65,
|
||||||
|
"parentChance": 0.35,
|
||||||
|
"hourlyWageBase": 30,
|
||||||
|
"hourlyWagePerSkill": 0.6,
|
||||||
|
}
|
||||||
@@ -32,11 +32,13 @@
|
|||||||
"defName": "Quiet",
|
"defName": "Quiet",
|
||||||
"weight": 7,
|
"weight": 7,
|
||||||
"incompatible": ["Leader", "Bully"],
|
"incompatible": ["Leader", "Bully"],
|
||||||
|
"wageAsk": -8,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "Leader",
|
"defName": "Leader",
|
||||||
"weight": 4,
|
"weight": 4,
|
||||||
"incompatible": ["Quiet"],
|
"incompatible": ["Quiet"],
|
||||||
|
"wageAsk": 10,
|
||||||
"skillModifiers": [
|
"skillModifiers": [
|
||||||
{ "skill": "History", "offset": 4 },
|
{ "skill": "History", "offset": 4 },
|
||||||
],
|
],
|
||||||
@@ -62,6 +64,7 @@
|
|||||||
"defName": "HotTempered",
|
"defName": "HotTempered",
|
||||||
"weight": 5,
|
"weight": 5,
|
||||||
"incompatible": ["Quiet"],
|
"incompatible": ["Quiet"],
|
||||||
|
"wageAsk": 6,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "Kind",
|
"defName": "Kind",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"HotTempered": "Hot-tempered",
|
"HotTempered": "Hot-tempered",
|
||||||
"Kind": "Kind",
|
"Kind": "Kind",
|
||||||
"Neat": "Neat",
|
"Neat": "Neat",
|
||||||
|
"Staffing": "Staffing",
|
||||||
"Height": "Height",
|
"Height": "Height",
|
||||||
"Weight": "Weight",
|
"Weight": "Weight",
|
||||||
"HairColor": "Hair colour",
|
"HairColor": "Hair colour",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"HotTempered": "Вспыльчивый",
|
"HotTempered": "Вспыльчивый",
|
||||||
"Kind": "Добрый",
|
"Kind": "Добрый",
|
||||||
"Neat": "Аккуратный",
|
"Neat": "Аккуратный",
|
||||||
|
"Staffing": "Штат",
|
||||||
"Height": "Рост",
|
"Height": "Рост",
|
||||||
"Weight": "Вес",
|
"Weight": "Вес",
|
||||||
"HairColor": "Цвет волос",
|
"HairColor": "Цвет волос",
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ public sealed class School : IDisposable
|
|||||||
/// <summary>Composition snapshot. Null in clock-only tests or before <see cref="InstallPeople"/>.</summary>
|
/// <summary>Composition snapshot. Null in clock-only tests or before <see cref="InstallPeople"/>.</summary>
|
||||||
public Roster? Roster { get; private set; }
|
public Roster? Roster { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>People looking for work. Not in the roster and not in the World until hired.</summary>
|
||||||
|
public ApplicantPool? Applicants { get; private set; }
|
||||||
|
|
||||||
public int PeopleSeed { get; private set; }
|
public int PeopleSeed { get; private set; }
|
||||||
|
|
||||||
/// <summary>Name pack used to generate this school's people. Needed again on 1 September.</summary>
|
/// <summary>Name pack used to generate this school's people. Needed again on 1 September.</summary>
|
||||||
@@ -76,7 +79,7 @@ public sealed class School : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
|
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void InstallPeople(Roster roster, int seed, string? nameSetId = null)
|
public void InstallPeople(Roster roster, int seed, string? nameSetId = null, ApplicantPool? applicants = null)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
ArgumentNullException.ThrowIfNull(roster);
|
ArgumentNullException.ThrowIfNull(roster);
|
||||||
@@ -84,11 +87,12 @@ public sealed class School : IDisposable
|
|||||||
Roster = roster;
|
Roster = roster;
|
||||||
PeopleSeed = seed;
|
PeopleSeed = seed;
|
||||||
NameSetId = nameSetId;
|
NameSetId = nameSetId;
|
||||||
|
Applicants = applicants;
|
||||||
RosterSpawner.Spawn(World, roster);
|
RosterSpawner.Spawn(World, roster);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Runs one fixed step of the school: calendar, yearly intake if 1 September passed, then need decay.</summary>
|
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay.</summary>
|
||||||
/// <returns><see langword="true"/> when the roster changed this step.</returns>
|
/// <returns><see langword="true"/> when the roster or the applicant pool changed this step.</returns>
|
||||||
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
|
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
@@ -99,6 +103,7 @@ public sealed class School : IDisposable
|
|||||||
if (gameMinutes > 0)
|
if (gameMinutes > 0)
|
||||||
{
|
{
|
||||||
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
||||||
|
peopleChanged |= TryApplicantRefresh();
|
||||||
if (Catalog is not null)
|
if (Catalog is not null)
|
||||||
{
|
{
|
||||||
NeedDecay.Apply(World, Catalog, gameMinutes);
|
NeedDecay.Apply(World, Catalog, gameMinutes);
|
||||||
@@ -130,6 +135,23 @@ public sealed class School : IDisposable
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool TryApplicantRefresh()
|
||||||
|
{
|
||||||
|
if (Applicants is null || Roster is null || Catalog is null || NameSetId is null || Catalog.StaffingRules is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = Applicants.Advance(Catalog, Roster, PeopleSeed, NameSetId, Clock.Time);
|
||||||
|
if (next.Week == Applicants.Week)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Applicants = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
|
|||||||
@@ -64,6 +64,23 @@ public class PeopleApiTests(AppHostFixture fixture)
|
|||||||
Assert.Equal("invalid-query", await ProblemCodeAsync(huge));
|
Assert.Equal("invalid-query", await ProblemCodeAsync(huge));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_HasPupilsAndParentsButNoStaff()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(client, "Пустой штат", Start);
|
||||||
|
|
||||||
|
var students = await GetPeopleAsync(client, school.Id, "role=student&pageSize=10");
|
||||||
|
var parents = await GetPeopleAsync(client, school.Id, "role=parent&pageSize=10");
|
||||||
|
var staff = await GetPeopleAsync(client, school.Id, "role=staff&pageSize=10");
|
||||||
|
|
||||||
|
Assert.NotEmpty(students.People);
|
||||||
|
Assert.NotEmpty(parents.People);
|
||||||
|
Assert.Equal(0, staff.Total);
|
||||||
|
Assert.Empty(staff.People);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task List_UnknownSchool_IsNotFound()
|
public async Task List_UnknownSchool_IsNotFound()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public class PeopleDefTests
|
|||||||
Assert.Equal("Slavic", catalog.Label("en", catalog.NameSets["Slavic"]));
|
Assert.Equal("Slavic", catalog.Label("en", catalog.NameSets["Slavic"]));
|
||||||
Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"]));
|
Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"]));
|
||||||
Assert.True(catalog.Subjects.ContainsKey("PrimarySchool"));
|
Assert.True(catalog.Subjects.ContainsKey("PrimarySchool"));
|
||||||
|
Assert.NotNull(catalog.StaffingRules);
|
||||||
|
Assert.Equal(12, catalog.StaffingRules.PoolSize);
|
||||||
Assert.Equal("Начальные классы", catalog.Label("ru", catalog.Subjects["PrimarySchool"]));
|
Assert.Equal("Начальные классы", catalog.Label("ru", catalog.Subjects["PrimarySchool"]));
|
||||||
Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"]));
|
Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ public class VanillaCoreTests
|
|||||||
Assert.Equal(1, catalog.Subjects["PrimarySchool"].Grades.Min);
|
Assert.Equal(1, catalog.Subjects["PrimarySchool"].Grades.Min);
|
||||||
Assert.Equal(4, catalog.Subjects["PrimarySchool"].Grades.Max);
|
Assert.Equal(4, catalog.Subjects["PrimarySchool"].Grades.Max);
|
||||||
Assert.True(catalog.Subjects.ContainsKey("PhysicalEducation"));
|
Assert.True(catalog.Subjects.ContainsKey("PhysicalEducation"));
|
||||||
|
Assert.NotNull(catalog.StaffingRules);
|
||||||
|
Assert.Equal(12, catalog.StaffingRules.PoolSize);
|
||||||
Assert.Equal(2, map.Buildings.Count);
|
Assert.Equal(2, map.Buildings.Count);
|
||||||
var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList();
|
var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList();
|
||||||
Assert.Equal(11, homerooms.Count);
|
Assert.Equal(11, homerooms.Count);
|
||||||
@@ -79,6 +81,7 @@ public class VanillaCoreTests
|
|||||||
keys.AddRange(Names(catalog.Needs.Values));
|
keys.AddRange(Names(catalog.Needs.Values));
|
||||||
keys.AddRange(Names(catalog.NameSets.Values));
|
keys.AddRange(Names(catalog.NameSets.Values));
|
||||||
keys.AddRange(Names(catalog.Subjects.Values));
|
keys.AddRange(Names(catalog.Subjects.Values));
|
||||||
|
keys.AddRange(Names(catalog.Staffing.Values));
|
||||||
|
|
||||||
// Derived in code, so no def carries them.
|
// Derived in code, so no def carries them.
|
||||||
keys.Add(BodyBuilds.Attribute);
|
keys.Add(BodyBuilds.Attribute);
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
namespace HSchool.People.Tests;
|
||||||
|
|
||||||
|
public class ApplicantPoolTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SameSeedAndWeek_YieldTheSamePool()
|
||||||
|
{
|
||||||
|
var roster = Fixtures.Generate(Fixtures.VanillaMap());
|
||||||
|
var a = Create(roster);
|
||||||
|
var b = Create(roster);
|
||||||
|
|
||||||
|
Assert.Equal(Snapshot(a), Snapshot(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Advance_KeepsSomePeopleWithTheSameSkillsAndAsk()
|
||||||
|
{
|
||||||
|
var roster = Fixtures.Generate(Fixtures.VanillaMap());
|
||||||
|
var first = Create(roster);
|
||||||
|
var next = first.Advance(Fixtures.Catalog(), roster, Fixtures.SchoolSeed, "Slavic", Fixtures.AsOf.AddDays(7));
|
||||||
|
|
||||||
|
Assert.NotEqual(first.Week, next.Week);
|
||||||
|
var stayed = first.Applicants
|
||||||
|
.Join(
|
||||||
|
next.Applicants,
|
||||||
|
applicant => applicant.Person.Id,
|
||||||
|
applicant => applicant.Person.Id,
|
||||||
|
(before, after) => (before, after),
|
||||||
|
StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.NotEmpty(stayed);
|
||||||
|
foreach (var (before, after) in stayed)
|
||||||
|
{
|
||||||
|
Assert.Equal(before.HourlyWageAsk, after.HourlyWageAsk);
|
||||||
|
Assert.Equal(Skills(before.Person), Skills(after.Person));
|
||||||
|
Assert.Equal(before.Person.Name.Full, after.Person.Name.Full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FiftyWeeks_StayAtPoolSize()
|
||||||
|
{
|
||||||
|
var catalog = Fixtures.Catalog();
|
||||||
|
var roster = Fixtures.Generate(Fixtures.VanillaMap());
|
||||||
|
var size = catalog.StaffingRules!.PoolSize;
|
||||||
|
var asOf = Fixtures.AsOf;
|
||||||
|
var pool = ApplicantPool.Create(catalog, roster, Fixtures.SchoolSeed, "Slavic", asOf);
|
||||||
|
|
||||||
|
for (var week = 0; week < 50; week++)
|
||||||
|
{
|
||||||
|
asOf = asOf.AddDays(7);
|
||||||
|
pool = pool.Advance(catalog, roster, Fixtures.SchoolSeed, "Slavic", asOf);
|
||||||
|
Assert.Equal(size, pool.Applicants.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StrongerSkills_AskForMore()
|
||||||
|
{
|
||||||
|
var catalog = Fixtures.Catalog();
|
||||||
|
var person = Fixtures.Generate(Fixtures.Classrooms(1)).People.First(candidate => candidate.IsParent);
|
||||||
|
var weak = person with { Skills = person.Skills.ToDictionary(pair => pair.Key, _ => 15) };
|
||||||
|
var strong = person with { Skills = person.Skills.ToDictionary(pair => pair.Key, _ => 90) };
|
||||||
|
|
||||||
|
Assert.True(ApplicantPool.HourlyAsk(catalog, strong) > ApplicantPool.HourlyAsk(catalog, weak));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GeneratedApplicants_AreNotOnTheRoster_ParentsKeepTheirId()
|
||||||
|
{
|
||||||
|
var roster = Fixtures.Generate(Fixtures.VanillaMap());
|
||||||
|
ApplicantPool? pool = null;
|
||||||
|
for (var seed = 1; seed <= 20 && pool is null; seed++)
|
||||||
|
{
|
||||||
|
var candidate = Fixtures.Generate(Fixtures.VanillaMap(), seed);
|
||||||
|
var created = ApplicantPool.Create(Fixtures.Catalog(), candidate, seed, "Slavic", Fixtures.AsOf);
|
||||||
|
if (created.Applicants.Any(applicant => candidate.People.Any(person => person.Id == applicant.Person.Id)))
|
||||||
|
{
|
||||||
|
roster = candidate;
|
||||||
|
pool = created;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.NotNull(pool);
|
||||||
|
var rosterIds = roster.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal);
|
||||||
|
foreach (var applicant in pool.Applicants)
|
||||||
|
{
|
||||||
|
if (rosterIds.Contains(applicant.Person.Id))
|
||||||
|
{
|
||||||
|
Assert.Contains(roster.People, person => person.Id == applicant.Person.Id && person.IsParent && !person.IsStaff);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.StartsWith("a", applicant.Person.Id, StringComparison.Ordinal);
|
||||||
|
Assert.False(applicant.Person.IsStaff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PeopleJson_RoundTripsThePool()
|
||||||
|
{
|
||||||
|
var roster = Fixtures.Generate(Fixtures.Classrooms(1));
|
||||||
|
var pool = Create(roster);
|
||||||
|
var json = RosterJson.Serialize(RosterDocument.From(Fixtures.SchoolSeed, roster, pool));
|
||||||
|
var loaded = RosterJson.Parse(json);
|
||||||
|
|
||||||
|
Assert.Equal(Snapshot(pool), Snapshot(loaded.Applicants!));
|
||||||
|
Assert.DoesNotContain(loaded.People, person => pool.Applicants.Any(applicant =>
|
||||||
|
applicant.Person.Id.StartsWith('a') && applicant.Person.Id == person.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicantPool Create(Roster roster) =>
|
||||||
|
ApplicantPool.Create(Fixtures.Catalog(), roster, Fixtures.SchoolSeed, "Slavic", Fixtures.AsOf);
|
||||||
|
|
||||||
|
private static string Snapshot(ApplicantPool pool) =>
|
||||||
|
string.Join('\n', pool.Applicants.Select(applicant =>
|
||||||
|
$"{pool.Week}|{pool.NextIndex}|{applicant.Person.Id}|{applicant.HourlyWageAsk:0.00}|{applicant.Person.Name.Full}|{Skills(applicant.Person)}"));
|
||||||
|
|
||||||
|
private static string Skills(Person person) =>
|
||||||
|
string.Join(',', person.Skills.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value}"));
|
||||||
|
}
|
||||||
@@ -45,16 +45,15 @@ public class ReviewFixTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StaffTopUp_LeavesNobodyWithoutARole()
|
public void RosterWithoutStaff_LeavesNobodyWithoutARole()
|
||||||
{
|
{
|
||||||
// More posts than the pupils' parents can fill, and an odd deficit: the old top-up added
|
|
||||||
// two adults per opening and the spare one ended up neither staff, parent nor pupil.
|
|
||||||
var roster = Fixtures.Generate(Fixtures.PostHeavyMap());
|
var roster = Fixtures.Generate(Fixtures.PostHeavyMap());
|
||||||
|
|
||||||
|
Assert.DoesNotContain(roster.People, person => person.IsStaff);
|
||||||
Assert.All(
|
Assert.All(
|
||||||
roster.People,
|
roster.People,
|
||||||
person => Assert.True(
|
person => Assert.True(
|
||||||
person.IsStudent || person.IsStaff || person.IsParent,
|
person.IsStudent || person.IsParent,
|
||||||
$"{person.Id} ({person.Name.Full}) has no role at all"));
|
$"{person.Id} ({person.Name.Full}) has no role at all"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class RosterBrowserTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParentFilter_IncludesStaffWhoAreAlsoParents()
|
public void ParentFilter_ReturnsOnlyParents()
|
||||||
{
|
{
|
||||||
var roster = Fixtures.Generate(Fixtures.VanillaMap());
|
var roster = Fixtures.Generate(Fixtures.VanillaMap());
|
||||||
var page = RosterBrowser.Apply(
|
var page = RosterBrowser.Apply(
|
||||||
@@ -61,7 +61,7 @@ public class RosterBrowserTests
|
|||||||
Fixtures.AsOf,
|
Fixtures.AsOf,
|
||||||
Query(role: PersonRoles.Parent, pageSize: RosterBrowser.MaxPageSize));
|
Query(role: PersonRoles.Parent, pageSize: RosterBrowser.MaxPageSize));
|
||||||
|
|
||||||
Assert.Contains(page.People, person => person.IsStaff && person.IsParent);
|
Assert.NotEmpty(page.People);
|
||||||
Assert.All(page.People, person => Assert.True(person.IsParent));
|
Assert.All(page.People, person => Assert.True(person.IsParent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,17 +95,19 @@ public class RosterGeneratorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OneClassroomAndEleven_BothFillSeatsAndJobs()
|
public void OneClassroomAndEleven_BothFillSeatsAndLeaveJobsEmpty()
|
||||||
{
|
{
|
||||||
AssertFilled(Fixtures.Generate(Fixtures.Classrooms(1)), classrooms: 1);
|
AssertFilled(Fixtures.Generate(Fixtures.Classrooms(1)), classrooms: 1);
|
||||||
AssertFilled(Fixtures.Generate(Fixtures.Classrooms(11)), classrooms: 11);
|
AssertFilled(Fixtures.Generate(Fixtures.Classrooms(11)), classrooms: 11);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SomeStaffAreAlsoParents()
|
public void NewSchool_HasNoStaff()
|
||||||
{
|
{
|
||||||
var roster = Fixtures.Generate(Fixtures.VanillaMap());
|
var roster = Fixtures.Generate(Fixtures.VanillaMap());
|
||||||
Assert.Contains(roster.People, person => person.IsStaff && person.IsParent);
|
Assert.DoesNotContain(roster.People, person => person.IsStaff);
|
||||||
|
Assert.Equal(11 * 16, roster.People.Count(person => person.IsStudent));
|
||||||
|
Assert.Contains(roster.People, person => person.IsParent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -173,11 +175,8 @@ public class RosterGeneratorTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
var demand = SchoolDemand.From(Fixtures.Catalog(), Fixtures.Classrooms(classrooms));
|
var demand = SchoolDemand.From(Fixtures.Catalog(), Fixtures.Classrooms(classrooms));
|
||||||
Assert.Equal(demand.Staff.Count, roster.People.Count(person => person.IsStaff));
|
Assert.Equal(0, roster.People.Count(person => person.IsStaff));
|
||||||
Assert.All(demand.Staff, opening =>
|
Assert.True(RosterFit.Matches(roster, demand));
|
||||||
Assert.Contains(
|
|
||||||
roster.People,
|
|
||||||
person => person.IsStaff && person.Position == opening.Position && person.WorkplaceRoomId == opening.RoomId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Snapshot(Roster roster) =>
|
private static string Snapshot(Roster roster) =>
|
||||||
|
|||||||
@@ -9,21 +9,23 @@ public class PeopleInSchoolTests
|
|||||||
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
|
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InstallPeople_FillsHomeroomsAndJobs()
|
public void InstallPeople_FillsHomeroomsAndLeavesJobsEmpty()
|
||||||
{
|
{
|
||||||
var (catalog, map) = Vanilla();
|
var (catalog, map) = Vanilla();
|
||||||
var demand = SchoolDemand.From(catalog, map);
|
var demand = SchoolDemand.From(catalog, map);
|
||||||
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Start);
|
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Start);
|
||||||
|
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", Start);
|
||||||
|
|
||||||
using var school = School.Create(1, "Полная", Start, catalog, map);
|
using var school = School.Create(1, "Полная", Start, catalog, map);
|
||||||
school.InstallPeople(roster, seed: 1);
|
school.InstallPeople(roster, seed: 1, "Slavic", pool);
|
||||||
school.Tick(1d / 20d, 5d);
|
school.Tick(1d / 20d, 5d);
|
||||||
|
|
||||||
Assert.Same(roster, school.Roster);
|
Assert.Same(roster, school.Roster);
|
||||||
Assert.Equal(11, roster.Classes.Count);
|
Assert.Equal(11, roster.Classes.Count);
|
||||||
Assert.Equal(demand.Seats.Count, roster.People.Count(person => person.IsStudent));
|
Assert.Equal(demand.Seats.Count, roster.People.Count(person => person.IsStudent));
|
||||||
Assert.Equal(demand.Staff.Count, roster.People.Count(person => person.IsStaff));
|
Assert.Equal(0, roster.People.Count(person => person.IsStaff));
|
||||||
Assert.True(RosterFit.Matches(roster, demand));
|
Assert.True(RosterFit.Matches(roster, demand));
|
||||||
|
Assert.Equal(catalog.StaffingRules!.PoolSize, school.Applicants!.Applicants.Count);
|
||||||
|
|
||||||
var peopleQuery = new QueryDescription().WithAll<PersonIdentity, PersonNeeds, PersonRoles>();
|
var peopleQuery = new QueryDescription().WithAll<PersonIdentity, PersonNeeds, PersonRoles>();
|
||||||
var classesQuery = new QueryDescription().WithAll<ClassIdentity>();
|
var classesQuery = new QueryDescription().WithAll<ClassIdentity>();
|
||||||
@@ -31,6 +33,28 @@ public class PeopleInSchoolTests
|
|||||||
Assert.Equal(11, school.World.CountEntities(in classesQuery));
|
Assert.Equal(11, school.World.CountEntities(in classesQuery));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tick_AcrossAWeekBoundary_RefreshesTheApplicantPool()
|
||||||
|
{
|
||||||
|
var start = new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
|
||||||
|
var (catalog, map) = Vanilla();
|
||||||
|
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", start);
|
||||||
|
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", start);
|
||||||
|
|
||||||
|
using var school = School.Create(1, "Неделя", start, catalog, map);
|
||||||
|
school.InstallPeople(roster, seed: 1, "Slavic", pool);
|
||||||
|
|
||||||
|
var monday = new DateTime(2012, 4, 2, 6, 0, 0, DateTimeKind.Utc);
|
||||||
|
var changed = school.Tick((monday - start).TotalMinutes / 5d, 5d);
|
||||||
|
|
||||||
|
Assert.True(changed);
|
||||||
|
Assert.NotNull(school.Applicants);
|
||||||
|
Assert.NotEqual(pool.Week, school.Applicants.Week);
|
||||||
|
Assert.Equal(pool.Applicants.Count, school.Applicants.Applicants.Count);
|
||||||
|
var peopleQuery = new QueryDescription().WithAll<PersonIdentity>();
|
||||||
|
Assert.Equal(roster.People.Count, school.World.CountEntities(in peopleQuery));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CoreNeeds_DoNotMoveOnTick()
|
public void CoreNeeds_DoNotMoveOnTick()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user