diff --git a/docs/phases/11-applicants.md b/docs/phases/11-applicants.md index 10f8afa..9c5cdd7 100644 --- a/docs/phases/11-applicants.md +++ b/docs/phases/11-applicants.md @@ -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] Соискатель не попадает в ростер школы, пока не нанят ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index 486d590..a9d3cca 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -50,7 +50,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | | [10. Предметы и мебель](10-subjects.md) | ✅ | `SubjectDef`, одна учительская должность, кабинет как число мест | -| [11. Пустая школа и пул](11-applicants.md) | ⬜ | Школа без сотрудников, соискатели с запросом по зарплате | +| [11. Пустая школа и пул](11-applicants.md) | ✅ | Школа без сотрудников, соискатели с запросом по зарплате | | [12. Наём и бюджет](12-hiring-budget.md) | ⬜ | Наём, назначение предметов, предел фонда оплаты | | [13. Раздел «Управление»](13-management-tab.md) | ⬜ | Деньги, соискатели, штат и назначения на экране | diff --git a/docs/protocol.md b/docs/protocol.md index 14671c2..ffcdcbb 100644 --- a/docs/protocol.md +++ b/docs/protocol.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 `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}` One person's card. Goes through the school's mailbox because need values live on entities and diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index 22409f1..10bceff 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -308,6 +308,7 @@ public sealed class CatalogLoader var needs = new Dictionary(StringComparer.Ordinal); var nameSets = new Dictionary(StringComparer.Ordinal); var subjects = new Dictionary(StringComparer.Ordinal); + var staffing = new Dictionary(StringComparer.Ordinal); foreach (var (key, json) in resolved) { @@ -355,6 +356,9 @@ public sealed class CatalogLoader case DefKind.Subject: subjects[key.Name] = Jsonc.Deserialize(json); break; + case DefKind.Staffing: + staffing[key.Name] = Jsonc.Deserialize(json); + break; } } @@ -374,6 +378,7 @@ public sealed class CatalogLoader needs, nameSets, subjects, + staffing, ru, en); } diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs index 15ccd81..13e029f 100644 --- a/src/HSchool.Content/DefCatalog.cs +++ b/src/HSchool.Content/DefCatalog.cs @@ -22,6 +22,7 @@ public sealed class DefCatalog IReadOnlyDictionary needs, IReadOnlyDictionary nameSets, IReadOnlyDictionary subjects, + IReadOnlyDictionary staffing, IReadOnlyDictionary ru, IReadOnlyDictionary en) { @@ -40,6 +41,7 @@ public sealed class DefCatalog Needs = needs; NameSets = nameSets; Subjects = subjects; + Staffing = staffing; _ru = ru; _en = en; AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f); @@ -82,6 +84,11 @@ public sealed class DefCatalog public IReadOnlyDictionary Subjects { get; } + public IReadOnlyDictionary Staffing { get; } + + /// The one concrete staffing ruleset, or null when a pack has not defined it. + public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract); + private readonly IReadOnlyDictionary _ru; private readonly IReadOnlyDictionary _en; @@ -103,6 +110,7 @@ public sealed class DefCatalog DefKind.Need => Needs.GetValueOrDefault(defName), DefKind.NameSet => NameSets.GetValueOrDefault(defName), DefKind.Subject => Subjects.GetValueOrDefault(defName), + DefKind.Staffing => Staffing.GetValueOrDefault(defName), _ => null, }; @@ -177,6 +185,7 @@ public sealed class DefCatalog NeedDef => DefKind.Need, NameSetDef => DefKind.NameSet, SubjectDef => DefKind.Subject, + StaffingDef => DefKind.Staffing, _ => throw new ArgumentOutOfRangeException(nameof(def)), }; diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index 39bedae..7cea87a 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -16,6 +16,7 @@ public enum DefKind Need, NameSet, Subject, + Staffing, } /// Shared JSONC fields. Kind comes from the folder under defs/, not from the file. diff --git a/src/HSchool.Content/PackPaths.cs b/src/HSchool.Content/PackPaths.cs index 0b6e3a2..a8bde16 100644 --- a/src/HSchool.Content/PackPaths.cs +++ b/src/HSchool.Content/PackPaths.cs @@ -104,6 +104,9 @@ internal static class PackPaths case "subjects": kind = DefKind.Subject; return true; + case "staffing": + kind = DefKind.Staffing; + return true; default: kind = default; return false; diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 5cda435..742de73 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -34,6 +34,16 @@ internal static class PeopleDefValidator 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); } @@ -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) { if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule)) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 18706e2..1d759d4 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -157,6 +157,24 @@ public sealed class TraitDef : Def public IntRange? Age { get; init; } public IReadOnlyList SkillModifiers { get; init; } = []; + + /// + /// Added to the hourly wage ask. Positive means the person wants more at the same skills. + /// + 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 diff --git a/src/HSchool.People/ApplicantPool.cs b/src/HSchool.People/ApplicantPool.cs new file mode 100644 index 0000000..d039f13 --- /dev/null +++ b/src/HSchool.People/ApplicantPool.cs @@ -0,0 +1,173 @@ +namespace HSchool.People; + +/// One person looking for work at this school, with the hourly rate they named. +public sealed record Applicant(Person Person, float HourlyWageAsk); + +/// +/// 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. +/// +public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList 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, [])); + } + + /// + /// Walks each week from exclusive to inclusive. + /// Same previous list, seed and week always yields the same stays and the same newcomers. + /// + 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(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(rules.PoolSize); + var taken = new HashSet(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."); +} diff --git a/src/HSchool.People/ApplicantWeeks.cs b/src/HSchool.People/ApplicantWeeks.cs new file mode 100644 index 0000000..c3a3c4f --- /dev/null +++ b/src/HSchool.People/ApplicantWeeks.cs @@ -0,0 +1,17 @@ +namespace HSchool.People; + +/// +/// 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. +/// +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; + } +} diff --git a/src/HSchool.People/FamilyFactory.cs b/src/HSchool.People/FamilyFactory.cs index 36e60db..ea33dc4 100644 --- a/src/HSchool.People/FamilyFactory.cs +++ b/src/HSchool.People/FamilyFactory.cs @@ -199,19 +199,20 @@ internal static class FamilyFactory } /// - /// 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 so it can never collide with roster + /// households (fN). /// 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); diff --git a/src/HSchool.People/RosterFit.cs b/src/HSchool.People/RosterFit.cs index 3937429..4e48112 100644 --- a/src/HSchool.People/RosterFit.cs +++ b/src/HSchool.People/RosterFit.cs @@ -3,6 +3,7 @@ namespace HSchool.People; /// /// 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. /// 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; } } diff --git a/src/HSchool.People/RosterGenerator.cs b/src/HSchool.People/RosterGenerator.cs index c0d0ccc..2a0e1f5 100644 --- a/src/HSchool.People/RosterGenerator.cs +++ b/src/HSchool.People/RosterGenerator.cs @@ -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); } /// @@ -76,48 +64,6 @@ public static class RosterGenerator return shuffled; } - private static IReadOnlyList AssignStaff(List people, IReadOnlyList openings) - { - if (openings.Count == 0) - { - return people; - } - - var jobs = new Dictionary(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 FillClasses( IReadOnlyList classes, IReadOnlyList people) diff --git a/src/HSchool.People/RosterJson.cs b/src/HSchool.People/RosterJson.cs index e2a1cf5..0706d8e 100644 --- a/src/HSchool.People/RosterJson.cs +++ b/src/HSchool.People/RosterJson.cs @@ -18,9 +18,11 @@ public sealed class RosterDocument public required IReadOnlyList 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, }; } diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index 9da4ad3..669e848 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -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; /// A stream that belongs to the school rather than to one family. public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt); diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 92aed6f..9d16dae 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -64,7 +64,7 @@ internal sealed class GameLoopService( { 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); diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 31246e1..b69bcf4 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -42,6 +42,7 @@ internal sealed class SchoolWorker private SchoolState _snapshot; private Roster? _rosterSnapshot; + private ApplicantPool? _applicantSnapshot; private DefCatalog? _catalogSnapshot; private School? _school; private Task? _run; @@ -96,6 +97,9 @@ internal sealed class SchoolWorker /// Last roster composition. Published like ; needs live on entities. public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot); + /// Last applicant pool. Same publication rules as the roster — not a live World query. + public ApplicantPool? ApplicantSnapshot => Volatile.Read(ref _applicantSnapshot); + /// Frozen catalog for this school. Safe to read from HTTP; it never mutates after load. public DefCatalog? CatalogSnapshot => Volatile.Read(ref _catalogSnapshot); @@ -450,11 +454,12 @@ internal sealed class SchoolWorker school.Clock.IsRunning, (byte)school.Clock.SpeedIndex)); Volatile.Write(ref _rosterSnapshot, school.Roster); + Volatile.Write(ref _applicantSnapshot, school.Applicants); } /// - /// Writes the composition file. Not called from the 30-second clock save — the roster changes - /// on create, load-migration and (later) yearly intake, not every tick. + /// 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. /// private void PersistPeople() { @@ -466,7 +471,7 @@ internal sealed class SchoolWorker 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) { @@ -484,6 +489,7 @@ internal sealed class SchoolWorker var demand = SchoolDemand.From(catalog, map); Roster roster; + ApplicantPool applicants; int seed; var generated = false; @@ -491,6 +497,7 @@ internal sealed class SchoolWorker { seed = school.Id; roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time); + applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time); generated = true; } else @@ -500,12 +507,22 @@ internal sealed class SchoolWorker { seed = school.Id; roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time); + applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time); generated = true; } else { seed = loaded.Seed; 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.InstallPeople(roster, seed, nameSetId); + school.InstallPeople(roster, seed, nameSetId, applicants); return generated; } diff --git a/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc b/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc new file mode 100644 index 0000000..2aad1ac --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc @@ -0,0 +1,8 @@ +{ + "defName": "Staffing", + "poolSize": 12, + "stayChance": 0.65, + "parentChance": 0.35, + "hourlyWageBase": 30, + "hourlyWagePerSkill": 0.6, +} diff --git a/src/HSchool.Server/mods/core/defs/traits/traits.jsonc b/src/HSchool.Server/mods/core/defs/traits/traits.jsonc index f1d3efb..8fcc332 100644 --- a/src/HSchool.Server/mods/core/defs/traits/traits.jsonc +++ b/src/HSchool.Server/mods/core/defs/traits/traits.jsonc @@ -32,11 +32,13 @@ "defName": "Quiet", "weight": 7, "incompatible": ["Leader", "Bully"], + "wageAsk": -8, }, { "defName": "Leader", "weight": 4, "incompatible": ["Quiet"], + "wageAsk": 10, "skillModifiers": [ { "skill": "History", "offset": 4 }, ], @@ -62,6 +64,7 @@ "defName": "HotTempered", "weight": 5, "incompatible": ["Quiet"], + "wageAsk": 6, }, { "defName": "Kind", diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 1686672..c4319a8 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -68,6 +68,7 @@ "HotTempered": "Hot-tempered", "Kind": "Kind", "Neat": "Neat", + "Staffing": "Staffing", "Height": "Height", "Weight": "Weight", "HairColor": "Hair colour", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 3e73df3..b2d107f 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -68,6 +68,7 @@ "HotTempered": "Вспыльчивый", "Kind": "Добрый", "Neat": "Аккуратный", + "Staffing": "Штат", "Height": "Рост", "Weight": "Вес", "HairColor": "Цвет волос", diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 8e449af..63948b4 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -68,6 +68,9 @@ public sealed class School : IDisposable /// Composition snapshot. Null in clock-only tests or before . public Roster? Roster { get; private set; } + /// People looking for work. Not in the roster and not in the World until hired. + public ApplicantPool? Applicants { get; private set; } + public int PeopleSeed { get; private set; } /// Name pack used to generate this school's people. Needed again on 1 September. @@ -76,7 +79,7 @@ public sealed class School : IDisposable /// /// Installs a roster that already matches the map. Spawns entities; does not write to disk. /// - 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); ArgumentNullException.ThrowIfNull(roster); @@ -84,11 +87,12 @@ public sealed class School : IDisposable Roster = roster; PeopleSeed = seed; NameSetId = nameSetId; + Applicants = applicants; RosterSpawner.Spawn(World, roster); } - /// Runs one fixed step of the school: calendar, yearly intake if 1 September passed, then need decay. - /// when the roster changed this step. + /// Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay. + /// when the roster or the applicant pool changed this step. public bool Tick(double deltaTime, double gameMinutesPerRealSecond) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -99,6 +103,7 @@ public sealed class School : IDisposable if (gameMinutes > 0) { peopleChanged = TryYearlyIntake(before, Clock.Time); + peopleChanged |= TryApplicantRefresh(); if (Catalog is not null) { NeedDecay.Apply(World, Catalog, gameMinutes); @@ -130,6 +135,23 @@ public sealed class School : IDisposable 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() { if (_disposed) diff --git a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs index 3396841..57e2746 100644 --- a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs @@ -64,6 +64,23 @@ public class PeopleApiTests(AppHostFixture fixture) 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] public async Task List_UnknownSchool_IsNotFound() { diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs index 53d7299..7f26a24 100644 --- a/tests/HSchool.Content.Tests/PeopleDefTests.cs +++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs @@ -24,6 +24,8 @@ public class PeopleDefTests Assert.Equal("Slavic", catalog.Label("en", catalog.NameSets["Slavic"])); Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"])); 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("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"])); } diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 824acb0..6638acf 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -41,6 +41,8 @@ public class VanillaCoreTests Assert.Equal(1, catalog.Subjects["PrimarySchool"].Grades.Min); Assert.Equal(4, catalog.Subjects["PrimarySchool"].Grades.Max); Assert.True(catalog.Subjects.ContainsKey("PhysicalEducation")); + Assert.NotNull(catalog.StaffingRules); + Assert.Equal(12, catalog.StaffingRules.PoolSize); Assert.Equal(2, map.Buildings.Count); var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList(); Assert.Equal(11, homerooms.Count); @@ -79,6 +81,7 @@ public class VanillaCoreTests keys.AddRange(Names(catalog.Needs.Values)); keys.AddRange(Names(catalog.NameSets.Values)); keys.AddRange(Names(catalog.Subjects.Values)); + keys.AddRange(Names(catalog.Staffing.Values)); // Derived in code, so no def carries them. keys.Add(BodyBuilds.Attribute); diff --git a/tests/HSchool.People.Tests/ApplicantPoolTests.cs b/tests/HSchool.People.Tests/ApplicantPoolTests.cs new file mode 100644 index 0000000..dd11a0f --- /dev/null +++ b/tests/HSchool.People.Tests/ApplicantPoolTests.cs @@ -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}")); +} diff --git a/tests/HSchool.People.Tests/ReviewFixTests.cs b/tests/HSchool.People.Tests/ReviewFixTests.cs index fa75a44..211e84c 100644 --- a/tests/HSchool.People.Tests/ReviewFixTests.cs +++ b/tests/HSchool.People.Tests/ReviewFixTests.cs @@ -45,16 +45,15 @@ public class ReviewFixTests } [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()); + Assert.DoesNotContain(roster.People, person => person.IsStaff); Assert.All( roster.People, person => Assert.True( - person.IsStudent || person.IsStaff || person.IsParent, + person.IsStudent || person.IsParent, $"{person.Id} ({person.Name.Full}) has no role at all")); } diff --git a/tests/HSchool.People.Tests/RosterBrowserTests.cs b/tests/HSchool.People.Tests/RosterBrowserTests.cs index 8cea45c..fd8a1a4 100644 --- a/tests/HSchool.People.Tests/RosterBrowserTests.cs +++ b/tests/HSchool.People.Tests/RosterBrowserTests.cs @@ -53,7 +53,7 @@ public class RosterBrowserTests } [Fact] - public void ParentFilter_IncludesStaffWhoAreAlsoParents() + public void ParentFilter_ReturnsOnlyParents() { var roster = Fixtures.Generate(Fixtures.VanillaMap()); var page = RosterBrowser.Apply( @@ -61,7 +61,7 @@ public class RosterBrowserTests Fixtures.AsOf, 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)); } diff --git a/tests/HSchool.People.Tests/RosterGeneratorTests.cs b/tests/HSchool.People.Tests/RosterGeneratorTests.cs index 46ea3a6..43d901a 100644 --- a/tests/HSchool.People.Tests/RosterGeneratorTests.cs +++ b/tests/HSchool.People.Tests/RosterGeneratorTests.cs @@ -95,17 +95,19 @@ public class RosterGeneratorTests } [Fact] - public void OneClassroomAndEleven_BothFillSeatsAndJobs() + public void OneClassroomAndEleven_BothFillSeatsAndLeaveJobsEmpty() { AssertFilled(Fixtures.Generate(Fixtures.Classrooms(1)), classrooms: 1); AssertFilled(Fixtures.Generate(Fixtures.Classrooms(11)), classrooms: 11); } [Fact] - public void SomeStaffAreAlsoParents() + public void NewSchool_HasNoStaff() { 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] @@ -173,11 +175,8 @@ public class RosterGeneratorTests }); var demand = SchoolDemand.From(Fixtures.Catalog(), Fixtures.Classrooms(classrooms)); - Assert.Equal(demand.Staff.Count, roster.People.Count(person => person.IsStaff)); - Assert.All(demand.Staff, opening => - Assert.Contains( - roster.People, - person => person.IsStaff && person.Position == opening.Position && person.WorkplaceRoomId == opening.RoomId)); + Assert.Equal(0, roster.People.Count(person => person.IsStaff)); + Assert.True(RosterFit.Matches(roster, demand)); } private static string Snapshot(Roster roster) => diff --git a/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs b/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs index 06e182d..dd73c0d 100644 --- a/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs +++ b/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs @@ -9,21 +9,23 @@ public class PeopleInSchoolTests private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); [Fact] - public void InstallPeople_FillsHomeroomsAndJobs() + public void InstallPeople_FillsHomeroomsAndLeavesJobsEmpty() { var (catalog, map) = Vanilla(); var demand = SchoolDemand.From(catalog, map); 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); + school.InstallPeople(roster, seed: 1, "Slavic", pool); school.Tick(1d / 20d, 5d); Assert.Same(roster, school.Roster); Assert.Equal(11, roster.Classes.Count); 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.Equal(catalog.StaffingRules!.PoolSize, school.Applicants!.Applicants.Count); var peopleQuery = new QueryDescription().WithAll(); var classesQuery = new QueryDescription().WithAll(); @@ -31,6 +33,28 @@ public class PeopleInSchoolTests 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(); + Assert.Equal(roster.People.Count, school.World.CountEntities(in peopleQuery)); + } + [Fact] public void CoreNeeds_DoNotMoveOnTick() {