From 79d5fa206f78d80709d66fe278c740dd826484cf Mon Sep 17 00:00:00 2001 From: ClFeSc <68013019+ClFeSc@users.noreply.github.com> Date: Mon, 23 Jan 2023 10:53:31 +0100 Subject: [PATCH] Introduce state-based patient model * Continuation of https://github.com/hpi-sam/digital-fuesim-manv/pull/490 Co-authored-by: Florian --- backend/src/exercise/patient-ticking.ts | 162 +- .../core/statistics/statistics.service.ts | 19 +- .../patient-feature-manager.ts | 7 +- .../patient-popup.component.html | 126 +- .../patient-popup/patient-popup.component.ts | 25 +- .../exercise-settings-modal.component.html | 15 + .../exercise-settings-modal.component.ts | 7 + .../hospital-patients-table.component.ts | 7 +- .../trainer-map-editor.component.html | 12 + .../trainer-map-editor.component.ts | 35 +- .../utility/create-patient-categories.ts | 17 + .../exercise/shared/utility/parse-csv.ts | 311 ++++ .../data/default-state/patient-templates.ts | 1595 +---------------- shared/src/data/dummy-objects/patient.ts | 30 +- shared/src/models/exercise-configuration.ts | 6 +- shared/src/models/hospital-patient.ts | 48 +- shared/src/models/patient-health-state.ts | 140 +- shared/src/models/patient-template.ts | 109 +- shared/src/models/patient.ts | 78 +- shared/src/models/utils/catering.ts | 6 + shared/src/models/utils/health-points.ts | 103 -- shared/src/models/utils/index.ts | 3 +- .../src/models/utils/pretriage-information.ts | 169 +- .../store/action-reducers/action-reducers.ts | 2 + .../store/action-reducers/configuration.ts | 21 +- shared/src/store/action-reducers/exercise.ts | 22 +- .../action-reducers/patient-categories.ts | 30 + shared/src/store/action-reducers/patient.ts | 42 +- .../action-reducers/utils/patient-updates.ts | 41 +- shared/src/utils/validators/index.ts | 3 +- shared/src/utils/validators/is-id-map.ts | 85 +- shared/src/utils/validators/is-map.ts | 55 + 32 files changed, 1298 insertions(+), 2033 deletions(-) create mode 100644 frontend/src/app/pages/exercises/exercise/shared/utility/create-patient-categories.ts create mode 100644 frontend/src/app/pages/exercises/exercise/shared/utility/parse-csv.ts create mode 100644 shared/src/models/utils/catering.ts delete mode 100644 shared/src/models/utils/health-points.ts create mode 100644 shared/src/store/action-reducers/patient-categories.ts create mode 100644 shared/src/utils/validators/is-map.ts diff --git a/backend/src/exercise/patient-ticking.ts b/backend/src/exercise/patient-ticking.ts index 1f6039936..f3c3eb933 100644 --- a/backend/src/exercise/patient-ticking.ts +++ b/backend/src/exercise/patient-ticking.ts @@ -1,20 +1,10 @@ import type { + Catering, ExerciseState, - HealthPoints, PatientUpdate, - PersonnelType, -} from 'digital-fuesim-manv-shared'; -import { - getElement, - healthPointsDefaults, - isAlive, - Patient, } from 'digital-fuesim-manv-shared'; - -/** - * The count of assigned personnel and material that cater for a {@link Patient}. - */ -type Catering = { [key in PersonnelType | 'material']: number }; +import { getElement, Patient } from 'digital-fuesim-manv-shared'; +import { cloneDeep } from 'lodash-es'; /** * Apply the patient tick to the {@link state} @@ -29,29 +19,37 @@ export function patientTick( return ( Object.values(state.patients) // Only look at patients that are alive and have a position, i.e. are not in a vehicle - .filter((patient) => isAlive(patient.health) && patient.position) + .filter( + (patient) => + Patient.getVisibleStatus( + patient, + state.configuration.pretriageEnabled, + state.configuration.bluePatientsEnabled + ) !== 'black' && !Patient.isInVehicle(patient) + ) .map((patient) => { // update the time a patient is being treated, to check for pretriage later const treatmentTime = Patient.isTreatedByPersonnel(patient) ? patient.treatmentTime + patientTickInterval : patient.treatmentTime; - const nextHealthPoints = getNextPatientHealthPoints( + const newTreatment = getDedicatedResources(state, patient); + const nextStateName = getNextStateName( patient, - getDedicatedResources(state, patient), - patientTickInterval + getAverageTreatment(patient.treatmentHistory, newTreatment) ); - const nextStateId = getNextStateId(patient); const nextStateTime = - nextStateId === patient.currentHealthStateId + nextStateName === patient.currentHealthStateName ? patient.stateTime + - patientTickInterval * patient.timeSpeed + patientTickInterval * + patient.changeSpeed * + state.configuration.globalPatientChangeSpeed : 0; return { id: patient.id, - nextHealthPoints, - nextStateId, + nextStateName, nextStateTime, treatmentTime, + newTreatment, }; }) ); @@ -87,84 +85,76 @@ function getDedicatedResources( return cateringTypes; } -/** - * Calculate the next {@link HealthPoints} for the {@link patient}. - * @param patient The {@link Patient} to calculate the {@link HealthPoints} for. - * @param treatedBy The count of personnel/material catering for the {@link patient}. - * @param patientTickInterval The time in ms between calls to this function. - * @returns The next {@link HealthPoints} for the {@link patient} - */ -// This is a heuristic and doesn't have to be 100% correct - the players don't see the healthPoints but only the color -// This function could be as complex as we want it to be (Math.sin to get something periodic, higher polynoms...) -function getNextPatientHealthPoints( - patient: Patient, - treatedBy: Catering, - patientTickInterval: number -): HealthPoints { - let material = treatedBy.material; - const notarzt = treatedBy.notarzt; - const notSan = treatedBy.notSan; - const rettSan = treatedBy.rettSan; - // TODO: Sans should be able to treat patients too. - const functionParameters = - patient.healthStates[patient.currentHealthStateId]!.functionParameters; - // To do anything the personnel needs material - // TODO: But a personnel should probably be able to treat a patient a bit without material - e.g. free airways, just press something on a strongly bleeding wound, etc. - // -> find a better heuristic - let equippedNotarzt = Math.min(notarzt, material); - material = Math.max(material - equippedNotarzt, 0); - let equippedNotSan = Math.min(notSan, material); - material = Math.max(material - equippedNotSan, 0); - let equippedRettSan = Math.min(rettSan, material); - // much more notarzt != much better patient - equippedNotarzt = Math.log2(equippedNotarzt + 1); - equippedNotSan = Math.log2(equippedNotSan + 1); - equippedRettSan = Math.log2(equippedRettSan + 1); - // TODO: some more heuristic precalculations ... - // e.g. each second we lose 100 health points - const changedHealthPerSecond = - functionParameters.constantChange + - // e.g. if we have a notarzt we gain 500 additional health points per second - functionParameters.notarztModifier * equippedNotarzt + - functionParameters.notSanModifier * equippedNotSan + - functionParameters.rettSanModifier * equippedRettSan; - - return Math.max( - healthPointsDefaults.min, - Math.min( - healthPointsDefaults.max, - // our current health points - patient.health + - (changedHealthPerSecond / 1000) * - patientTickInterval * - patient.timeSpeed - ) - ); -} - /** * Find the next {@link PatientHealthState} id for the {@link patient} by using the {@link ConditionParameters}. * @param patient The {@link Patient} to get the next {@link PatientHealthState} id for. * @returns The next {@link PatientHealthState} id. */ -function getNextStateId(patient: Patient) { - const currentState = patient.healthStates[patient.currentHealthStateId]!; +function getNextStateName(patient: Patient, dedicatedResources: Catering) { + const currentState = patient.healthStates[patient.currentHealthStateName]!; for (const nextConditions of currentState.nextStateConditions) { if ( (nextConditions.earliestTime === undefined || patient.stateTime > nextConditions.earliestTime) && (nextConditions.latestTime === undefined || patient.stateTime < nextConditions.latestTime) && - (nextConditions.minimumHealth === undefined || - patient.health > nextConditions.minimumHealth) && - (nextConditions.maximumHealth === undefined || - patient.health < nextConditions.maximumHealth) && (nextConditions.isBeingTreated === undefined || Patient.isTreatedByPersonnel(patient) === - nextConditions.isBeingTreated) + nextConditions.isBeingTreated) && + (nextConditions.requiredMaterialAmount === undefined || + dedicatedResources.material >= + nextConditions.requiredMaterialAmount) && + (nextConditions.requiredNotArztAmount === undefined || + dedicatedResources.notarzt >= + nextConditions.requiredNotArztAmount) && + (nextConditions.requiredNotSanAmount === undefined || + dedicatedResources.notSan + dedicatedResources.notarzt >= + nextConditions.requiredNotSanAmount) && + (nextConditions.requiredRettSanAmount === undefined || + dedicatedResources.rettSan + + dedicatedResources.notSan + + dedicatedResources.notarzt >= + nextConditions.requiredRettSanAmount) && + (nextConditions.requiredSanAmount === undefined || + dedicatedResources.san + + dedicatedResources.rettSan + + dedicatedResources.notSan + + dedicatedResources.notarzt >= + nextConditions.requiredSanAmount) ) { - return nextConditions.matchingHealthStateId; + return nextConditions.matchingHealthStateName; } } - return patient.currentHealthStateId; + return patient.currentHealthStateName; +} + +/** + * Get the average treatment for roughly the last minute, scaled to 100% from {@link requiredPercentage} + */ +function getAverageTreatment( + treatmentHistory: readonly Catering[], + newTreatment: Catering, + requiredPercentage: number = 0.8 +) { + const averageCatering: Catering = cloneDeep(newTreatment); + treatmentHistory.forEach((catering, index) => { + if (index === 0) { + return; + } + averageCatering.gf += catering.gf; + averageCatering.material += catering.material; + averageCatering.notarzt += catering.notarzt; + averageCatering.notSan += catering.notSan; + averageCatering.rettSan += catering.rettSan; + averageCatering.san += catering.san; + }); + const modifier = requiredPercentage * treatmentHistory.length; + averageCatering.gf = averageCatering.gf / modifier; + averageCatering.material = averageCatering.material / modifier; + averageCatering.notarzt = averageCatering.notarzt / modifier; + averageCatering.notSan = averageCatering.notSan / modifier; + averageCatering.rettSan = averageCatering.rettSan / modifier; + averageCatering.san = averageCatering.san / modifier; + + return averageCatering; } diff --git a/frontend/src/app/pages/exercises/exercise/shared/core/statistics/statistics.service.ts b/frontend/src/app/pages/exercises/exercise/shared/core/statistics/statistics.service.ts index 530c82ddb..8fe4c8bd0 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/core/statistics/statistics.service.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/core/statistics/statistics.service.ts @@ -1,16 +1,16 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import type { - Client, ExerciseState, - Patient, + Client, Vehicle, } from 'digital-fuesim-manv-shared'; import { loopTroughTime, - Personnel, - uuid, Viewport, + uuid, + Patient, + Personnel, } from 'digital-fuesim-manv-shared'; import { countBy } from 'lodash-es'; import { ReplaySubject } from 'rxjs'; @@ -93,6 +93,7 @@ export class StatisticsService { draftState: ExerciseState ): StatisticsEntry { const exerciseStatistics = this.generateAreaStatistics( + draftState, Object.values(draftState.clients), Object.values(draftState.patients), Object.values(draftState.vehicles), @@ -103,6 +104,7 @@ export class StatisticsService { Object.entries(draftState.viewports).map(([id, viewport]) => [ id, this.generateAreaStatistics( + draftState, Object.values(draftState.clients).filter( (client) => client.viewRestrictedToViewportId === id ), @@ -133,6 +135,7 @@ export class StatisticsService { } private generateAreaStatistics( + state: ExerciseState, clients: Client[], patients: Patient[], vehicles: Vehicle[], @@ -143,7 +146,13 @@ export class StatisticsService { (client) => !client.isInWaitingRoom && client.role === 'participant' ).length, - patients: countBy(patients, (patient) => patient.realStatus), + patients: countBy(patients, (patient) => + Patient.getVisibleStatus( + patient, + state.configuration.pretriageEnabled, + state.configuration.bluePatientsEnabled + ) + ), vehicles: countBy(vehicles, (vehicle) => vehicle.vehicleType), personnel: countBy( personnel.filter( diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-map/feature-managers/patient-feature-manager.ts b/frontend/src/app/pages/exercises/exercise/shared/exercise-map/feature-managers/patient-feature-manager.ts index e2ff69fee..4d8915c29 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/exercise-map/feature-managers/patient-feature-manager.ts +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-map/feature-managers/patient-feature-manager.ts @@ -28,7 +28,7 @@ export class PatientFeatureManager extends ElementFeatureManager< const patient = this.getElementFromFeature(feature)!.value; return { ...patient.image, - rotation: patient.pretriageInformation.isWalkable + rotation: Patient.getPretriageInformation(patient).isWalkable ? undefined : (3 * Math.PI) / 2, }; @@ -59,8 +59,9 @@ export class PatientFeatureManager extends ElementFeatureManager< }, 0.025, (feature) => - this.getElementFromFeature(feature)!.value.pretriageInformation - .isWalkable + Patient.getPretriageInformation( + this.getElementFromFeature(feature)!.value + ).isWalkable ? [0, 0.25] : [-0.25, 0] ); diff --git a/frontend/src/app/pages/exercises/exercise/shared/exercise-map/shared/patient-popup/patient-popup.component.html b/frontend/src/app/pages/exercises/exercise/shared/exercise-map/shared/patient-popup/patient-popup.component.html index 14b7ebed9..7bb79370d 100644 --- a/frontend/src/app/pages/exercises/exercise/shared/exercise-map/shared/patient-popup/patient-popup.component.html +++ b/frontend/src/app/pages/exercises/exercise/shared/exercise-map/shared/patient-popup/patient-popup.component.html @@ -5,7 +5,7 @@
*ngIf=" (currentRole$ | async) === 'participant' ? (visibleStatus$ | async) - : patient.realStatus as displayedStatus + : patient.healthStates[patient.currentHealthStateName]!.status as displayedStatus " [ngStyle]="{ 'background-color': displayedStatus }" [class.text-dark]=" @@ -13,11 +13,7 @@
" class="badge rounded-pill font-monospace" > - {{ - (currentRole$ | async) !== 'participant' - ? (patient.health / healthPointsDefaults.max | percent) - : statusNames[displayedStatus] - }} + {{ statusNames[displayedStatus] }}