diff --git a/docs/phases/14-health/80-nurse-health-ui.md b/docs/phases/14-health/80-nurse-health-ui.md index 5e9c089..fb2ccef 100644 --- a/docs/phases/14-health/80-nurse-health-ui.md +++ b/docs/phases/14-health/80-nurse-health-ui.md @@ -12,22 +12,22 @@ ## Задачи -- [ ] Больной с дискомфортом выше порога стремится в `MedicalOffice`; медсестра с `MedicalDuty` +- [x] Больной с дискомфортом выше порога стремится в `MedicalOffice`; медсестра с `MedicalDuty` снижает тяжесть / ускоряет иммунитет (навык Medicine) -- [ ] Нет медсестры — болезнь идёт натурально (хуже/дольше по данным) -- [ ] Очередь у медкабинета проще директора, но не телепорт -- [ ] Вкладка «Здоровье» на карточке: условия, стадия/тяжесть, краткий эффект -- [ ] Связность: симптомы режут вес кружка / общения (поле на def); явка-болезнь уже из 78 -- [ ] Игрок не жмёт «вылечи»; гость только смотрит -- [ ] Локали RU/EN +- [x] Нет медсестры — болезнь идёт натурально (хуже/дольше по данным) +- [x] Очередь у медкабинета проще директора, но не телепорт +- [x] Вкладка «Здоровье» на карточке: условия, стадия/тяжесть, краткий эффект +- [x] Связность: симптомы режут вес кружка / общения (поле на def); явка-болезнь уже из 78 +- [x] Игрок не жмёт «вылечи»; гость только смотрит +- [x] Локали RU/EN ## Тесты, без которых фаза не закрыта -- [ ] С медсестрой в кабинете тяжесть падает быстрее контроля без неё -- [ ] Карточка показывает активные условия -- [ ] Нет API лечения от клиента -- [ ] Сильный симптом снижает шанс начать Chat относительно контроля -- [ ] Skip ночи не оставляет вечную очередь в медкабинете без разрешения +- [x] С медсестрой в кабинете тяжесть падает быстрее контроля без неё +- [x] Карточка показывает активные условия +- [x] Нет API лечения от клиента +- [x] Сильный симптом снижает шанс начать Chat относительно контроля +- [x] Skip ночи не оставляет вечную очередь в медкабинете без разрешения ## Критерий готовности diff --git a/docs/phases/14-health/README.md b/docs/phases/14-health/README.md index cea3aea..5dfb6e8 100644 --- a/docs/phases/14-health/README.md +++ b/docs/phases/14-health/README.md @@ -30,6 +30,6 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [80. Медсестра и карточка](80-nurse-health-ui.md) | 🔄 | Уход, вкладка, связность с журналом/общением | +| [80. Медсестра и карточка](80-nurse-health-ui.md) | ✅ | Уход, вкладка, связность с журналом/общением | 77 стоит на нуждах/сейве человека; 78 — на 77 и 32/53; 79 — на 78; 80 — на 79 и 74/75. diff --git a/docs/protocol.md b/docs/protocol.md index c24f869..f422d51 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -377,10 +377,16 @@ the client. HTTP JSON is additive — no protocol version bump. in the world and in `people.json`. Mark rows carry `subject`, `subjectLabel`, `value` (2–5), `time`, `period`. Attendance rows carry `subject`, `subjectLabel`, `status` (`present` / `late` / `absent`), `statusLabel` (mod locale), `time`, `period`, and optional -`absenceReason` (vanilla `truancy`; illness comes later). Empty is `[]`. Ceilings live on +`absenceReason` (vanilla `truancy` or `illness`). Empty is `[]`. Ceilings live on `BehaviorDef` (`lessonMarkMax`, `attendanceMax`). There is **no** POST/PUT to set marks or attendance — guests and owners only read. The people list does not include these fields. +`healthConditions` is the **Здоровье** tab (slice 14 phase 80): active medical conditions in the +same order as on the person. Each row has `defName`, `label` (mod locale), `severity` / `progress` +(0–1), optional `stageLabel`, and optional `effect` (short symptom line from the disease stage). +Empty is `[]` when healthy. There is **no** POST/PUT to heal or tend — the nurse system is +simulation-only; guests and owners only read. The people list does not include these fields. + ```json { "id": "f0.c0", @@ -467,6 +473,16 @@ attendance — guests and owners only read. The people list does not include the "absenceReason": null } ], + "healthConditions": [ + { + "defName": "CommonCold", + "label": "ОРВИ", + "severity": 0.55, + "progress": 0.2, + "stageLabel": null, + "effect": "Слабость, реже болтает" + } + ], "connections": { "family": { "parents": [ diff --git a/src/HSchool.Ai/Decision.cs b/src/HSchool.Ai/Decision.cs index 1c305b8..1618266 100644 --- a/src/HSchool.Ai/Decision.cs +++ b/src/HSchool.Ai/Decision.cs @@ -42,7 +42,8 @@ public readonly record struct ActorState( Intent Intent, bool LunchWindowOpen = false, ApparelActor Apparel = default, - TalkPlannerContext Talk = default); + TalkPlannerContext Talk = default, + float SocialWeightFactor = 1f); /// /// Picks a goal by weight and plans walk-then-do. No world, no clock — a table of inputs to an @@ -301,7 +302,18 @@ public static class DecisionPlanner return Intent.None; } - return new Intent(GoalKind.Leisure, action.DefName, action.Weight, action.DefName); + var weight = action.Weight; + if (TalkActions.IsTalk(action.DefName) && state.SocialWeightFactor != 1f) + { + weight *= Math.Max(0f, state.SocialWeightFactor); + } + + if (weight <= 0) + { + return Intent.None; + } + + return new Intent(GoalKind.Leisure, action.DefName, weight, action.DefName); } private static Decision Continue(ActorState state) diff --git a/src/HSchool.Ai/NurseCare.cs b/src/HSchool.Ai/NurseCare.cs new file mode 100644 index 0000000..61e2e26 --- /dev/null +++ b/src/HSchool.Ai/NurseCare.cs @@ -0,0 +1,122 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Ai; + +/// +/// Pure map / roster helpers for nurse visits. Queue state lives on the school worker. +/// Simpler than director summons: no hearing opinion, tend while co-located. +/// +public static class NurseCare +{ + /// Presence / card label while walking to the wait node or office. + public const string GoingAction = "GoingToNurse"; + + /// Presence / card label while queued in the corridor. + public const string WaitingAction = "WaitForNurse"; + + /// Presence / card label while being tended in the office. + public const string CareAction = "NurseCare"; + + public const byte PresenceNone = 0; + + public const byte PresenceGoing = 1; + + public const byte PresenceWaiting = 2; + + public const byte PresenceCare = 3; + + public const string OfficeRoomDef = "MedicalOffice"; + + public const string CorridorRoomDef = "Corridor"; + + public const string MedicineSkill = "Medicine"; + + public static string? OfficeNodeId(MapLayout map) + { + ArgumentNullException.ThrowIfNull(map); + return map.Rooms + .Where(room => room.Def.Equals(OfficeRoomDef, StringComparison.Ordinal)) + .Select(room => room.Id) + .OrderBy(id => id, StringComparer.Ordinal) + .FirstOrDefault(); + } + + /// + /// Corridor (or other neighbour) where the queue waits — never inside the office. + /// + public static string? WaitNodeId(MapLayout map, string? officeNodeId = null) + { + ArgumentNullException.ThrowIfNull(map); + officeNodeId ??= OfficeNodeId(map); + if (officeNodeId is null) + { + return null; + } + + string? bestCorridor = null; + string? bestAny = null; + foreach (var link in map.Links) + { + var other = OtherEnd(link, officeNodeId); + if (other is null) + { + continue; + } + + bestAny = PickOrdinal(bestAny, other); + if (map.NodeDef(other) is { } def + && def.Equals(CorridorRoomDef, StringComparison.Ordinal)) + { + bestCorridor = PickOrdinal(bestCorridor, other); + } + } + + return bestCorridor ?? bestAny; + } + + public static bool HasHiredNurse(Roster roster) => + roster.People.Any(person => + person.IsStaff + && Staffing.NursePosition.Equals(person.Position, StringComparison.Ordinal)); + + public static string? NurseId(Roster roster) => + roster.People + .Where(person => + person.IsStaff + && Staffing.NursePosition.Equals(person.Position, StringComparison.Ordinal)) + .Select(person => person.Id) + .OrderBy(id => id, StringComparer.Ordinal) + .FirstOrDefault(); + + public static float MedicineOf(Person? nurse) => + nurse is not null && nurse.Skills.TryGetValue(MedicineSkill, out var level) ? level : 0f; + + public static bool CanStart(Roster roster, MapLayout map) => + HasHiredNurse(roster) && OfficeNodeId(map) is not null && WaitNodeId(map) is not null; + + private static string? OtherEnd(MapLink link, string nodeId) + { + if (link.A.Equals(nodeId, StringComparison.Ordinal)) + { + return link.B; + } + + if (link.B.Equals(nodeId, StringComparison.Ordinal)) + { + return link.A; + } + + return null; + } + + private static string PickOrdinal(string? current, string candidate) + { + if (current is null) + { + return candidate; + } + + return string.CompareOrdinal(candidate, current) < 0 ? candidate : current; + } +} diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 1182bc0..50c8eed 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -212,6 +212,7 @@ const ru = { peopleTabNow: 'Сейчас', peopleTabConnections: 'Связи', peopleTabGradebook: 'Журнал', + peopleTabHealth: 'Здоровье', peopleTabPortrait: 'Портрет', peopleConnectionsFriends: 'Друзья', peopleConnectionsEnemies: 'Враги', @@ -244,6 +245,11 @@ const ru = { peopleGradebookSubject: 'Предмет', peopleGradebookMark: 'Оценка', peopleGradebookStatus: 'Статус', + peopleHealthEmpty: 'Активных состояний нет.', + peopleHealthCondition: 'Состояние', + peopleHealthSeverity: 'Тяжесть', + peopleHealthEffect: 'Эффект', + peopleHealthEffectNone: '—', classGradebookAverage: 'средний {mark}', classGradebookNoMarks: 'оценок пока нет', classGradebookAbsences: 'прогулов {n}', @@ -637,6 +643,7 @@ const en: Messages = { peopleTabNow: 'Now', peopleTabConnections: 'Connections', peopleTabGradebook: 'Gradebook', + peopleTabHealth: 'Health', peopleTabPortrait: 'Portrait', peopleConnectionsFriends: 'Friends', peopleConnectionsEnemies: 'Enemies', @@ -669,6 +676,11 @@ const en: Messages = { peopleGradebookSubject: 'Subject', peopleGradebookMark: 'Mark', peopleGradebookStatus: 'Status', + peopleHealthEmpty: 'No active conditions.', + peopleHealthCondition: 'Condition', + peopleHealthSeverity: 'Severity', + peopleHealthEffect: 'Effect', + peopleHealthEffectNone: '—', classGradebookAverage: 'avg {mark}', classGradebookNoMarks: 'no marks yet', classGradebookAbsences: 'absences {n}', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 68c46a5..1c12731 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -363,6 +363,8 @@ export interface PersonCard { readonly lessonMarks?: readonly LessonMarkEntry[] | null; /** Recent attendance rows — same order as the world list. */ readonly attendance?: readonly AttendanceEntry[] | null; + /** Active medical conditions — same order as the world list. */ + readonly healthConditions?: readonly HealthConditionEntry[] | null; } export interface OffenseEntry { @@ -373,6 +375,15 @@ export interface OffenseEntry { readonly otherFullName: string | null; } +export interface HealthConditionEntry { + readonly defName: string; + readonly label: string; + readonly severity: number; + readonly progress: number; + readonly stageLabel: string | null; + readonly effect: string | null; +} + export interface LessonMarkEntry { readonly subject: string; readonly subjectLabel: string; diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index 86751bd..9e96540 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -530,4 +530,42 @@ describe('renderPersonCard', () => { expect(panel?.textContent).toContain(t('peopleGradebookMarks')); expect(panel?.textContent).toContain(t('peopleGradebookAttendance')); }); + + it('shows empty health placeholder when conditions are missing', () => { + setLocale('ru'); + const root = document.createElement('div'); + renderPersonCard(root, card({ healthConditions: [] }), () => {}, options({ tab: 'health' })); + + expect(tabButton(root, 'health').textContent).toBe(t('peopleTabHealth')); + expect(root.textContent).toContain(t('peopleHealthEmpty')); + }); + + it('lists active health conditions on the health tab', () => { + setLocale('ru'); + const root = document.createElement('div'); + renderPersonCard( + root, + card({ + healthConditions: [ + { + defName: 'CommonCold', + label: 'ОРВИ', + severity: 0.55, + progress: 0.2, + stageLabel: null, + effect: 'Слабость, реже болтает', + }, + ], + }), + () => {}, + options({ tab: 'health' }), + ); + + const panel = root.querySelector('[data-card-tab="health"]'); + expect(panel).not.toBeNull(); + expect((panel as HTMLElement).hidden).toBe(false); + expect(panel?.textContent).toContain('ОРВИ'); + expect(panel?.textContent).toContain('55%'); + expect(panel?.textContent).toContain('Слабость, реже болтает'); + }); }); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index 8be01fe..892c56d 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -6,6 +6,7 @@ import { fillApparel } from './personCardApparel.ts'; import { fillCarry } from './personCardCarry.ts'; import { fillConnections } from './personCardConnections.ts'; import { fillGradebook } from './personCardGradebook.ts'; +import { fillHealth } from './personCardHealth.ts'; import { fillNow } from './personCardNow.ts'; import { fillOverview } from './personCardOverview.ts'; import { fillPortrait } from './personCardPortrait.ts'; @@ -25,6 +26,7 @@ export type PersonCardTab = | 'now' | 'connections' | 'gradebook' + | 'health' | 'portrait'; export interface RenderPersonCardOptions { @@ -124,6 +126,7 @@ export function renderPersonCard( now: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'now' } }), connections: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'connections' } }), gradebook: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'gradebook' } }), + health: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'health' } }), portrait: el('div', { class: 'people__tab-panel people__portrait-panel', dataset: { cardTab: 'portrait' } }), }; @@ -134,6 +137,7 @@ export function renderPersonCard( now: 'peopleTabNow', connections: 'peopleTabConnections', gradebook: 'peopleTabGradebook', + health: 'peopleTabHealth', portrait: 'peopleTabPortrait', }; @@ -160,6 +164,7 @@ export function renderPersonCard( fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options); fillConnections(panels.connections, card, onRelative, options); fillGradebook(panels.gradebook, card); + fillHealth(panels.health, card); fillPortrait(panels.portrait, card, options); const showTab = (next: PersonCardTab): void => { @@ -188,6 +193,7 @@ export function renderPersonCard( panels.now, panels.connections, panels.gradebook, + panels.health, panels.portrait, ); } diff --git a/src/HSchool.Client/src/ui/personCardHealth.ts b/src/HSchool.Client/src/ui/personCardHealth.ts new file mode 100644 index 0000000..31bb7bd --- /dev/null +++ b/src/HSchool.Client/src/ui/personCardHealth.ts @@ -0,0 +1,51 @@ +import type { HealthConditionEntry, PersonCard } from '../net/api.ts'; +import { t } from '../i18n/strings.ts'; +import { el } from './dom.ts'; + +export function fillHealth(parent: HTMLElement, card: PersonCard): void { + const rows = card.healthConditions ?? []; + if (rows.length === 0) { + parent.append(el('p', { class: 'panel__empty', text: t('peopleHealthEmpty') })); + return; + } + + const table = el('table', { class: 'people__health' }); + const body = el('tbody'); + table.append( + el( + 'thead', + {}, + el( + 'tr', + {}, + el('th', { text: t('peopleHealthCondition') }), + el('th', { text: t('peopleHealthSeverity') }), + el('th', { text: t('peopleHealthEffect') }), + ), + ), + body, + ); + + for (const row of rows) { + body.append(conditionRow(row)); + } + + parent.append(table); +} + +function conditionRow(row: HealthConditionEntry): HTMLElement { + const severityPct = Math.round(Math.max(0, Math.min(1, row.severity)) * 100); + const severityText = + row.stageLabel !== null && row.stageLabel.length > 0 + ? `${severityPct}% · ${row.stageLabel}` + : `${severityPct}%`; + const effect = + row.effect !== null && row.effect.length > 0 ? row.effect : t('peopleHealthEffectNone'); + return el( + 'tr', + {}, + el('td', { text: row.label }), + el('td', { text: severityText }), + el('td', { text: effect }), + ); +} diff --git a/src/HSchool.Client/src/ui/viewState.ts b/src/HSchool.Client/src/ui/viewState.ts index 7ec41b5..189a802 100644 --- a/src/HSchool.Client/src/ui/viewState.ts +++ b/src/HSchool.Client/src/ui/viewState.ts @@ -94,6 +94,7 @@ const CARD_TABS: readonly PersonCardTab[] = [ 'now', 'connections', 'gradebook', + 'health', 'portrait', ]; const LOG_DIRS: readonly PersonLogDir[] = ['asc', 'desc']; diff --git a/src/HSchool.Content/DiseaseDefValidator.cs b/src/HSchool.Content/DiseaseDefValidator.cs index d2004e5..9d6f112 100644 --- a/src/HSchool.Content/DiseaseDefValidator.cs +++ b/src/HSchool.Content/DiseaseDefValidator.cs @@ -92,8 +92,20 @@ internal static class DiseaseDefValidator $"DiseaseDef '{def.DefName}' warmthDecayFactor cannot be negative."); } + if (stage.TalkWeightFactor < 0f) + { + throw new ContentLoadException( + $"DiseaseDef '{def.DefName}' talkWeightFactor cannot be negative."); + } + previous = stage; } + + if (def.NurseSeekSeverity < 0f || def.NurseSeekSeverity > 1f) + { + throw new ContentLoadException( + $"DiseaseDef '{def.DefName}' nurseSeekSeverity must be 0–1."); + } } } } diff --git a/src/HSchool.Content/DiseaseDefs.cs b/src/HSchool.Content/DiseaseDefs.cs index 6f282f1..3881336 100644 --- a/src/HSchool.Content/DiseaseDefs.cs +++ b/src/HSchool.Content/DiseaseDefs.cs @@ -28,6 +28,15 @@ public sealed class DiseaseStage /// Multiplies Warmth decay. 1 = unchanged. public float WarmthDecayFactor { get; init; } = 1f; + + /// + /// Multiplies Chat / talk leisure weight and talk-circle start pull. 1 = unchanged; lower when + /// symptoms make conversation harder. + /// + public float TalkWeightFactor { get; init; } = 1f; + + /// Locale key for the person-card effect line. Empty — card shows severity only. + public string Effect { get; init; } = ""; } /// @@ -72,4 +81,18 @@ public sealed class DiseaseDef : Def /// When true, the carrier can infect during incubation before stage effects apply. public bool ContagiousDuringIncubation { get; init; } + + /// + /// After incubation, severity at or above this sends the person toward the nurse's office. + /// + public float NurseSeekSeverity { get; init; } = 0.35f; + + /// + /// Extra severity change per day while a nurse tends (usually negative). 0 — care only + /// advances progress. + /// + public float TendSeverityPerDay { get; init; } + + /// Extra progress per day while a nurse tends (speeds recovery / immunity). + public float TendProgressPerDay { get; init; } } diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 5363bdd..f15936b 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -793,6 +793,12 @@ internal static class PeopleDefValidator throw new ContentLoadException( $"BehaviorDef '{behavior.DefName}' diseaseOutbreakMinNewCases cannot be negative."); } + + if (behavior.NurseTendMedicineScale < 0f) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' nurseTendMedicineScale cannot be negative."); + } } private static void ValidateTopic(TopicDef topic, DefCatalog catalog) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index ebdb702..f0d8fa0 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -694,6 +694,12 @@ public sealed class BehaviorDef : Def /// public int DiseaseOutbreakMinNewCases { get; init; } = 3; + /// + /// Scales nurse tend deltas by Medicine skill: factor = this * (0.5 + Medicine/100). + /// Missing keeps 1. + /// + public float NurseTendMedicineScale { get; init; } = 1f; + public static IReadOnlyList DefaultLessonMarkThresholds { get; } = [ 0.85f, diff --git a/src/HSchool.People/DiseaseEffects.cs b/src/HSchool.People/DiseaseEffects.cs index 7c92e47..8d8ed8b 100644 --- a/src/HSchool.People/DiseaseEffects.cs +++ b/src/HSchool.People/DiseaseEffects.cs @@ -160,4 +160,177 @@ public static class DiseaseEffects return factor; } + + /// + /// Product of incubated stage talk factors (1 when healthy). Strong symptoms shrink Chat weight. + /// + public static float TalkWeightFactor(Person person, DefCatalog catalog, DateTime now) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentNullException.ThrowIfNull(catalog); + var list = person.Conditions; + if (list is null || list.Count == 0) + { + return 1f; + } + + var factor = 1f; + foreach (var condition in list) + { + if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease) || disease.Abstract) + { + continue; + } + + if (!IsIncubated(condition, disease, now) || !TryStage(disease, condition.Severity, out var stage)) + { + continue; + } + + factor *= stage.TalkWeightFactor; + } + + return factor; + } + + /// + /// Highest incubated severity that meets its DiseaseDef nurse-seek threshold, or null when none. + /// + public static HealthCondition? ConditionNeedingNurse(Person person, DefCatalog catalog, DateTime now) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentNullException.ThrowIfNull(catalog); + var list = person.Conditions; + if (list is null || list.Count == 0) + { + return null; + } + + HealthCondition? worst = null; + foreach (var condition in list) + { + if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease) || disease.Abstract) + { + continue; + } + + if (!IsIncubated(condition, disease, now)) + { + continue; + } + + if (condition.Severity + 1e-6f < disease.NurseSeekSeverity) + { + continue; + } + + if (worst is null || condition.Severity > worst.Severity) + { + worst = condition; + } + } + + return worst; + } + + /// + /// Medicine-scaled tend for one game-minute slice. Same peopleSeed + person + day → same curve. + /// + public static bool Tend( + Person person, + int peopleSeed, + int dayNumber, + double gameMinutes, + DefCatalog catalog, + float medicineSkill, + DateTime now, + float medicineScale = 1f) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentNullException.ThrowIfNull(catalog); + var list = person.Conditions; + if (list is null || list.Count == 0 || gameMinutes <= 0) + { + return false; + } + + var daySeed = Seed.Mix(peopleSeed, person.Id, dayNumber, Seed.NurseSalt); + var unit = (daySeed & int.MaxValue) / (float)int.MaxValue; + var dayFactor = 0.5f + unit; + var skillFactor = medicineScale * (0.5f + Math.Clamp(medicineSkill, 0f, 100f) / 100f); + var days = gameMinutes / (24d * 60d); + var changed = false; + List? recovered = null; + var clock = DateTime.SpecifyKind(now, DateTimeKind.Utc); + + foreach (var condition in list) + { + if (!catalog.Diseases.TryGetValue(condition.DefName, out var disease) || disease.Abstract) + { + continue; + } + + if (!IsIncubated(condition, disease, clock)) + { + continue; + } + + var severityDelta = (float)(disease.TendSeverityPerDay * dayFactor * skillFactor * days); + var progressDelta = (float)(disease.TendProgressPerDay * dayFactor * skillFactor * days); + if (severityDelta == 0f && progressDelta == 0f) + { + continue; + } + + var nextSeverity = Math.Clamp(condition.Severity + severityDelta, 0f, 1f); + var nextProgress = Math.Clamp(condition.Progress + progressDelta, 0f, 1f); + if (nextSeverity == condition.Severity && nextProgress == condition.Progress) + { + continue; + } + + condition.Severity = nextSeverity; + condition.Progress = nextProgress; + changed = true; + + if (nextProgress >= 1f) + { + recovered ??= []; + recovered.Add(condition); + } + } + + if (recovered is null) + { + return changed; + } + + foreach (var done in recovered) + { + list.Remove(done); + if (diseaseImmunity(done.DefName, catalog, out var disease) + && disease.ImmunityDays > 0f) + { + DiseaseImmunities.Grant(person, done.DefName, clock.AddDays(disease.ImmunityDays)); + } + } + + if (list.Count == 0) + { + person.Conditions = null; + } + + return true; + + static bool diseaseImmunity(string defName, DefCatalog catalog, out DiseaseDef disease) + { + if (catalog.Diseases.TryGetValue(defName, out disease!) && !disease.Abstract) + { + return true; + } + + disease = null!; + return false; + } + } } diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index 1348a85..b74bb7d 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -28,6 +28,7 @@ public static class Seed public const int DiseaseSalt = 20; public const int ContagionSalt = 21; public const int GradeTrailSalt = 22; + public const int NurseSalt = 23; /// 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.People/Staffing.cs b/src/HSchool.People/Staffing.cs index b823879..36c6776 100644 --- a/src/HSchool.People/Staffing.cs +++ b/src/HSchool.People/Staffing.cs @@ -45,6 +45,8 @@ public static class Staffing public const string PrincipalPosition = "Principal"; + public const string NursePosition = "Nurse"; + public static StaffingOutcome UnknownSchool() => Fail(StaffingError.UnknownSchool, new Roster([], [], []), ApplicantPool.Empty, allocated: 0, payroll: 0, attempted: 0); diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 2afaa87..e5f548b 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -90,7 +90,8 @@ internal sealed record PersonCardResponse( string? ClassTeacherName = null, IReadOnlyList? Offenses = null, IReadOnlyList? LessonMarks = null, - IReadOnlyList? Attendance = null); + IReadOnlyList? Attendance = null, + IReadOnlyList? HealthConditions = null); internal sealed record OffenseEntryResponse( string Kind, @@ -99,6 +100,15 @@ internal sealed record OffenseEntryResponse( string? OtherPersonId, string? OtherFullName); +/// One active medical condition on the person card (slice 14 phase 80). +internal sealed record HealthConditionEntryResponse( + string DefName, + string Label, + float Severity, + float Progress, + string? StageLabel, + string? Effect); + /// One recent lesson mark on the person card. Same order as the world list. internal sealed record LessonMarkEntryResponse( string Subject, diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index f5b0d59..5dc5d21 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -63,6 +63,7 @@ internal static partial class PersonCardReader var skills = LiveSkills(school.World, personId); var activityId = LiveActivity(school.World, personId) ?? school.DirectorSummonPresenceActionId(personId) + ?? school.NurseCarePresenceActionId(personId) ?? school.ParentMeetingPresenceActionId(personId); string? activityLabel = null; if (activityId is not null && catalog is not null && catalog.Actions.TryGetValue(activityId, out var action)) @@ -113,7 +114,75 @@ internal static partial class PersonCardReader ClassTeacherName: classTeacherName, Offenses: Offenses(roster, person, catalog, locale), LessonMarks: LessonMarks(person, catalog, locale), - Attendance: Attendance(person, catalog, locale)); + Attendance: Attendance(person, catalog, locale), + HealthConditions: Health(person, catalog, locale, school.Clock.Time)); + } + + private static IReadOnlyList Health( + Person person, + DefCatalog? catalog, + string locale, + DateTime now) + { + var rows = person.Conditions; + if (rows is null || rows.Count == 0) + { + return []; + } + + var result = new HealthConditionEntryResponse[rows.Count]; + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + string label = row.DefName; + string? stageLabel = null; + string? effect = null; + if (catalog is not null && catalog.Diseases.TryGetValue(row.DefName, out var disease)) + { + label = catalog.Label(locale, disease); + if (DiseaseEffects.TryStage(disease, row.Severity, out var stage)) + { + stageLabel = StageLabel(catalog, locale, row.DefName, stage); + effect = EffectLabel(catalog, locale, stage); + } + } + + result[i] = new HealthConditionEntryResponse( + row.DefName, + label, + row.Severity, + row.Progress, + stageLabel, + effect); + } + + return result; + } + + private static string? StageLabel(DefCatalog catalog, string locale, string defName, DiseaseStage stage) + { + var key = $"{defName}.stage.{stage.MinSeverity:0.##}"; + if (catalog.HasText(locale, key)) + { + return catalog.Text(locale, key); + } + + return null; + } + + private static string? EffectLabel(DefCatalog catalog, string locale, DiseaseStage stage) + { + if (string.IsNullOrWhiteSpace(stage.Effect)) + { + return null; + } + + if (catalog.HasText(locale, stage.Effect)) + { + return catalog.Text(locale, stage.Effect); + } + + return stage.Effect; } private static IReadOnlyList LessonMarks( diff --git a/src/HSchool.Server/mods/core/defs/actions/living.jsonc b/src/HSchool.Server/mods/core/defs/actions/living.jsonc index b289c45..9d42695 100644 --- a/src/HSchool.Server/mods/core/defs/actions/living.jsonc +++ b/src/HSchool.Server/mods/core/defs/actions/living.jsonc @@ -171,6 +171,28 @@ "roles": ["staff", "parent"], "weight": 0, }, + { + // Nurse visit presence / card labels (slice 14 phase 80). Weight 0 — never planner picks. + "defName": "GoingToNurse", + "room": "Corridor", + "minutes": 1, + "roles": ["student", "staff"], + "weight": 0, + }, + { + "defName": "WaitForNurse", + "room": "Corridor", + "minutes": 1, + "roles": ["student", "staff"], + "weight": 0, + }, + { + "defName": "NurseCare", + "room": "MedicalOffice", + "minutes": 8, + "roles": ["student", "staff"], + "weight": 0, + }, { "defName": "ChangeClothesMale", "room": "MaleChangingRoom", diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index 2651dd7..a91704e 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -132,4 +132,6 @@ "diseaseVectorScale": 1, // Contagion cases in one day at or above this raise diseaseOutbreak (slice 14 phase 79). "diseaseOutbreakMinNewCases": 3, + // Nurse tend scales with Medicine skill (slice 14 phase 80). + "nurseTendMedicineScale": 1, } diff --git a/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc b/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc index e63bf35..1c368a8 100644 --- a/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc +++ b/src/HSchool.Server/mods/core/defs/diseases/vanilla.jsonc @@ -12,10 +12,13 @@ "nodeContagionChancePerDay": 0.55, "classContagionChancePerDay": 0.25, "contagiousDuringIncubation": true, + "nurseSeekSeverity": 0.35, + "tendSeverityPerDay": -0.3, + "tendProgressPerDay": 0.4, "stages": [ - { "minSeverity": 0, "severityPerDay": 0.25, "progressPerDay": 0.05, "lessonLearningFactor": 0.85, "stayHomeChance": 0.15, "warmthDecayFactor": 1.1 }, - { "minSeverity": 0.4, "severityPerDay": 0.1, "progressPerDay": 0.12, "lessonLearningFactor": 0.65, "stayHomeChance": 0.45, "warmthDecayFactor": 1.2 }, - { "minSeverity": 0.7, "severityPerDay": -0.05, "progressPerDay": 0.2, "lessonLearningFactor": 0.45, "stayHomeChance": 0.75, "warmthDecayFactor": 1.3 }, + { "minSeverity": 0, "severityPerDay": 0.25, "progressPerDay": 0.05, "lessonLearningFactor": 0.85, "stayHomeChance": 0.15, "warmthDecayFactor": 1.1, "talkWeightFactor": 0.9, "effect": "ColdEffectMild" }, + { "minSeverity": 0.4, "severityPerDay": 0.1, "progressPerDay": 0.12, "lessonLearningFactor": 0.65, "stayHomeChance": 0.45, "warmthDecayFactor": 1.2, "talkWeightFactor": 0.55, "effect": "ColdEffectModerate" }, + { "minSeverity": 0.7, "severityPerDay": -0.05, "progressPerDay": 0.2, "lessonLearningFactor": 0.45, "stayHomeChance": 0.75, "warmthDecayFactor": 1.3, "talkWeightFactor": 0.3, "effect": "ColdEffectHeavy" }, ], }, { @@ -31,10 +34,13 @@ "nodeContagionChancePerDay": 0.65, "classContagionChancePerDay": 0.35, "contagiousDuringIncubation": true, + "nurseSeekSeverity": 0.3, + "tendSeverityPerDay": -0.4, + "tendProgressPerDay": 0.5, "stages": [ - { "minSeverity": 0, "severityPerDay": 0.35, "progressPerDay": 0.03, "lessonLearningFactor": 0.7, "stayHomeChance": 0.35, "warmthDecayFactor": 1.2 }, - { "minSeverity": 0.35, "severityPerDay": 0.15, "progressPerDay": 0.08, "lessonLearningFactor": 0.4, "stayHomeChance": 0.7, "warmthDecayFactor": 1.4 }, - { "minSeverity": 0.65, "severityPerDay": -0.08, "progressPerDay": 0.18, "lessonLearningFactor": 0.25, "stayHomeChance": 0.95, "warmthDecayFactor": 1.5 }, + { "minSeverity": 0, "severityPerDay": 0.35, "progressPerDay": 0.03, "lessonLearningFactor": 0.7, "stayHomeChance": 0.35, "warmthDecayFactor": 1.2, "talkWeightFactor": 0.7, "effect": "FluEffectMild" }, + { "minSeverity": 0.35, "severityPerDay": 0.15, "progressPerDay": 0.08, "lessonLearningFactor": 0.4, "stayHomeChance": 0.7, "warmthDecayFactor": 1.4, "talkWeightFactor": 0.35, "effect": "FluEffectModerate" }, + { "minSeverity": 0.65, "severityPerDay": -0.08, "progressPerDay": 0.18, "lessonLearningFactor": 0.25, "stayHomeChance": 0.95, "warmthDecayFactor": 1.5, "talkWeightFactor": 0.15, "effect": "FluEffectHeavy" }, ], }, { @@ -47,9 +53,12 @@ "nodeContagionChancePerDay": 0.3, "classContagionChancePerDay": 0, "contagiousDuringIncubation": false, + "nurseSeekSeverity": 0.4, + "tendSeverityPerDay": -0.35, + "tendProgressPerDay": 0.55, "stages": [ - { "minSeverity": 0, "severityPerDay": 0.4, "progressPerDay": 0.08, "lessonLearningFactor": 0.6, "stayHomeChance": 0.5, "warmthDecayFactor": 1 }, - { "minSeverity": 0.5, "severityPerDay": -0.1, "progressPerDay": 0.25, "lessonLearningFactor": 0.35, "stayHomeChance": 0.85, "warmthDecayFactor": 1 }, + { "minSeverity": 0, "severityPerDay": 0.4, "progressPerDay": 0.08, "lessonLearningFactor": 0.6, "stayHomeChance": 0.5, "warmthDecayFactor": 1, "talkWeightFactor": 0.5, "effect": "StomachEffectMild" }, + { "minSeverity": 0.5, "severityPerDay": -0.1, "progressPerDay": 0.25, "lessonLearningFactor": 0.35, "stayHomeChance": 0.85, "warmthDecayFactor": 1, "talkWeightFactor": 0.25, "effect": "StomachEffectHeavy" }, ], }, { @@ -61,10 +70,13 @@ "coldBelowC": 8, "coldChancePerDay": 0.05, "snowChancePerDay": 0.03, + "nurseSeekSeverity": 0.4, + "tendSeverityPerDay": -0.25, + "tendProgressPerDay": 0.35, "stages": [ - { "minSeverity": 0, "severityPerDay": 0.2, "progressPerDay": 0.04, "lessonLearningFactor": 0.8, "stayHomeChance": 0.2, "warmthDecayFactor": 1.05 }, - { "minSeverity": 0.45, "severityPerDay": 0.05, "progressPerDay": 0.1, "lessonLearningFactor": 0.55, "stayHomeChance": 0.55, "warmthDecayFactor": 1.1 }, - { "minSeverity": 0.75, "severityPerDay": -0.06, "progressPerDay": 0.22, "lessonLearningFactor": 0.4, "stayHomeChance": 0.8, "warmthDecayFactor": 1.15 }, + { "minSeverity": 0, "severityPerDay": 0.2, "progressPerDay": 0.04, "lessonLearningFactor": 0.8, "stayHomeChance": 0.2, "warmthDecayFactor": 1.05, "talkWeightFactor": 0.75, "effect": "OtitisEffectMild" }, + { "minSeverity": 0.45, "severityPerDay": 0.05, "progressPerDay": 0.1, "lessonLearningFactor": 0.55, "stayHomeChance": 0.55, "warmthDecayFactor": 1.1, "talkWeightFactor": 0.45, "effect": "OtitisEffectModerate" }, + { "minSeverity": 0.75, "severityPerDay": -0.06, "progressPerDay": 0.22, "lessonLearningFactor": 0.4, "stayHomeChance": 0.8, "warmthDecayFactor": 1.15, "talkWeightFactor": 0.25, "effect": "OtitisEffectHeavy" }, ], }, ] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index bc178fa..8246ca8 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -245,9 +245,23 @@ "GoingToPrincipal": "going to the principal", "WaitForPrincipal": "waiting at the principal's office", "GoingToParentMeeting": "going to a parent meeting", + "GoingToNurse": "going to the nurse", + "WaitForNurse": "waiting at the nurse's office", + "NurseCare": "seeing the nurse", "CommonCold": "Common cold", "Influenza": "Influenza", "StomachBug": "Stomach bug", "Otitis": "Ear infection", + "ColdEffectMild": "Mild sniffles, learns a bit worse", + "ColdEffectModerate": "Weakness, chats less", + "ColdEffectHeavy": "Strong discomfort, barely talks", + "FluEffectMild": "Aches, poorer attention", + "FluEffectModerate": "Fever, little energy for talk", + "FluEffectHeavy": "Severe weakness, barely learns", + "StomachEffectMild": "Nausea, avoids food and chatter", + "StomachEffectHeavy": "Strong discomfort, better to rest", + "OtitisEffectMild": "Earache, hears worse", + "OtitisEffectModerate": "Pain hurts lessons and talk", + "OtitisEffectHeavy": "Severe pain, barely talks", "core": "Core", } diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index dfb6365..e3b954d 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -245,9 +245,23 @@ "GoingToPrincipal": "идёт к директору", "WaitForPrincipal": "ждёт у кабинета директора", "GoingToParentMeeting": "идёт на собрание", + "GoingToNurse": "идёт к медсестре", + "WaitForNurse": "ждёт у медкабинета", + "NurseCare": "на приёме у медсестры", "CommonCold": "ОРВИ", "Influenza": "Грипп", "StomachBug": "Кишечная инфекция", "Otitis": "Отит", + "ColdEffectMild": "Лёгкий насморк, учится чуть хуже", + "ColdEffectModerate": "Слабость, реже болтает", + "ColdEffectHeavy": "Сильный дискомфорт, почти не общается", + "FluEffectMild": "Ломота, сниженное внимание", + "FluEffectModerate": "Высокая температура, мало сил на разговоры", + "FluEffectHeavy": "Тяжёлая слабость, почти не учится", + "StomachEffectMild": "Тошнота, избегает еды и болтовни", + "StomachEffectHeavy": "Сильный дискомфорт, лучше лежать", + "OtitisEffectMild": "Боль в ухе, хуже слышит", + "OtitisEffectModerate": "Боль мешает уроку и разговору", + "OtitisEffectHeavy": "Сильная боль, почти не общается", "core": "Базовая игра", } diff --git a/src/HSchool.Simulation/ActivitySystem.cs b/src/HSchool.Simulation/ActivitySystem.cs index 4886756..1751e7d 100644 --- a/src/HSchool.Simulation/ActivitySystem.cs +++ b/src/HSchool.Simulation/ActivitySystem.cs @@ -195,6 +195,10 @@ internal static class ActivitySystem actionId is not null && actionId.Equals(DirectorSummons.HearingAction, StringComparison.Ordinal); + internal static bool IsNurseCare(string? actionId) => + actionId is not null + && actionId.Equals(NurseCare.CareAction, StringComparison.Ordinal); + internal static bool IsParentMeeting(string? actionId) => actionId is not null && actionId.Equals(ParentMeetings.MeetingAction, StringComparison.Ordinal); diff --git a/src/HSchool.Simulation/NurseCareSystem.cs b/src/HSchool.Simulation/NurseCareSystem.cs new file mode 100644 index 0000000..4f2982c --- /dev/null +++ b/src/HSchool.Simulation/NurseCareSystem.cs @@ -0,0 +1,373 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.People; + +namespace HSchool.Simulation; + +/// +/// Sick people seek the medical office; FIFO corridor wait; nurse tend while co-located. +/// Only the school worker thread mutates . +/// +internal static class NurseCareSystem +{ + private static readonly QueryDescription PresenceQuery = + new QueryDescription().WithAll(); + + public static bool IsVisiting(School school, string personId) => + school.NurseVisits.Any(row => row.PersonId.Equals(personId, StringComparison.Ordinal)); + + public static bool TryDutyRoom(School school, string personId, out string roomId) + { + roomId = null!; + var ticket = school.NurseVisits.FirstOrDefault(row => + row.PersonId.Equals(personId, StringComparison.Ordinal)); + if (ticket is null || school.Map is null) + { + return false; + } + + var office = NurseCare.OfficeNodeId(school.Map); + var wait = NurseCare.WaitNodeId(school.Map, office); + if (office is null || wait is null) + { + return false; + } + + roomId = ticket.Admitted ? office : wait; + return true; + } + + public static byte ResolvePresencePhase(School school, string personId) + { + var ticket = school.NurseVisits.FirstOrDefault(row => + row.PersonId.Equals(personId, StringComparison.Ordinal)); + if (ticket is null) + { + return NurseCare.PresenceNone; + } + + var walking = false; + var caring = false; + string? nodeId = null; + school.World.Query( + in PresenceQuery, + (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent _) => + { + if (!identity.Id.Equals(personId, StringComparison.Ordinal)) + { + return; + } + + nodeId = presence.NodeId; + walking = presence.Path.Length > 0 || presence.RemainingMinutes > 0 + || (presence.DestinationId is not null + && !presence.DestinationId.Equals(presence.NodeId, StringComparison.Ordinal)); + caring = activity.IsActive && ActivitySystem.IsNurseCare(activity.ActionId); + }); + + if (caring) + { + return NurseCare.PresenceCare; + } + + if (walking) + { + return NurseCare.PresenceGoing; + } + + var office = NurseCare.OfficeNodeId(school.Map!); + if (ticket.Admitted + && office is not null + && nodeId is not null + && nodeId.Equals(office, StringComparison.Ordinal)) + { + return NurseCare.PresenceCare; + } + + return NurseCare.PresenceWaiting; + } + + public static string? PresenceActionId(byte phase) => phase switch + { + NurseCare.PresenceGoing => NurseCare.GoingAction, + NurseCare.PresenceWaiting => NurseCare.WaitingAction, + NurseCare.PresenceCare => NurseCare.CareAction, + _ => null, + }; + + public static void ClearAll(School school) + { + if (school.NurseVisits.Count == 0) + { + return; + } + + var ids = school.NurseVisits.Select(row => row.PersonId).ToArray(); + school.NurseVisits.Clear(); + foreach (var id in ids) + { + PresenceSystem.Enqueue(school, id); + } + } + + public static void Apply(School school, double gameMinutes) + { + if (school.Roster is null || school.Map is null || school.Catalog is null) + { + return; + } + + if (!NurseCare.CanStart(school.Roster, school.Map)) + { + ClearAll(school); + return; + } + + EnqueueNeedingCare(school); + DropRecovered(school); + AdmitHead(school); + ApplyTend(school, gameMinutes); + TryStartCareActivity(school); + } + + private static void EnqueueNeedingCare(School school) + { + var catalog = school.Catalog!; + var now = school.Clock.Time; + foreach (var person in school.Roster!.People.OrderBy(row => row.Id, StringComparer.Ordinal)) + { + if (IsVisiting(school, person.Id)) + { + continue; + } + + if (DiseaseEffects.ConditionNeedingNurse(person, catalog, now) is null) + { + continue; + } + + // Pupils and staff on campus can seek care; parents off-roster roles skip. + if (!person.IsStudent && !person.IsStaff) + { + continue; + } + + school.NurseVisits.Add(new NurseVisitTicket(person.Id, school.NextNurseVisitOrder++, admitted: false)); + PresenceSystem.Enqueue(school, person.Id); + } + } + + private static void DropRecovered(School school) + { + var catalog = school.Catalog!; + var now = school.Clock.Time; + for (var i = school.NurseVisits.Count - 1; i >= 0; i--) + { + var ticket = school.NurseVisits[i]; + var person = school.Roster!.People.FirstOrDefault(row => + row.Id.Equals(ticket.PersonId, StringComparison.Ordinal)); + if (person is not null + && DiseaseEffects.ConditionNeedingNurse(person, catalog, now) is not null) + { + continue; + } + + school.NurseVisits.RemoveAt(i); + PresenceSystem.Enqueue(school, ticket.PersonId); + } + } + + private static void AdmitHead(School school) + { + if (school.NurseVisits.Any(row => row.Admitted)) + { + return; + } + + var head = school.NurseVisits.OrderBy(row => row.Order).FirstOrDefault(); + if (head is null) + { + return; + } + + head.Admitted = true; + PresenceSystem.Enqueue(school, head.PersonId); + } + + private static void ApplyTend(School school, double gameMinutes) + { + if (gameMinutes <= 0) + { + return; + } + + var nurseId = NurseCare.NurseId(school.Roster!); + if (nurseId is null) + { + return; + } + + var office = NurseCare.OfficeNodeId(school.Map!); + if (office is null || !IsIdleOrCaringIn(school, nurseId, office)) + { + return; + } + + var nurse = school.Roster!.People.First(row => row.Id.Equals(nurseId, StringComparison.Ordinal)); + var medicine = LiveMedicine(school, nurseId) ?? NurseCare.MedicineOf(nurse); + var scale = school.Catalog!.BehaviorRules?.NurseTendMedicineScale ?? 1f; + var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber; + var changed = false; + + foreach (var ticket in school.NurseVisits.Where(row => row.Admitted).ToArray()) + { + if (!IsInNode(school, ticket.PersonId, office)) + { + continue; + } + + var patient = school.Roster.People.FirstOrDefault(row => + row.Id.Equals(ticket.PersonId, StringComparison.Ordinal)); + if (patient is null) + { + continue; + } + + if (DiseaseEffects.Tend( + patient, + school.PeopleSeed, + dayNumber, + gameMinutes, + school.Catalog, + medicine, + school.Clock.Time, + scale)) + { + changed = true; + } + } + + if (changed) + { + school.RosterTalkDirty = true; + } + } + + private static void TryStartCareActivity(School school) + { + var admitted = school.NurseVisits.FirstOrDefault(row => row.Admitted); + if (admitted is null || school.Catalog is null) + { + return; + } + + var office = NurseCare.OfficeNodeId(school.Map!); + if (office is null + || !IsInNode(school, admitted.PersonId, office) + || !school.Catalog.Actions.TryGetValue(NurseCare.CareAction, out var action) + || action.Abstract) + { + return; + } + + var minutes = action.Minutes > 0f ? action.Minutes : 5f; + school.World.Query( + in PresenceQuery, + (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) => + { + if (!identity.Id.Equals(admitted.PersonId, StringComparison.Ordinal)) + { + return; + } + + if (activity.IsActive || presence.Path.Length > 0 || presence.RemainingMinutes > 0) + { + return; + } + + if (presence.NodeId is null + || !presence.NodeId.Equals(office, StringComparison.Ordinal)) + { + return; + } + + activity = new PersonActivity(NurseCare.CareAction, null, minutes); + intent = Intent.None; + }); + } + + private static bool IsInNode(School school, string personId, string nodeId) + { + var found = false; + school.World.Query( + in PresenceQuery, + (ref PersonIdentity identity, ref Presence presence, ref PersonActivity _, ref Intent _) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal) + && presence.NodeId is not null + && presence.NodeId.Equals(nodeId, StringComparison.Ordinal) + && presence.Path.Length == 0 + && presence.RemainingMinutes <= 0) + { + found = true; + } + }); + return found; + } + + private static bool IsIdleOrCaringIn(School school, string personId, string nodeId) + { + var found = false; + school.World.Query( + in PresenceQuery, + (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent _) => + { + if (!identity.Id.Equals(personId, StringComparison.Ordinal)) + { + return; + } + + if (presence.NodeId is null + || !presence.NodeId.Equals(nodeId, StringComparison.Ordinal) + || presence.Path.Length > 0 + || presence.RemainingMinutes > 0) + { + return; + } + + // Nurse on MedicalDuty stands in the office; any active non-care action blocks tend. + if (activity.IsActive && !ActivitySystem.IsNurseCare(activity.ActionId)) + { + return; + } + + found = true; + }); + return found; + } + + private static float? LiveMedicine(School school, string personId) + { + float? found = null; + var query = new QueryDescription().WithAll(); + school.World.Query(in query, (ref PersonIdentity identity, ref PersonSkills skills) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal) + && skills.Values.TryGetValue(NurseCare.MedicineSkill, out var level)) + { + found = level; + } + }); + return found; + } +} + +/// One nurse-visit ticket. is FIFO; lower goes first. +internal sealed class NurseVisitTicket(string personId, int order, bool admitted) +{ + public string PersonId { get; } = personId; + + public int Order { get; } = order; + + public bool Admitted { get; set; } = admitted; +} diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs index 1a2661d..1d44a6c 100644 --- a/src/HSchool.Simulation/PresenceSystem.cs +++ b/src/HSchool.Simulation/PresenceSystem.cs @@ -332,7 +332,8 @@ internal static class PresenceSystem ? slot.Kind != DaySlotKind.Outside : slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index); if (DirectorSummonSystem.IsSummoned(school, person.Id) - || ParentMeetingSystem.IsInMeeting(school, person.Id)) + || ParentMeetingSystem.IsInMeeting(school, person.Id) + || NurseCareSystem.IsVisiting(school, person.Id)) { bound = true; } @@ -353,6 +354,11 @@ internal static class PresenceSystem return null; } + if (activity.IsActive && ActivitySystem.IsNurseCare(activity.ActionId)) + { + return null; + } + if (activity.IsActive && ActivitySystem.IsParentMeeting(activity.ActionId)) { return null; @@ -371,6 +377,16 @@ internal static class PresenceSystem return null; } + if (NurseCareSystem.TryDutyRoom(school, person.Id, out var nurseArrive)) + { + presence = PresenceStepper.StartWalk( + Presence.OffCampus, + walks, + nurseArrive, + headingHome: false); + return null; + } + if (plan.AppearAt is { } appear && now >= appear && (plan.WalkHomeAt is null || now < plan.WalkHomeAt)) { var dest = plan.FirstRoom ?? Duty.RoomAt( @@ -384,6 +400,10 @@ internal static class PresenceSystem { dest = summonArrive; } + else if (NurseCareSystem.TryDutyRoom(school, person.Id, out var nurseDest)) + { + dest = nurseDest; + } presence = dest is null ? Presence.OffCampus @@ -398,7 +418,8 @@ internal static class PresenceSystem if (plan.WalkHomeAt is { } leave && now >= leave && !DirectorSummonSystem.IsSummoned(school, person.Id) - && !ParentMeetingSystem.IsInMeeting(school, person.Id)) + && !ParentMeetingSystem.IsInMeeting(school, person.Id) + && !NurseCareSystem.IsVisiting(school, person.Id)) { activity = PersonActivity.Idle; intent = Intent.None; @@ -423,6 +444,11 @@ internal static class PresenceSystem duty = meetingRoom; bound = true; } + else if (NurseCareSystem.TryDutyRoom(school, person.Id, out var nurseRoom)) + { + duty = nurseRoom; + bound = true; + } if (duty is null) { @@ -437,6 +463,9 @@ internal static class PresenceSystem && SchoolDay.IsLunchWindow(frame, slot, ClassOf(school, person)?.Year); var apparel = ApparelPresence.Build(school, person, ClassOf(school, person)); var talk = BuildTalkContext(school, person, bound, lunchOpen, slot); + var socialWeight = school.Catalog is null + ? 1f + : DiseaseEffects.TalkWeightFactor(person, school.Catalog, now); var state = new ActorState( presence.NodeId, presence.DestinationId, @@ -451,7 +480,8 @@ internal static class PresenceSystem intent, lunchOpen, apparel, - talk); + talk, + socialWeight); var decision = DecisionPlanner.Decide( school.Catalog!, school.Map!, @@ -462,19 +492,21 @@ internal static class PresenceSystem var changing = activity.IsActive && ActivitySystem.IsChangeClothes(activity.ActionId); var inTalk = activity.IsActive && TalkActions.IsTalk(activity.ActionId); var inHearing = activity.IsActive && ActivitySystem.IsPrincipalHearing(activity.ActionId); + var inNurseCare = activity.IsActive && ActivitySystem.IsNurseCare(activity.ActionId); if (decision.WalkTo is not null && !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal) && activity.IsActive && !changing && !inTalk - && !inHearing) + && !inHearing + && !inNurseCare) { activity = PersonActivity.Idle; } intent = decision.Intent; - if (decision.WalkTo is not null && !changing && !inTalk && !inHearing) + if (decision.WalkTo is not null && !changing && !inTalk && !inHearing && !inNurseCare) { presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false); } diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 5207d1c..b150aa8 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -158,6 +158,13 @@ public sealed class School : IDisposable internal int NextSummonOrder { get; set; } + /// + /// Nurse visit FIFO. Worker thread only — never HTTP. Cleared on morning / skip. + /// + internal List NurseVisits { get; } = []; + + internal int NextNurseVisitOrder { get; set; } + /// /// Active parent meetings. Worker thread only — never HTTP. Cleared on morning / skip. /// @@ -348,6 +355,7 @@ public sealed class School : IDisposable Clock.JumpTo(next.Value); TalkCircleSystem.AbandonAll(this); DirectorSummonSystem.ClearAll(this); + NurseCareSystem.ClearAll(this); ParentMeetingSystem.ClearAll(this); ResetDayLog(); var peopleChanged = TryYearlyIntake(before, next.Value); @@ -423,7 +431,8 @@ public sealed class School : IDisposable ResetDayLog(); // Hung summons do not survive the work morning — same clear as skip empty. DirectorSummonSystem.ClearAll(this); - ParentMeetingSystem.ClearAll(this); + NurseCareSystem.ClearAll(this); + ParentMeetingSystem.ClearAll(this); } peopleChanged = TryYearlyIntake(before, Clock.Time); @@ -481,6 +490,12 @@ public sealed class School : IDisposable public string? DirectorSummonPresenceActionId(string personId) => DirectorSummonSystem.PresenceActionId(DirectorSummonPresencePhase(personId)); + public byte NurseCarePresencePhase(string personId) => + NurseCareSystem.ResolvePresencePhase(this, personId); + + public string? NurseCarePresenceActionId(string personId) => + NurseCareSystem.PresenceActionId(NurseCarePresencePhase(personId)); + /// /// Card label while walking to / sitting in a parent meeting without a live activity yet. /// @@ -528,6 +543,7 @@ public sealed class School : IDisposable } DirectorSummonSystem.Apply(this); + NurseCareSystem.Apply(this, gameMinutes); ParentMeetingSystem.Apply(this); PersonDayLog.Sync(this); diff --git a/src/HSchool.Simulation/TalkCircleSystem.cs b/src/HSchool.Simulation/TalkCircleSystem.cs index 01f56e6..f6a724c 100644 --- a/src/HSchool.Simulation/TalkCircleSystem.cs +++ b/src/HSchool.Simulation/TalkCircleSystem.cs @@ -57,6 +57,7 @@ internal static class TalkCircleSystem school.ApologyDebts.Clear(); DirectorSummonSystem.ClearAll(school); + NurseCareSystem.ClearAll(school); ParentMeetingSystem.ClearAll(school); school.World.Query(in People, (ref PersonActivity activity) => { diff --git a/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs b/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs index 1a9c084..7f24866 100644 --- a/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs +++ b/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs @@ -346,6 +346,71 @@ public class DecisionPlannerTests Assert.Equal(DecisionPlanner.DutyLessonWeight, peckishLesson.Intent.Weight); } + [Fact] + public void LowSocialWeight_MakesChatLessAttractiveThanControl() + { + var (catalog, map, walks) = World(); + var corridor = map.Rooms.First(room => room.Def == "Corridor").Id; + var needs = new Dictionary(StringComparer.Ordinal) + { + ["Toilet"] = 1f, + ["Hunger"] = 1f, + ["Social"] = 0.4f, + ["Sleep"] = 1f, + }; + + var healthy = DecisionPlanner.Decide( + catalog, + map, + walks, + new ActorState( + corridor, + null, + false, + false, + true, + false, + false, + false, + corridor, + needs, + Intent.None, + SocialWeightFactor: 1f), + (_, _) => 0); + var sick = DecisionPlanner.Decide( + catalog, + map, + walks, + new ActorState( + corridor, + null, + false, + false, + true, + false, + false, + false, + corridor, + needs, + Intent.None, + SocialWeightFactor: 0.15f), + (_, _) => 0); + + var chatWeight = catalog.Actions[TalkActions.Chat].Weight; + Assert.True(chatWeight * 0.15f < chatWeight); + if (healthy.Intent.ActionId == TalkActions.Chat) + { + Assert.True( + sick.Intent.ActionId != TalkActions.Chat + || sick.Intent.Weight < healthy.Intent.Weight); + } + else + { + // Control did not pick Chat — still assert the scaled candidate loses to other leisure. + Assert.True(sick.Intent.ActionId != TalkActions.Chat || sick.Intent.Weight <= chatWeight * 0.15f + 1e-3f); + } + } + private static Decision Decide( DefCatalog catalog, MapLayout map, diff --git a/tests/HSchool.People.Tests/NurseTendAndTalkWeightTests.cs b/tests/HSchool.People.Tests/NurseTendAndTalkWeightTests.cs new file mode 100644 index 0000000..b10b0d2 --- /dev/null +++ b/tests/HSchool.People.Tests/NurseTendAndTalkWeightTests.cs @@ -0,0 +1,102 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.People.Tests; + +public class NurseTendAndTalkWeightTests +{ + private static readonly DateTime Now = new(2012, 4, 3, 10, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Tend_WithMedicine_DropsSeverityFasterThanNaturalTick() + { + var catalog = LoadVanilla(); + var tended = Blank("a"); + var control = Blank("b"); + Onset(tended, "CommonCold", 0.55f); + Onset(control, "CommonCold", 0.55f); + + Assert.True(DiseaseEffects.Tend( + tended, + peopleSeed: 7, + dayNumber: 100, + gameMinutes: 24 * 60, + catalog, + medicineSkill: 90f, + Now)); + Assert.True(HealthConditions.Tick( + control, + peopleSeed: 7, + dayNumber: 100, + gameMinutes: 24 * 60, + catalog, + Now)); + + Assert.True(tended.Conditions![0].Severity < control.Conditions![0].Severity); + Assert.True(tended.Conditions[0].Progress > control.Conditions[0].Progress); + } + + [Fact] + public void HeavySymptom_TalkWeightBelowHealthyControl() + { + var catalog = LoadVanilla(); + var sick = Blank("s"); + var healthy = Blank("h"); + Onset(sick, "Influenza", 0.8f); + + var sickFactor = DiseaseEffects.TalkWeightFactor(sick, catalog, Now); + var healthyFactor = DiseaseEffects.TalkWeightFactor(healthy, catalog, Now); + Assert.Equal(1f, healthyFactor); + Assert.True(sickFactor < 0.3f); + Assert.True(sickFactor < healthyFactor); + } + + private static void Onset(Person person, string defName, float severity) => + HealthConditions.Add( + person, + new HealthCondition + { + DefName = defName, + Severity = severity, + Progress = 0.1f, + Source = "test", + StartedAt = Now.AddDays(-3), + }); + + private static Person Blank(string id) + { + var cases = new CaseTable + { + Nom = id, + Gen = id, + Dat = id, + Acc = id, + Ins = id, + Pre = id, + }; + return new Person + { + Id = id, + FamilyId = "f", + Female = false, + BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Name = new PersonName(id, id, id, cases, cases, cases), + IsStudent = true, + IsStaff = false, + IsParent = false, + Numbers = new Dictionary(StringComparer.Ordinal), + Choices = new Dictionary(StringComparer.Ordinal), + Skills = new Dictionary(StringComparer.Ordinal), + Traits = [], + Needs = new Dictionary(StringComparer.Ordinal), + Opinions = new Dictionary(StringComparer.Ordinal), + }; + } + + private static DefCatalog LoadVanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var documents = PackDocuments.FromDirectory(CatalogLoader.CorePackId, root).ToList(); + return new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + } +} diff --git a/tests/HSchool.Server.Tests/PersonCardReaderHealthTests.cs b/tests/HSchool.Server.Tests/PersonCardReaderHealthTests.cs new file mode 100644 index 0000000..20f957a --- /dev/null +++ b/tests/HSchool.Server.Tests/PersonCardReaderHealthTests.cs @@ -0,0 +1,99 @@ +using HSchool.Content; +using HSchool.People; +using HSchool.Server.Api; +using HSchool.Server.Game; +using HSchool.Simulation; + +namespace HSchool.Server.Tests; + +public class PersonCardReaderHealthTests +{ + private static readonly DateTime Start = new(2012, 4, 3, 9, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Card_ShowsActiveHealthConditions() + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 80, "Russia", Start); + var pupil = roster.People.First(person => person.IsStudent && !person.IsParent); + HealthConditions.Add( + pupil, + new HealthCondition + { + DefName = "Influenza", + Severity = 0.5f, + Progress = 0.2f, + Source = "test", + StartedAt = Start.AddDays(-3), + }); + + var school = School.Create(80, "HealthCard", Start, catalog, map); + using (school) + { + school.InstallPeople(roster, 80, "Russia", ApplicantPool.Empty); + var card = PersonCardReader.Read(school, pupil.Id, "ru"); + Assert.NotNull(card); + Assert.NotNull(card!.HealthConditions); + var row = Assert.Single(card.HealthConditions!); + Assert.Equal("Influenza", row.DefName); + Assert.Equal("Грипп", row.Label); + Assert.Equal(0.5f, row.Severity); + Assert.Equal(0.2f, row.Progress); + Assert.Equal("Высокая температура, мало сил на разговоры", row.Effect); + } + } + + [Fact] + public void HealthyCard_HasEmptyHealthConditions() + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 80, "Russia", Start); + var pupil = roster.People.First(person => + person.IsStudent && (person.Conditions is null || person.Conditions.Count == 0)); + var school = School.Create(81, "HealthEmpty", Start, catalog, map); + using (school) + { + school.InstallPeople(roster, 81, "Russia", ApplicantPool.Empty); + var card = PersonCardReader.Read(school, pupil.Id, "en"); + Assert.NotNull(card); + Assert.NotNull(card!.HealthConditions); + Assert.Empty(card.HealthConditions!); + } + } + + [Fact] + public void WorkerCommands_HaveNoHealOrTendIntent() + { + var names = typeof(WorkerCommand).GetNestedTypes() + .Select(type => type.Name) + .ToArray(); + Assert.DoesNotContain(names, name => name.Contains("Heal", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(names, name => name.Contains("Tend", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(names, name => name.Contains("Cure", StringComparison.OrdinalIgnoreCase)); + } + + private static (DefCatalog Catalog, MapLayout Map) Vanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + Assert.True(Directory.Exists(root), $"Vanilla pack missing at {root}."); + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + documents.Add(new ContentDocument( + CatalogLoader.CorePackId, + Path.GetRelativePath(root, path).Replace('\\', '/'), + File.ReadAllText(path))); + } + + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } +} diff --git a/tests/HSchool.Simulation.Tests/NurseCareSimulationTests.cs b/tests/HSchool.Simulation.Tests/NurseCareSimulationTests.cs new file mode 100644 index 0000000..fc3f21f --- /dev/null +++ b/tests/HSchool.Simulation.Tests/NurseCareSimulationTests.cs @@ -0,0 +1,207 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Simulation.Tests; + +public class NurseCareSimulationTests +{ + private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 8, 0, 0, DateTimeKind.Utc); + + [Fact] + public void NurseInOffice_SeverityFallsFasterThanUntendedControl() + { + using var tended = OpenWithNurse(); + using var control = OpenWithNurse(); + var tendedPupil = MakeSick(tended, severity: 0.6f); + var controlPupil = MakeSick(control, severity: 0.6f); + SetMedicine(tended, 80); + SetMedicine(control, 80); + + PlaceAt(tended, tendedPupil.Id, "medical-office"); + PlaceAt(control, controlPupil.Id, "classroom-101"); + PlaceNurseAt(tended, "medical-office"); + PlaceNurseAt(control, "classroom-101"); + + // Enqueue + admit so tend applies to the co-located patient. + NurseCareSystem.Apply(tended, gameMinutes: 0); + Assert.True(NurseCareSystem.IsVisiting(tended, tendedPupil.Id)); + NurseCareSystem.Apply(tended, gameMinutes: 24 * 60); + + Assert.True(HealthConditions.Tick( + controlPupil, + control.PeopleSeed, + DateOnly.FromDateTime(control.Clock.Time).DayNumber, + gameMinutes: 24 * 60, + control.Catalog, + control.Clock.Time)); + + Assert.True(tendedPupil.Conditions![0].Severity < controlPupil.Conditions![0].Severity); + Assert.True(tendedPupil.Conditions[0].Progress > controlPupil.Conditions[0].Progress); + } + + [Fact] + public void SkipEmpty_ClearsNurseVisitQueue() + { + using var school = OpenWithNurse(); + var pupil = MakeSick(school, severity: 0.7f); + NurseCareSystem.Apply(school, gameMinutes: 0); + Assert.True(NurseCareSystem.IsVisiting(school, pupil.Id)); + Assert.NotEmpty(school.NurseVisits); + + ClearCampus(school); + school.Clock.JumpTo(new DateTime(2012, 4, 3, 20, 0, 0, DateTimeKind.Utc)); + var result = school.TrySkipEmpty(); + Assert.Equal(SkipEmptyError.None, result.Error); + Assert.Empty(school.NurseVisits); + Assert.False(NurseCareSystem.IsVisiting(school, pupil.Id)); + } + + [Fact] + public void WithoutNurse_DoesNotEnqueue() + { + using var school = OpenStaffedNoNurse(); + var pupil = MakeSick(school, severity: 0.7f); + NurseCareSystem.Apply(school, gameMinutes: 0); + Assert.False(NurseCareSystem.IsVisiting(school, pupil.Id)); + Assert.Empty(school.NurseVisits); + } + + private static Person MakeSick(School school, float severity) + { + var pupil = school.Roster!.People.First(person => person.IsStudent && !person.IsParent); + HealthConditions.Add( + pupil, + new HealthCondition + { + DefName = "CommonCold", + Severity = severity, + Progress = 0.1f, + Source = "test", + StartedAt = TuesdayMorning.AddDays(-2), + }); + return pupil; + } + + private static void SetMedicine(School school, int medicine) + { + var nurse = school.Roster!.People.First(person => + person.IsStaff && Staffing.NursePosition.Equals(person.Position, StringComparison.Ordinal)); + ((Dictionary)nurse.Skills)[NurseCare.MedicineSkill] = medicine; + var query = new QueryDescription().WithAll(); + school.World.Query(in query, (ref PersonIdentity identity, ref PersonSkills skills) => + { + if (identity.Id.Equals(nurse.Id, StringComparison.Ordinal)) + { + skills.Values[NurseCare.MedicineSkill] = medicine; + } + }); + } + + private static void PlaceNurseAt(School school, string nodeId) + { + var nurseId = NurseCare.NurseId(school.Roster!)!; + PlaceAt(school, nurseId, nodeId); + } + + private static void PlaceAt(School school, string personId, string? nodeId) + { + TalkCircleSystem.Interrupt(school, personId); + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) => + { + if (!identity.Id.Equals(personId, StringComparison.Ordinal)) + { + return; + } + + presence = nodeId is null ? Presence.OffCampus : new Presence(nodeId, 0f, nodeId, false, []); + activity = PersonActivity.Idle; + intent = Intent.None; + }); + } + + private static void ClearCampus(School school) + { + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity _, ref Presence presence, ref PersonActivity activity, ref Intent intent) => + { + presence = Presence.OffCampus; + activity = PersonActivity.Idle; + intent = Intent.None; + }); + } + + private static School OpenWithNurse() + { + var school = OpenStaffedNoNurse(); + HireNurse(school); + return school; + } + + private static void HireNurse(School school) + { + const float cap = 100_000f; + var pool = school.Applicants!; + var applicantId = pool.Applicants[0].Person.Id; + var hired = Staffing.Hire( + school.Catalog!, + school.Map!, + school.Roster!, + pool, + applicantId, + Staffing.NursePosition, + cap); + Assert.Equal(StaffingError.None, hired.Error); + school.ApplyStaffing(hired.Roster, hired.Pool); + Assert.NotNull(NurseCare.NurseId(school.Roster!)); + } + + private static School OpenStaffedNoNurse() + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 80, "Russia", TuesdayMorning); + var pool = ApplicantPool.Create(catalog, roster, 80, "Russia", TuesdayMorning); + var school = School.Create(80, "NurseCare", TuesdayMorning, catalog, map); + school.InstallPeople(roster, 80, "Russia", pool); + var schoolClass = roster.Classes.First(row => row.RoomId == "classroom-101"); + school.SetTimetable(new Timetable( + [ + new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1), + ], + [])); + school.Clock.JumpTo(TuesdayMorning); + return school; + } + + private static (DefCatalog Catalog, MapLayout Map) Vanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + Assert.True(Directory.Exists(root), $"Vanilla pack missing at {root}."); + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + documents.Add(new ContentDocument( + CatalogLoader.CorePackId, + Path.GetRelativePath(root, path).Replace('\\', '/'), + File.ReadAllText(path))); + } + + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } +}