Merge branch 'phase/80-nurse-health-ui'

This commit is contained in:
Leonid Pershin
2026-08-21 20:29:12 +03:00
34 changed files with 1569 additions and 35 deletions
+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; }
}
@@ -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)
+6
View File
@@ -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,
+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
@@ -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);
+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",
@@ -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": "Базовая игра",
}
+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);
}
+17 -1
View File
@@ -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,7 +431,8 @@ public sealed class School : IDisposable
ResetDayLog();
// Hung summons do not survive the work morning — same clear as skip empty.
DirectorSummonSystem.ClearAll(this);
ParentMeetingSystem.ClearAll(this);
NurseCareSystem.ClearAll(this);
ParentMeetingSystem.ClearAll(this);
}
peopleChanged = TryYearlyIntake(before, Clock.Time);
@@ -481,6 +490,12 @@ public sealed class School : IDisposable
public string? DirectorSummonPresenceActionId(string personId) =>
DirectorSummonSystem.PresenceActionId(DirectorSummonPresencePhase(personId));
public byte NurseCarePresencePhase(string personId) =>
NurseCareSystem.ResolvePresencePhase(this, personId);
public string? NurseCarePresenceActionId(string personId) =>
NurseCareSystem.PresenceActionId(NurseCarePresencePhase(personId));
/// <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) =>
{