Add nurse care queue and person-card health tab.

Sick people seek MedicalOffice for tend by Medicine skill; symptoms cut talk weight; no heal API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 16:17:51 +03:00
co-authored by Cursor
parent afab014572
commit 33f253c9e3
33 changed files with 1568 additions and 34 deletions
+12 -12
View File
@@ -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 ночи не оставляет вечную очередь в медкабинете без разрешения
## Критерий готовности
+17 -1
View File
@@ -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` (25),
`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`
(01), 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": [
+14 -2
View File
@@ -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)
+122
View File
@@ -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;
}
}
+12
View File
@@ -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}',
+11
View File
@@ -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
View File
@@ -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 }),
);
}
+1
View File
@@ -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 01.");
}
}
}
}
+23
View File
@@ -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; }
}
@@ -763,6 +763,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)
+6
View File
@@ -666,6 +666,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,
+173
View File
@@ -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;
}
}
}
+1
View File
@@ -27,6 +27,7 @@ public static class Seed
public const int MeetingAttendSalt = 19;
public const int DiseaseSalt = 20;
public const int ContagionSalt = 21;
public const int NurseSalt = 22;
/// <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);
+2
View File
@@ -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);
+11 -1
View File
@@ -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,
+70 -1
View File
@@ -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",
@@ -125,4 +125,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" },
],
},
]
@@ -240,9 +240,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",
}
@@ -240,9 +240,23 @@
"GoingToPrincipal": "идёт к директору",
"WaitForPrincipal": "ждёт у кабинета директора",
"GoingToParentMeeting": "идёт на собрание",
"GoingToNurse": "идёт к медсестре",
"WaitForNurse": "ждёт у медкабинета",
"NurseCare": "на приёме у медсестры",
"CommonCold": "ОРВИ",
"Influenza": "Грипп",
"StomachBug": "Кишечная инфекция",
"Otitis": "Отит",
"ColdEffectMild": "Лёгкий насморк, учится чуть хуже",
"ColdEffectModerate": "Слабость, реже болтает",
"ColdEffectHeavy": "Сильный дискомфорт, почти не общается",
"FluEffectMild": "Ломота, сниженное внимание",
"FluEffectModerate": "Высокая температура, мало сил на разговоры",
"FluEffectHeavy": "Тяжёлая слабость, почти не учится",
"StomachEffectMild": "Тошнота, избегает еды и болтовни",
"StomachEffectHeavy": "Сильный дискомфорт, лучше лежать",
"OtitisEffectMild": "Боль в ухе, хуже слышит",
"OtitisEffectModerate": "Боль мешает уроку и разговору",
"OtitisEffectHeavy": "Сильная боль, почти не общается",
"core": "Базовая игра",
}
+4
View File
@@ -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);
+373
View File
@@ -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;
}
+37 -5
View File
@@ -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);
}
+16
View File
@@ -156,6 +156,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>
@@ -326,6 +333,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);
@@ -401,6 +409,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);
}
@@ -459,6 +468,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>
@@ -506,6 +521,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);
}
}