Merge branch 'phase/80-nurse-health-ui'
This commit is contained in:
@@ -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 ночи не оставляет вечную очередь в медкабинете без разрешения
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+17
-1
@@ -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": [
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
|
||||
namespace HSchool.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class NurseCare
|
||||
{
|
||||
/// <summary>Presence / card label while walking to the wait node or office.</summary>
|
||||
public const string GoingAction = "GoingToNurse";
|
||||
|
||||
/// <summary>Presence / card label while queued in the corridor.</summary>
|
||||
public const string WaitingAction = "WaitForNurse";
|
||||
|
||||
/// <summary>Presence / card label while being tended in the office.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Corridor (or other neighbour) where the queue waits — never inside the office.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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}',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('Слабость, реже болтает');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
@@ -94,6 +94,7 @@ const CARD_TABS: readonly PersonCardTab[] = [
|
||||
'now',
|
||||
'connections',
|
||||
'gradebook',
|
||||
'health',
|
||||
'portrait',
|
||||
];
|
||||
const LOG_DIRS: readonly PersonLogDir[] = ['asc', 'desc'];
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ public sealed class DiseaseStage
|
||||
|
||||
/// <summary>Multiplies Warmth decay. 1 = unchanged.</summary>
|
||||
public float WarmthDecayFactor { get; init; } = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Multiplies Chat / talk leisure weight and talk-circle start pull. 1 = unchanged; lower when
|
||||
/// symptoms make conversation harder.
|
||||
/// </summary>
|
||||
public float TalkWeightFactor { get; init; } = 1f;
|
||||
|
||||
/// <summary>Locale key for the person-card effect line. Empty — card shows severity only.</summary>
|
||||
public string Effect { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -72,4 +81,18 @@ public sealed class DiseaseDef : Def
|
||||
|
||||
/// <summary>When true, the carrier can infect during incubation before stage effects apply.</summary>
|
||||
public bool ContagiousDuringIncubation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// After incubation, severity at or above this sends the person toward the nurse's office.
|
||||
/// </summary>
|
||||
public float NurseSeekSeverity { get; init; } = 0.35f;
|
||||
|
||||
/// <summary>
|
||||
/// Extra severity change per day while a nurse tends (usually negative). 0 — care only
|
||||
/// advances progress.
|
||||
/// </summary>
|
||||
public float TendSeverityPerDay { get; init; }
|
||||
|
||||
/// <summary>Extra progress per day while a nurse tends (speeds recovery / immunity).</summary>
|
||||
public float TendProgressPerDay { get; init; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -694,6 +694,12 @@ public sealed class BehaviorDef : Def
|
||||
/// </summary>
|
||||
public int DiseaseOutbreakMinNewCases { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Scales nurse tend deltas by Medicine skill: factor = this * (0.5 + Medicine/100).
|
||||
/// Missing keeps 1.
|
||||
/// </summary>
|
||||
public float NurseTendMedicineScale { get; init; } = 1f;
|
||||
|
||||
public static IReadOnlyList<float> DefaultLessonMarkThresholds { get; } =
|
||||
[
|
||||
0.85f,
|
||||
|
||||
@@ -160,4 +160,177 @@ public static class DiseaseEffects
|
||||
|
||||
return factor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product of incubated stage talk factors (1 when healthy). Strong symptoms shrink Chat weight.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Highest incubated severity that meets its DiseaseDef nurse-seek threshold, or null when none.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Medicine-scaled tend for one game-minute slice. Same peopleSeed + person + day → same curve.
|
||||
/// </summary>
|
||||
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<HealthCondition>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
||||
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -90,7 +90,8 @@ internal sealed record PersonCardResponse(
|
||||
string? ClassTeacherName = null,
|
||||
IReadOnlyList<OffenseEntryResponse>? Offenses = null,
|
||||
IReadOnlyList<LessonMarkEntryResponse>? LessonMarks = null,
|
||||
IReadOnlyList<AttendanceEntryResponse>? Attendance = null);
|
||||
IReadOnlyList<AttendanceEntryResponse>? Attendance = null,
|
||||
IReadOnlyList<HealthConditionEntryResponse>? HealthConditions = null);
|
||||
|
||||
internal sealed record OffenseEntryResponse(
|
||||
string Kind,
|
||||
@@ -99,6 +100,15 @@ internal sealed record OffenseEntryResponse(
|
||||
string? OtherPersonId,
|
||||
string? OtherFullName);
|
||||
|
||||
/// <summary>One active medical condition on the person card (slice 14 phase 80).</summary>
|
||||
internal sealed record HealthConditionEntryResponse(
|
||||
string DefName,
|
||||
string Label,
|
||||
float Severity,
|
||||
float Progress,
|
||||
string? StageLabel,
|
||||
string? Effect);
|
||||
|
||||
/// <summary>One recent lesson mark on the person card. Same order as the world list.</summary>
|
||||
internal sealed record LessonMarkEntryResponse(
|
||||
string Subject,
|
||||
|
||||
@@ -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<HealthConditionEntryResponse> 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<LessonMarkEntryResponse> LessonMarks(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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": "Базовая игра",
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Ai;
|
||||
using HSchool.People;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Sick people seek the medical office; FIFO corridor wait; nurse tend while co-located.
|
||||
/// Only the school worker thread mutates <see cref="School.NurseVisits"/>.
|
||||
/// </summary>
|
||||
internal static class NurseCareSystem
|
||||
{
|
||||
private static readonly QueryDescription PresenceQuery =
|
||||
new QueryDescription().WithAll<PersonIdentity, Presence, PersonActivity, Intent>();
|
||||
|
||||
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<PersonIdentity, PersonSkills>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One nurse-visit ticket. <see cref="Order"/> is FIFO; lower goes first.</summary>
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -158,6 +158,13 @@ public sealed class School : IDisposable
|
||||
|
||||
internal int NextSummonOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Nurse visit FIFO. Worker thread only — never HTTP. Cleared on morning / skip.
|
||||
/// </summary>
|
||||
internal List<NurseVisitTicket> NurseVisits { get; } = [];
|
||||
|
||||
internal int NextNurseVisitOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Active parent meetings. Worker thread only — never HTTP. Cleared on morning / skip.
|
||||
/// </summary>
|
||||
@@ -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,6 +431,7 @@ public sealed class School : IDisposable
|
||||
ResetDayLog();
|
||||
// Hung summons do not survive the work morning — same clear as skip empty.
|
||||
DirectorSummonSystem.ClearAll(this);
|
||||
NurseCareSystem.ClearAll(this);
|
||||
ParentMeetingSystem.ClearAll(this);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
/// <summary>
|
||||
/// Card label while walking to / sitting in a parent meeting without a live activity yet.
|
||||
/// </summary>
|
||||
@@ -528,6 +543,7 @@ public sealed class School : IDisposable
|
||||
}
|
||||
|
||||
DirectorSummonSystem.Apply(this);
|
||||
NurseCareSystem.Apply(this, gameMinutes);
|
||||
ParentMeetingSystem.Apply(this);
|
||||
|
||||
PersonDayLog.Sync(this);
|
||||
|
||||
@@ -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) =>
|
||||
{
|
||||
|
||||
@@ -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<string, float>(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,
|
||||
|
||||
@@ -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<string, int>(StringComparer.Ordinal),
|
||||
Choices = new Dictionary<string, string>(StringComparer.Ordinal),
|
||||
Skills = new Dictionary<string, int>(StringComparer.Ordinal),
|
||||
Traits = [],
|
||||
Needs = new Dictionary<string, float>(StringComparer.Ordinal),
|
||||
Opinions = new Dictionary<string, int>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<ContentDocument>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, int>)nurse.Skills)[NurseCare.MedicineSkill] = medicine;
|
||||
var query = new QueryDescription().WithAll<PersonIdentity, PersonSkills>();
|
||||
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<PersonIdentity, Presence, PersonActivity, Intent>();
|
||||
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<PersonIdentity, Presence, PersonActivity, Intent>();
|
||||
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<ContentDocument>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user