{
<!-- Student review, rounds 1 and 2: "the Case 1 exam is clunkier than Case 3". It was
three SEPARATE passages - Abdomen, Asterixis, Pupils - each navigating away,
applying its own cost, and then re-rendering its OWN private copy of the remaining
menu. Three copies of one menu, three different headings, and each screen showed a
single finding with the previous ones gone from view.
Now one screen that accumulates findings, like Case3Exam. THE MECHANIC IS UNCHANGED
at author direction: 5 stability per maneuver, a budget of two, asterixis still pays
+5 reputation as the highest-yield exam, and the transcript strings are
byte-identical to the ones the three passages wrote - those are in the collected
Form data and must not drift.
WHY EACH EXAM STILL TAKES A TURN, i.e. (goto: "Bedside") rather than a pure in-place
(replace:). Case3Exam can reveal in place because its reveals are free: they set no
variables and cost nothing. These are scored. Harlowe only commits state to its
session store, and only re-renders the header, on a TURN boundary - and a (link:)
that does not navigate is not a turn. Built with in-place reveals and measured, the
cost was invisible in two ways: the header still read the pre-exam stability and
reputation after a maneuver had been paid for, and a reload discarded the exams
entirely, refunding the stability and handing back a fresh budget of two. A
self-goto keeps the turn semantics the scoring depends on. The learner still stays
on one screen; it re-renders under them, and every finding taken so far stays on it.
Everything below renders from $checked* and $exam_count rather than from what was
clicked this turn, so each finding has exactly ONE copy of its text and a reload
rebuilds the screen exactly as it was. -->
<h3 style="margin-bottom: 5px;">Physical Exam</h3><h3><i class="fa-solid fa-user-doctor"></i></h3>
The patient is restless. You estimate you only have time to examine **two** systems before he becomes too agitated to cooperate.
<br><br>
**Exams Performed:** (print: $exam_count) / 2
<br>
<span style="font-size: 0.88em; color: #aaa;">He is agitated and will not tolerate a long examination — every maneuver spends some of the cooperation you have left. Two is what you get, so spend them on the findings that will actually change your management.</span>
(if: $exam_count >= 2)[
<div class="critical-damage">PATIENT AGITATED</div>
He pulls his hands away and tucks them under the sheets. "Enough!" he yells. You have pushed him too far.
]
<hr style="margin: 10px 0;">
<strong>Examine Patient:</strong>
<br>
(if: $checkedPupils is false and $exam_count < 2)[
(link: "Check Pupils")[
(set: $checkedPupils to true)
(set: $stability to (max: 0, $stability - 5))
(set: $exam_count to $exam_count + 1)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Examined Pupils | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(goto: "Bedside")
]
]
(else-if: $checkedPupils is true)[<span style="color: #777; font-size: 0.9em;">✔ Check Pupils — done</span>]
(else:)[<span style="color: #777; font-size: 0.9em;">✕ He turns his head away from the light.</span>]
<br>
(if: $checkedAsterixis is false and $exam_count < 2)[
(link: "Check for Asterixis")[
(set: $checkedAsterixis to true)
(set: $stability to (max: 0, $stability - 5))
(set: $cred to (min: 100, $cred + 5))
(set: $exam_count to $exam_count + 1)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Examined for asterixis (highest yield exam) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(goto: "Bedside")
]
]
(else-if: $checkedAsterixis is true)[<span style="color: #777; font-size: 0.9em;">✔ Check for Asterixis — done</span>]
(else:)[<span style="color: #777; font-size: 0.9em;">✕ He will not hold his hands out for you now.</span>]
<br>
(if: $checkedAbdomen is false and $exam_count < 2)[
(link: "Examine Abdomen")[
(set: $checkedAbdomen to true)
(set: $stability to (max: 0, $stability - 5))
(set: $exam_count to $exam_count + 1)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Examined Abdomen | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(goto: "Bedside")
]
]
(else-if: $checkedAbdomen is true)[<span style="color: #777; font-size: 0.9em;">✔ Examine Abdomen — done</span>]
(else:)[<span style="color: #777; font-size: 0.9em;">✕ He grabs your wrist before you can palpate.</span>]
<br>
(if: $exam_count > 0)[
<hr style="margin: 10px 0;">
<strong>Findings:</strong>
<br>
(if: $checkedPupils is true)[<div style="margin-bottom: 10px;"><strong style="color: #00BFFF">HEENT:</strong> You shine your penlight into his eyes. The pupils are **dilated (6mm)**, briskly reactive to light. Sclera are anicteric.</div>]
(if: $checkedAsterixis is true)[<div style="margin-bottom: 10px;"><strong style="color: #00BFFF">Asterixis:</strong> You ask him to "stop traffic" with his hands. He holds them up and extends his wrists. There is **NO** flapping tremor. Instead, you see a high-frequency, fine tremor in both hands.</div>]
(if: $checkedAbdomen is true)[<div style="margin-bottom: 10px;"><strong style="color: #00BFFF">Abdomen:</strong> Distended with a positive fluid wave (ascites). The spleen tip is palpable (consistent with splenomegaly). There is no tenderness or guarding. This tells you he has decompensated cirrhosis, but it does not differentiate why he is confused right now.</div>]
]
<hr style="margin: 10px 0;">
(if: $exam_count >= 2)[ [[Formulate Diagnosis->Diagnosis]] ]
(else:)[ [[Stop Exam and Formulate Diagnosis->Diagnosis]] ]
}{
(set: $caseName to "Case 1")
(set: _repText to "")
(set: _costText to "")
(set: _clinText to "")
(set: _dispoText to "")
<!-- ROUTE FLAGS (round-3 review, P1/P2). Everything below that describes what happened
reads these, so the status, management, disposition, clinical rating and the HONORS
gate cannot disagree about the route. They are READ FROM THE ROUTE, not inferred from
counters:
- Wrong differentials are COUNTED in (history:). $diagnosisErrors cannot be used: the
Seizure passage sets it to 5, so after a haloperidol seizure it claims five wrong
diagnoses.
- The three ways into Seizure are told apart by the transcript line the Seizure
passage itself writes for each (drug-precipitated vs untreated withdrawal).
- The trauma-bay route is the one branch of Case1Treatment that writes
"Seizure (Delayed Treatment)" - lorazepam after two or more wrong differentials,
which moves him for a phenobarbital load.
Learner-typed reasoning never enters $transcript, so none of these can be triggered by
what a student writes. Story variables, because the display is a separate hook. -->
(set: $c1WrongDx to (count: (history:), "DiagnosisWrong"))
(set: $c1DrugSeizure to $transcript contains "Seizure (Precipitated by incorrect medication")
(set: $c1UntreatedSeizure to $transcript contains "Seizure (Untreated alcohol withdrawal")
(set: $c1TraumaBay to $transcript contains "Seizure (Delayed Treatment)")
(set: $c1Restrained to (history:) contains "Restraints")
(set: $c1IntubatedAgitation to $transcript contains "Intubated for Agitation Control")
<!-- Errors that stand at the end of the case, per the author's list: a drug-induced
seizure, metoprolol, a delayed diagnosis (two or more wrong differentials, or a
seizure from untreated withdrawal), and physical restraint. Used by the Status marker
and by the HONORS gate in GameConclusion. -->
(set: $c1CareErrors to 0)
(if: $c1DrugSeizure)[(set: $c1CareErrors to $c1CareErrors + 1)]
(if: $choice is "BetaBlocker")[(set: $c1CareErrors to $c1CareErrors + 1)]
(if: $c1WrongDx >= 2 or $c1UntreatedSeizure)[(set: $c1CareErrors to $c1CareErrors + 1)]
(if: $c1Restrained)[(set: $c1CareErrors to $c1CareErrors + 1)]
<!-- STATUS sentence: route first, and carries NO severity word - the coloured marker
carries severity, from stability. Earlier this was a stability band, so the
delayed-diagnosis explanation only appeared at exactly 40, and a learner who examined
the patient first (good practice) got a bare "Critical condition" with no reason. -->
(if: $c1DrugSeizure)[
(set: $c1Note to "The course was complicated by a withdrawal seizure following the medication given.")
]
(else-if: $c1UntreatedSeizure)[
(set: $c1Note to "Untreated withdrawal progressed to a seizure while the diagnosis was being established.")
]
(else-if: $c1TraumaBay)[
(set: $c1Note to "Withdrawal progressed to the edge of a seizure while the diagnosis was being established, and he was moved to the trauma bay for a phenobarbital load.")
]
(else-if: $c1IntubatedAgitation)[
(set: $c1Note to "He was paralysed and intubated for agitation rather than treated for withdrawal.")
]
(else-if: $c1WrongDx >= 2)[
(set: $c1Note to "Treatment was delayed while the diagnosis was established.")
]
(else-if: $stability > 70)[
(set: $c1Note to "Autonomic symptoms resolved.")
]
(else:)[
(set: $c1Note to "Withdrawal treatment was delayed.")
]
(if: $c1Restrained)[(set: $c1Note to $c1Note + " Physical restraint was required.")]
<!-- MANAGEMENT: what was actually given. Both seizure outcomes end in IV lorazepam -
given by the learner, or by the attending when reputation is low. -->
(if: $choice is "haloperidol")[
(set: $c1Mgmt to "Haloperidol; the seizure that followed was stopped with IV lorazepam.")
]
(else-if: $choice is "BetaBlocker")[
(set: $c1Mgmt to "Metoprolol; the seizure that followed was stopped with IV lorazepam.")
]
(else-if: $c1UntreatedSeizure)[
(set: $c1Mgmt to "IV lorazepam for a seizure, given before withdrawal was diagnosed.")
]
(else-if: $c1IntubatedAgitation)[
(set: $c1Mgmt to "Paralysis and intubation for agitation, after physical restraint.")
]
(else-if: $c1Restrained)[
(set: $c1Mgmt to "IV lorazepam, given after physical restraint.")
]
(else-if: $c1TraumaBay)[
(set: $c1Mgmt to "IV lorazepam, then a phenobarbital load in the trauma bay.")
]
(else:)[
(set: $c1Mgmt to "IV lorazepam with symptom-triggered (CIWA) dosing.")
]
<!-- DISPOSITION (P3): the patient's bed follows the PATIENT - stability and route - and
never the resident's reputation. The display used "stability < 40 OR reputation <
40" while the transcript used stability alone, so the two could disagree. One rule
now feeds both. The trauma-bay route goes to the ICU because the game moves him
there for phenobarbital. -->
(set: $c1ICU to $stability <= 40 or $c1TraumaBay)
<!-- 9C-2: the raw-cost symbol ladder is retired; Header derives $costDisplay
from _excess and the tile below prints raw dollars. -->
<!-- 9C-2: tier on EXCESS over this case's unavoidable floor, not raw $cost. -->
(set: _excess to $cost - $optimalCost)
(if: _excess < 0)[(set: _excess to 0)]
(set: $costColor to "#00FF00")
(if: _excess > 300)[(set: $costColor to "#FFCC00")]
(if: _excess > 1500)[(set: $costColor to "#FF0000")]
(if: $cred >= 80)[(set: $credColor to "#00FF00")]
(else-if: $cred >= 50)[(set: $credColor to "#FFCC00")]
(else:)[(set: $credColor to "#FF0000")]
(if: $stability >= 80)[(set: $stabilityColor to "#00FF00")]
(else-if: $stability >= 40)[(set: $stabilityColor to "#FFCC00")]
(else:)[(set: $stabilityColor to "#FF0000")]
<!-- "(Attending Intervened)" was asserted from $cred < 40 alone, but the attending
taking over is a specific event with its own passage. A learner who lost
credibility through wrong diagnoses, restraints and a seizure - never meeting
Dr. Sato - was still told she had intervened. (history:) is never reset, so it
is the reliable test. -->
(if: $cred < 40 and (history:) contains "Case1AttendingIntervention")[
(set: _repText to "REPUTATION: Needs More Supervision (Attending Intervened).")
]
(else-if: $cred < 40)[
(set: _repText to "REPUTATION: Needs More Supervision (Credibility Lost).")
]
(else:)[
(set: _repText to "REPUTATION: Trusted.")
]
(if: _excess <= 300)[
(set: _costText to "COST: Efficient (High Value Care).")
]
(else-if: _excess <= 1500)[
(set: _costText to "COST: Acceptable (Some unnecessary testing).")
]
(else:)[
(set: _costText to "COST: Wasteful (Significant unnecessary resource utilization).")
]
<!-- Student review: this chain tested stability first, so the seizure line only
appeared below 40. A seizure costs 40 stability, so a learner who reached it from
100 landed at 60 and was rated "Competent (Delayed/Errors)" for a
drug-induced seizure. Whether a seizure happened is a fact about the run, not a
function of how much stability survived it, so it is tested first. -->
(if: (history:) contains "Seizure")[
(set: _clinText to "CLINICAL: Adverse Events Developed (Preventable Seizure).")
]
<!-- Route before stability, as for the seizure: the trauma-bay route lands at 40 on a
run with no physical exam and below 40 on one with, and it used to be rated
"Competent" at 40 and bare "Adverse Events Developed" below it. -->
(else-if: $c1TraumaBay)[
(set: _clinText to "CLINICAL: Adverse Events Developed (Delayed Diagnosis - Phenobarbital Load Required).")
]
(else-if: $stability >= 80 and $diagnosisErrors is 0)[
(set: _clinText to "CLINICAL: Expert (Early Recognition).")
]
(else-if: $stability >= 40)[
(set: _clinText to "CLINICAL: Competent (Delayed/Errors).")
]
(else:)[
<!-- Name only what actually happened. $intubated is set exclusively by the Case 2
passages, so it is never true here - this line could not accurately claim an
intubation under any Case 1 path. The seizure case is handled above. -->
(set: _clinText to (cond: $c1Restrained, "CLINICAL: Adverse Events Developed (Physical Restraint Required).", "CLINICAL: Adverse Events Developed."))
]
(if: $c1ICU)[ (set: _dispoText to "DISPOSITION: ICU.") ]
(else:)[ (set: _dispoText to "DISPOSITION: Medicine Floor.") ]
(set: $turnCount to $turnCount + 1)
<!-- Student review: this rating was written to the transcript and never shown.
Learners saw "patient is stable" after a drug-induced seizure, a
wrong-spectrum antibiotic or an unnecessary MRI, and read it as approval. The
game had judged them correctly all along and simply never said so. Mirrored to
a story variable because _temp does not cross the { } hook boundary. -->
(set: $clinDisplay to _clinText)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: CASE 1 END SUMMARY. " + _repText + " " + _costText + " " + _clinText + " " + _dispoText + " | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
<!-- PRELOAD-3: placed HERE, after the END SUMMARY append and after
$caseName is set, so the warm sees exactly the state SubmitData will.
Earlier in the passage it saw an unset case name and a transcript
missing its final turn, and the cache silently never matched. -->
(display: "FeedbackWarm")
}
{
<h2 style="margin-bottom: 10px; text-align: center;">CASE 1 SUMMARY</h2>
{(if: $clinDisplay is not 0 and $clinDisplay is not "")[
<div style="background: #1a1a1a; border-left: 4px solid #00BFFF; padding: 10px 14px; margin-bottom: 18px; color: #ddd; font-size: 0.95em;">(print: $clinDisplay)</div>
]}
<div style="display: flex; justify-content: space-around; background: #222; padding: 15px; border-radius: 5px; margin-bottom: 20px; border: 1px solid #444;">
<div style="text-align: center;">
<span style="font-size: 0.8em; color: #aaa;">Stability</span><br>
<strong style="font-size: 1.2em; color: (print:$stabilityColor);">(print: $stability)%</strong>
</div>
<div style="text-align: center;">
<span style="font-size: 0.8em; color: #aaa;">Cost</span><br>
(print: "<strong style='font-size: 1.2em; color: " + $costColor + ";'>$" + (text: $cost) + "</strong>")
</div>
<div style="text-align: center;">
<span style="font-size: 0.8em; color: #aaa;">Reputation</span><br>
(print: "<strong style='font-size: 1.2em; color: " + $credColor + ";'>" + (text: $cred) + "%</strong>")
</div>
</div>
<div style="background: rgba(255, 255, 255, 0.05); padding: 20px; border-radius: 8px; border: 1px solid #333;">
<h3 style="margin-top: 0; color: #00BFFF; border-bottom: 1px solid #444; padding-bottom: 10px;">Post-Shift Log</h3>
<!-- One template for all three cases (round-3 review, P1): Patient, Diagnosis,
Management, Status, Disposition. STATUS is how he is - a coloured marker for
severity and one sentence for why; DISPOSITION is only where he goes. The marker
scale is identical in every case. -->
<p><strong>Patient:</strong> Mr. Ess (Bed 4)</p>
<p><strong>Diagnosis:</strong> Alcohol Withdrawal</p>
<p><strong>Management:</strong> (print: $c1Mgmt)</p>
<p><strong>Status:</strong>
(if: $stability > 70 and $c1CareErrors is 0)[<span style="color: #00FF00">✔ STABLE.</span>]
(else-if: $stability > 70)[<span style="color: #FFCC00">⚠ STABLE, WITH RESERVATIONS.</span>]
(else-if: $stability > 40)[<span style="color: #FFCC00">⚠ GUARDED.</span>]
(else:)[<span style="color: #FF0000">✖ CRITICAL.</span>]
(print: $c1Note)
</p>
<p><strong>Disposition:</strong>
(if: $c1ICU)[Admitted to the ICU.](else:)[Admitted to the medicine floor.]
</p>
</div>
<div style="text-align: center; margin-top: 30px;">
<p style="font-size: 0.9em; color: #aaa; font-style: italic;">You are now heading to meet Dr. Sato to present the case.</p>
<div class="btn-tech-outline">
[[Present Case to Dr. Sato ->SubmitData]]
</div>
</div>
</div>
}{
(set: $caseName to "Case 1")
}
{
(if: $cred < 40)[
(set: $stability to (min: 100, $stability + 20))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Attending Assumed Care (Low Credibility) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
<div style="border: 1px solid #FFCC00; background-color: #222; padding: 20px; border-radius: 10px;">
<h3 style="color: #FFCC00; margin-top: 0;">MANAGEMENT ASSUMED</h3>
<p>
Dr. Sato does not wait for your input. She crosses her arms, watching the nurse intently.
</p>
<p>
<strong>"2mg Lorazepam, IV push. Now,"</strong> she commands. "And lets get him on the CIWA protocol immediately."
</p>
<p>
You stand back as the nurse accesses the IV. Within minutes, the patient's violent picking at the air slows. His breathing deepens. The terror leaves his eyes.
</p>
<hr style="border-color: #444;">
<p>
Dr. Sato turns to you, her voice low. "He's stabilizing. But we need to talk about your differential diagnosis process later. Find me later so I can provide you with feedback."
</p>
<h4 style="color: #FFCC00; text-align: center;">PATIENT STABILIZED (BY ATTENDING)</h4>
</div>
[[Admit to Floor & Finish Shift ->Case1Success]]
]
(else:)[
(set: $choice to "Lorazepam")
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Benzodiazepines (Lorazepam) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(if: $diagnosisErrors < 2)[
(set: $cred to (min: 100, $cred + 10))
(set: $stability to (min: 100, $stability + 20))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | EVENT: Patient Stabilized | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
<h4 style="color: #00FF00">CORRECT TREATMENT</h4>
**"Good catch,"** Dr. Sato says from the doorway.
<br><br>
You recognize the signs of **autonomic hyperactivity**. The alcohol withdrawal has suppressed his GABA receptors (inhibitory) and upregulated glutamate (excitatory).
<br><br>
Feeling confident, you turn to the patient's nurse. "Nurse, please give him 2mg lorazepam IV now, and place him on the symptom-triggered (CIWA) protocol."
<hr><br>
Within 20 minutes, Mr. Ess stops shaking. His heart rate comes down to 90 bpm. He takes a deep breath and looks at you with clarity.
<br><br>
"Thank you," he says. "I felt like I was going to jump out of my skin."
<br><br>
[[Admit to Floor & Finish Shift ->Case1Success]]
]
(else:)[
(set: $cred to (min: 100, $cred + 5))
<!-- D24 (LEDGER-1 class, outside 9A-3's table): was an absolute set to 30, which
erased prior state so two errors converged to the same score. Delta chosen to
match the rubric's near-miss/takeover tier - adjust at the metric review. -->
(set: $stability to (max: 0, $stability - 30))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | EVENT: Seizure (Delayed Treatment) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
<h3 style="color: #FF4444">CORRECT DRUG, DELAYED TIMING</h3>
**"Finally... but is it too late?"**
<br><br>
You chose the right medication, but the time you spent ruling out other causes has cost him.
<br>
While you are putting in the orders, Mr. Ess's tremors turn into violent shaking.
"Doctor!" the nurse yells. "HR is 160. He's about to start seizing!"
<br>
Because of the delay, oral or low-dose meds won't touch him now. You have to move him to the Trauma Bay for a phenobarbital load and closer monitoring.
<br>
[[Transfer to Resuscitation Bay ->Case1Success]]
]
]
}{(set: $stability to 0)}
"Abort! Give Sugammadex and wake him up!"
You decide the hemodynamics are too unstable to proceed. You pull the laryngoscope and demand reversal.
Nurse Sarah sprints to the medication dispenser. It takes her 45 seconds to override the machine, grab the vials, and run back.
While waiting, you have to bag him to keep his oxygen saturation up.
You squeeze the bag. **Whoosh**. The air enters the stomach. Gurgle.
The gastric pressure exceeds the lower esophageal sphincter pressure. Mr. Smith vomits 500mL of blood and clots. Because the sugammadex hasn't fully circulated yet, he still has no gag reflex.
The vomit floods his lungs before he ever wakes up. O2 Saturation: 94% -> 70% -> 40%
By the time he starts moving his arm (reversal successful), his heart has already stopped from severe hypoxia.
[["Code Blue!"|CodeBlue]]{(set: $stability to (max: 0, $stability - 30))
(set: $status to "Critical")
(set: $cred to (max: 0, $cred - 20))
<!-- 9C-1 / LEDGER-4: premature intubation cost $0, making the most dangerous
action in the game free while restraints cost $5,000. Cost should track
resources consumed - RSI meds, ventilator, procedure, ICU escalation. -->
(set: $cost to $cost + 1800)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Premature Intubation before adequate volume resuscitation (Induced Hypotension) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(set: $failReason to "PrematureIntubation")
}
<h3>AIRWAY ATTEMPT</h3>
{
"Airway is priority. Let's tube him."
<br><br>
You decide the aspiration risk is too high. You order Rapid Sequence Intubation (RSI).
<br><br>
Nurse Sarah looks hesitant but pushes the etomidate and rocuronium through the small IV.
<br><br>
As Mr. Smith's consciousness fades, his sympathetic drive - the only thing keeping his blood pressure up - vanishes.
<br><br>
<div style="border-left: 4px solid #FF4444; background: rgba(255,68,68,0.08); padding: 10px;">
<strong>The error was the sequence, not the intubation.</strong> Protecting this airway was a
defensible choice. Doing it at a pressure of 74/40, through a 22-gauge IV, with nothing running,
was not — induction agents removed the sympathetic drive that was holding him together.
</div>
<div class="critical-damage">
<strong>ALARMS BLARE</strong><br>
BP: 58/30 mmHg (CRITICAL)<br>
HR: 150 bpm
</div>
The Respiratory Therapist prepares to bag the patient, but as soon as positive pressure is applied to the chest, venous return to the heart drops even further.
<br><br>
You have induced <strong>peri-intubation hypotension</strong>. He is moments away from cardiac arrest.
<br><br>
<em>The team stares at the patient in shock. You are losing control of the situation.</em>
<br>
<!-- D72/D76: this used to go straight to CodeBlue, which made premature intubation
100% fatal (22 of 22 stress runs that entered here died) and left the whole written
rescue arc unreachable: Case2Crash -> Case2Resus's IO-access scene -> Case2Intubate.
$status is set to "Critical" just above, and Case2Resus's (if: $status is "Critical")
branch is the only consumer of it - so with no route here, that branch and its
+$1,500 could never run. That in turn capped every surviving Case 2 run at $350
against an optimalCost of 1700, which is why 145 of 145 Case 2 summaries read
"COST: Efficient". Routing here instead makes the crash survivable-with-effort and
restores the cost range. Death is still reachable: "Abort intubation" -> Case2Abort
-> CodeBlue is unchanged, and Case2Crash only offers a way out to a learner who
actually resuscitates. -->
[["Ignore the BP, we need the tube in. Pass me the laryngoscope!"|Case2Crash]]
<br>
[["Abort intubation! Wake him up!"|Case2Abort]]
}{(set: $stability to 80)
(set: $exam_count to 0)
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #555; padding: 5px; margin-bottom: 10px; font-size: 0.9em; background: #222;">
<span><strong>TIME:</strong> 00:37</span>
<span><strong>LOC:</strong> ICU BED 8</span>
</div>
<div style="margin-bottom: 10px;">You sprint to ICU Bed 8 and enter a chaotic scene.</div>
<div style="display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start;">
<div style="flex: 1; min-width: 300px;">
<div style="background-color: #222; padding: 10px; border-radius: 4px; border: 1px solid #444; box-sizing: border-box;">
<strong style="color: #00BFFF; font-size: 1.1em; display: block; border-bottom: 1px solid #444; margin-bottom: 5px; padding-bottom: 2px;">Mr. Smith</strong>
<div style="font-size: 0.9em; color: #ccc; margin-bottom: 15px; line-height: 1.3;">
<strong>Hx:</strong> 45-year-old male with decompensated alcohol-associated cirrhosis. Ongoing alcohol use. Admitted to the ICU due to hypotension and hematemesis.
<br><br>
</div>
|examRegion>[
(link: "[ ▶ PERFORM PHYSICAL EXAM ]")[
(replace: ?examRegion)[
<strong style="font-size: 0.95em; color: #fff;">Physical Exam:</strong>
<ul style="margin: 5px 0 0 0; padding-left: 20px; color: #ddd; font-size: 0.9em; line-height: 1.4;">
<li><strong>Gen:</strong> Pale, diaphoretic, anxious-appearing.</li>
<li><strong>HEENT:</strong> Scleral icterus present. Dry mucous membranes.</li>
<li><strong>Skin:</strong> Spider angiomata present.</li>
<li><strong>Abd:</strong> Non-tender. Distended, fluid wave present.</li>
</ul>
]
]
]
</div>
</div>
<div style="flex: 1; min-width: 300px; display: flex; flex-direction: column; gap: 8px;">
<div style="background-color: #000; border: 3px solid #333; padding: 5px 10px; font-family: monospace; color: #00ff00; border-radius: 4px;">
<!-- Student review, round 2 (phone screenshot): each vital is a <span>, and an
inline span breaks at its own internal spaces before the flex row wraps
between its children. On a 390px screen "O2: 94% - Room Air" and "RR: 22"
interleaved and read as "O2: 94% - RR:" / "Room Air 22". Making each vital
an unbreakable inline-block moves the break point to between the vitals,
which is the only place it is safe to break a number from its label. -->
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-end; gap: 2px 12px;">
<!-- Same rule as the O2 line below: the VALUE is unbreakable, the label may
break away from it. Holding the whole vital together was too wide for
the monitor at 320px and clipped "74/40" instead of wrapping it. -->
<span>HR: <strong style="color: #ff0000; font-size: 1.3em; white-space: nowrap;">128</strong></span>
<span>BP: <strong style="color: #ff0000; font-size: 1.3em; white-space: nowrap;">74/40</strong></span>
</div>
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; margin-top: 2px; gap: 2px 12px; font-size: 0.9em; color: #88ff88;">
<!-- Regression caught in the screen inventory: nowrap on the WHOLE vital made
this one wider than the monitor at 390px, so instead of interleaving it
overflowed and "Room Air" was clipped to "Room Ai" - a clipped reading is
worse than a wrapped one. Only the label-and-value pair has to stay
together; the qualifier after it may break onto its own line. -->
<span><span style="white-space: nowrap;">O2: 94%</span> — Room Air</span>
<span><span style="white-space: nowrap;">RR: 22</span></span>
</div>
</div>
<div style="margin-top: 8px; font-size: 0.92em; color: #ddd; line-height: 1.5;">
Legs mottled, hands cool, capillary refill about four seconds.
<br>
He vomited blood at home and again in triage, but is <strong>not actively vomiting now</strong>.
GCS 15, answering in short sentences, gag intact, saturating on room air —
<strong>he is protecting his own airway</strong>.
</div>
|labsRegion>[
<div style="background-color: #1a1a1a; border: 1px dashed #444; padding: 10px; text-align: center; border-radius: 4px; display: flex; align-items: center; justify-content: center;">
(link: "[ ▶ CHECK iSTAT LABS ]")[
(replace: ?labsRegion)[
<div style="background-color: #1a1a1a; padding: 10px; border: 1px solid #444; border-radius: 4px; width: 100%; box-sizing: border-box;">
<strong style="display: block; margin-bottom: 8px; color: #00BFFF; text-align: center; font-size: 0.9em; border-bottom: 1px solid #333; font-family: sans-serif;">iSTAT LABS</strong>
<div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 6px;">
<div style="background: #252525; width: 80px; padding: 5px; border-radius: 3px; text-align: center; border: 1px solid #333;">
<span style="color: #888; font-size: 0.75em; display: block; font-family: sans-serif;">HGB</span>
<!-- Author direction: amber, not red. 7.8 is low and clinically
important here, but it is not a critical value, and red is
reserved on this panel for results that are. -->
<strong style="color: #ffcc00; font-size: 1.1em; font-family: monospace;">7.8 L</strong>
</div>
<div style="background: #252525; width: 80px; padding: 5px; border-radius: 3px; text-align: center; border: 1px solid #333;">
<span style="color: #888; font-size: 0.75em; display: block; font-family: sans-serif;">PLT</span>
<!-- Author direction: the suffix on this panel is the abnormal
FLAG (L/H), as on every other tile here. "85 k" read as the
unit and left the only low-flagged result on the panel
unflagged. -->
<strong style="color: #ffcc00; font-size: 1.1em; font-family: monospace;">85 L</strong>
</div>
<div style="background: #252525; width: 80px; padding: 5px; border-radius: 3px; text-align: center; border: 1px solid #333;">
<span style="color: #888; font-size: 0.75em; display: block; font-family: sans-serif;">INR</span>
<strong style="color: #ff4444; font-size: 1.1em; font-family: monospace;">2.6 H</strong>
</div>
<div style="background: #252525; width: 80px; padding: 5px; border-radius: 3px; text-align: center; border: 1px solid #333;">
<span style="color: #888; font-size: 0.75em; display: block; font-family: sans-serif;">CREAT</span>
<strong style="color: #ffcc00; font-size: 1.1em; font-family: monospace;">1.6 H</strong>
</div>
<div style="background: #252525; width: 80px; padding: 5px; border-radius: 3px; text-align: center; border: 1px solid #333;">
<span style="color: #888; font-size: 0.75em; display: block; font-family: sans-serif;">T. BILI</span>
<strong style="color: #ff4444; font-size: 1.1em; font-family: monospace;">26 H</strong>
</div>
</div>
</div>
]
]
</div>
]
</div>
</div>
<div style="margin-top: 10px; padding: 8px; background: rgba(255, 215, 0, 0.1); border-left: 4px solid #FFD700; border-radius: 0 4px 4px 0;">
<br>
<strong>Nurse Sarah:</strong> "He just threw up quite a bit of blood. We have one peripheral 22-gauge IV. What do you want to do first?"
</div>
<br>
<div id="reasoning-container" style="background: #222; padding: 20px; border: 1px solid #555; border-radius: 5px; margin-top: 15px; box-shadow: 0 4px 10px rgba(0,0,0,0.3);">
<strong style="color: #FFD700; font-size: 1.1em;">🧠 Clinical Reasoning Checkpoint</strong>
<br><br>
<label style="font-size: 0.9em; color: #ddd; display: block; margin-bottom: 10px;">
This patient is hemodynamically unstable with recent hematemesis but is still protecting their airway.
Before you act — what is your **immediate priority**, in what **order** will you address
resuscitation, medications, and airway, and **why**?
<br><strong style="color: #FFD700;">Include the why:</strong> give the reason for your order. One or two sentences is enough.
</label>
<textarea id="reasoningInput" maxlength="1000" rows="3" style="width: 100%; padding: 10px; background: #111; color: #e0e0e0; border: 1px solid #444; border-radius: 5px; font-family: sans-serif; resize: vertical;" placeholder="I am prioritizing X because..."></textarea>
<div id="cc-c2" style="font-size: 0.85em; color: #888; margin-top: 6px;">0 / 20 characters minimum</div>
<!-- Student review, round 2: the reasoning-commit button was #00BFFF here and #FFD700
on Cases 1 and 3, so the same control changed colour between cases. Matched to the
other two; #00BFFF is used elsewhere for navigation, not for committing. -->
<button id="lock-btn-c2" style="margin-top: 15px; background-color: #FFD700; color: #000; font-weight: bold; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; transition: all 0.2s;">
<i class="fa-solid fa-lock"></i> CONFIRM REASONING & REVEAL OPTIONS
</button>
<div id="error-msg" style="color: #ff4444; font-size: 0.9em; margin-top: 10px; display: none;">
<i class="fa-solid fa-triangle-exclamation"></i> Enter your explanation (at least 20 characters) to proceed.
</div>
</div>
<div id="reveal-hook-c2" style="display:none;">
(link-repeat: "reveal-options-c2")[
(show: ?optionsHookC2)
]
</div>
|optionsHookC2)[
<div id="decision-options" style="margin-top: 15px; animation: fade-in 1s;">
[["Call Hepatology to come in for endoscopy."|Case2EndoFirst]]
[["Place two large-bore IVs (16G or 18G) and hang fluids/blood."|Case2Resus]]
[["His airway is at risk. We need to intubate him immediately."|Case2Airway]]
</div>
]
<script>
(function(){
// Live-test fix: keep this reasoning checkpoint anchored at the top of the page.
// Harlowe's engine runs passage.find("input, textarea").first().focus() 100ms
// after every passage render, and re-applies that focus when the passage
// transition finishes. On a long passage that scrolls the answer box into view
// and hides everything above it, so the learner lands mid-page. Refuse the
// programmatic focus until the learner actually reaches for the box: a pointer
// press on the box, or any keypress (which is what tabbing into it looks like),
// marks real engagement first, so mouse and keyboard entry both still work.
const scrollBox = document.getElementById("reasoningInput");
if (scrollBox) {
let engaged = false;
const engage = function() { engaged = true; };
["pointerdown", "mousedown", "touchstart"].forEach(function(ev) {
scrollBox.addEventListener(ev, engage, true);
});
document.addEventListener("keydown", engage, true);
scrollBox.addEventListener("focus", function() {
if (engaged) { return; }
scrollBox.blur();
window.scrollTo(0, 0);
});
}
window.scrollTo(0, 0);
// --- REASONING LOGIC (Standard) ---
const btn = document.getElementById('lock-btn-c2');
const input = document.getElementById('reasoningInput');
const flagShort = window.hnsAttachCounter && window.hnsAttachCounter("reasoningInput", "cc-c2", 20);
const errorMsg = document.getElementById('error-msg');
if(btn && input) {
window.studentReasoningText = "";
btn.addEventListener('click', function() {
// Gate on the TRIMMED length. All three reasoning gates tested input.value.length,
// so a submission of nothing but spaces or tabs passed as long as it was long
// enough - and that whitespace was then sent to the scorer as the learner's
// reasoning and written to the reasoning_text column, where it is indistinguishable
// from a real short answer. Raised from 10 to 20 at author direction after the
// round-2 student review: 10 characters admits "idk" plus padding, and the live
// counter now makes the target visible while typing.
if(input.value.trim().length < 20) {
// Student review: all three checkpoints now report a short answer the same way,
// through the character counter. The separate #error-msg line said the same
// sentence, so showing both would just repeat it.
if (flagShort) { flagShort(); }
return;
}
if(errorMsg) errorMsg.style.display = 'none';
window.studentReasoningText = "CASE 2 PRIORITY: " + input.value;
// 9H-1 / DATA-6: mirror into Harlowe state. window.* globals are wiped by a
// page reload, but Harlowe restores its own variables from sessionStorage - so
// without this the typed reasoning is silently replaced by the "no reasoning"
// fallback and THAT string is written to the Form and sent to Gemini as the
// student's answer.
if (window.Harlowe && window.Harlowe.API_ACCESS) {
try { window.Harlowe.API_ACCESS.STATE.variables.studentReasoning = window.studentReasoningText; } catch(e) {}
}
try { sessionStorage.setItem("hns_reasoning", window.studentReasoningText); } catch(e) {}
input.disabled = true;
input.style.backgroundColor = "#333";
input.style.color = "#888";
input.style.cursor = "not-allowed";
btn.style.display = "none";
// 9D-3 / UX-04: target the hook by a stable container id instead of matching
// on link text. The reveal itself is already Harlowe-native - the hook runs
// (show: ?optionsHookC2) - so only the lookup needed hardening.
const hiddenLink = document.querySelector('#reveal-hook-c2 tw-link');
if(hiddenLink) {
hiddenLink.click();
} else {
document.querySelector('tw-hook[name="optionsHookC2"]').style.display = "block";
}
});
}
})();
</script>
<style>
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
tw-hook[name="optionsHookC2"] { display: none; }
</style>}{
You step out of the room and call the Hepatology fellow on call.
<br><br>
"This is the resident covering the ICU," you say. "I have a 45-year-old male, decompensated cirrhosis, large-volume hematemesis, and I am worried about a variceal bleed."
<br><br>
The fellow sounds awake and alert. "Okay. Give me the rundown."
<br><br>
(if: $fluids is true)[
"He is resuscitated with fluids and blood," you reply. "He has two large-bore IVs. BP is stable at 98/60."
<br><br>
**"Good work,"** she says. "How about the meds? Did you start the octreotide and antibiotics?"
<br><br>
(if: $meds is true)[
(if: $abx is "ceftriaxone")[
"Yes, octreotide has been started and ceftriaxone is hanging now," you reply.
<br><br>
<!-- 9A-2b: raised from 40 to 50. With the soft floor at 45 a learner always enters
Case 2 at >= 45, so a 40 gate could never fire and the skeptical-fellow branch -
the payoff that motivates persisting reputation - was unreachable. -->
(if: $cred < 50)[
**"Uh huh,"** she sounds skeptical. "Look, I want to make sure everything you told me is accurate. Last time you called me for this, it was 50cc of coffee ground emesis..."
<br><br>
She pauses. "Are you reporting what the nurse told you, or did you see it? I want you to go back in there, quantify the exact volume in the suction canister, and confirm what the output looks like."
<br><br>
You have to hang up, run back into the room, measure the output (500mL bright red blood), and call her back for confirmation.
<!-- 9B-2: the -15 stability hit is removed. The learner was being charged
patient harm for the fellow's skepticism, which was earned in an EARLIER
case - and now that reputation persists (9A-2) that would compound a
past error into present physiology. Low cred IS the penalty; the delay
costs time and money, not stability. -->
(set: $cost to $cost + 50)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Fellow Demanded Re-Check (Low Credibility) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
<br><br>
"Fine," she finally says. "Sounds real. I'm coming."
<br><br>
[[Continue to Procedure ->Case2EndoPrep]]
]
(else:)[
**"Perfect,"** she says immediately. "You've done a great job getting him ready for us. I will let my attending know and we will be there in 20 minutes."
(set: $cred to (min: 100, $cred + 15))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Consulted GI (Meds & Access Optimized) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
<br><br>
<em>You have earned the fellow's respect.</em>
<br><br>
[[Continue to Procedure ->Case2EndoPrep]]
]
]
(else-if: $abx contains "piperacillin")[
"Yes," you say. "Octreotide is running. For antibiotics, I started piperacillin-tazobactam."
<br><br>
**"Stop,"** she interrupts. "Why is he on piperacillin-tazobactam?"
<br><br>
She takes a breath, using this as a teaching moment. "Piperacillin-tazobactam covers the organisms, sure, but it is **overkill**. You don't need anti-pseudomonal coverage for SBP prophylaxis. It represents poor antibiotic stewardship."
<br><br>
"We need to be precise. Switch him to ceftriaxone 1g IV."
<br><br>
(set: $cred to (max: 0, $cred - 10))
(set: $stability to (max: 0, $stability - 5))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Fellow Corrected Antibiotics (Piperacillin to Ceftriaxone) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(link: "Correct the order to ceftriaxone")[
(set: $abx to "ceftriaxone")
(set: $cost to $cost + 50)
(goto: "Case2EndoPrep")
]
]
(else-if: $abx contains "vancomycin")[
"Yes," you say. "Octreotide is running. For prophylactic antibiotics, I started vancomycin."
<br><br>
**"No, that's not the right antibiotic,"** she responds. "Why did you choose vanc?"
<br><br>
"Vancomycin covers gram-positives (MRSA), but the main threat here is **gram-negative gut flora**," she explains urgently. "You are leaving him exposed to SBP, which increases his risk of re-bleeding and death."
<br><br>
"Switch him to ceftriaxone 1g IV immediately."
<br><br>
(set: $cred to (max: 0, $cred - 15))
(set: $stability to (max: 0, $stability - 10))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Fellow Corrected Antibiotics (Vancomycin to Ceftriaxone) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(link: "Correct the order to ceftriaxone")[
(set: $abx to "ceftriaxone")
(set: $cost to $cost + 50)
(goto: "Case2EndoPrep")
]
]
(else:)[
"Yes," you say. "Octreotide is running. For antibiotics, I started $abx."
<br><br>
**"Stop,"** she interrupts. "The guidelines for prophylaxis are specific. We need 3rd-generation cephalosporin coverage for gut translocation."
<br><br>
"Switch him to ceftriaxone 1g IV."
<br><br>
(set: $cred to (max: 0, $cred - 10))
(set: $stability to (max: 0, $stability - 5))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Fellow Corrected Antibiotics (Generic Error) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(link: "Correct the order to ceftriaxone")[
(set: $abx to "ceftriaxone")
(set: $cost to $cost + 50)
(goto: "Case2EndoPrep")
]
]
]
(else:)[
"Uh, not yet," you realize you forgot.
<br><br>
**She sighs audibly.** "We talked about this in lecture. The octreotide reduces portal pressures, and the antibiotics reduce mortality by preventing SBP. Every minute you delay increases his risk of re-bleeding."
<br><br>
"I'll be there in 20 minutes—have octreotide and **prophylactic antibiotics** to prevent SBP running before I walk in the door."
(set: $stability to (max: 0, $stability - 15))
(set: $cred to (max: 0, $cred - 10))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Forgot Meds (Fellow Scolded) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
<br><br>
You hang up and turn to the nurse. "We need to start octreotide starting with a 50mcg IV bolus then 50mcg/hr," you order quickly.
<br><br>
"Got it," the nurse says, punching it into the pump. "And which antibiotic do you want for prophylaxis?"
<br><br>
(link: "Ceftriaxone 1g IV")[
(set: $meds to true)
(set: $abx to "ceftriaxone")
(set: $cost to $cost + 20)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Selected ceftriaxone (Prompted by Nurse) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(goto: "Case2EndoPrep")
]
(link: "Piperacillin-tazobactam 3.375g IV")[
(set: $meds to true)
(set: $abx to "piperacillin")
(set: $cost to $cost + 100)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Selected piperacillin-tazobactam (Prompted by Nurse) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case2EndoPrep")
]
(link: "Vancomycin 1g IV")[
(set: $meds to true)
(set: $abx to "vancomycin")
(set: $cost to $cost + 50)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Selected Vancomycin (Prompted by Nurse) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case2EndoPrep")
]
]
]
(else:)[
"We are still working on better access," you sheepishly admit.
<br><br>
**"Keep working on it,"** she says sternly. "I can't scope a patient without proper access. Call me when the lines are in."
(set: $stability to (max: 0, $stability - 10))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Premature Consult (No IV Access) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
<br><br>
[[Return to Patient ->Case2ResusDelayed]]
]
}{
(set: $stability to (min: 100, $stability + 5))
(set: $abx to "ceftriaxone")
(set: $meds to true)
<!-- 9C-0b / MET-02: ceftriaxone was free here but $200 in Case3Calc. Same drug,
same price - otherwise cost is not a comparable construct across cases. -->
(set: $cost to $cost + 200)
(set: $cred to (min: 100, $cred + 5))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Correctly ordered ceftriaxone for SBP prophylaxis in the setting of upper GI bleed in cirrhosis which is the correct antibiotic | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
}
**Correct.**
Ceftriaxone is the guideline-recommended prophylaxis. It covers the gram-negative gut flora (like *E. coli*) that most commonly cause spontaneous bacterial peritonitis and has a proven mortality benefit in variceal hemorrhage.
{
(if: $stability < 60)[
<div class="critical-damage">WARNING: HEMODYNAMIC INSTABILITY</div>
The meds are running, but looking at the monitor, the BP is **critical (82/45)**.
<br><br>
<!-- Student review, round 2: this told a learner who had ALREADY intubated that
they must resuscitate before intubating. Same family as the Case2ResusDelayed
double-intubation defect - the line asserted an airway decision that was
already made. $intubated is set only by the Case 2 airway passages, so it is
the exact test. -->
(if: $intubated is true)[
The tube is already in and the sedation is running — and that sedation is
part of why the pressure is this low. The airway is secured; the problem now is
purely hemodynamic. **He needs volume and blood before anything else.**
](else:)[
You know that if you try to intubate him now, the induction meds will tank his pressure further and likely cause cardiac arrest. **You must resuscitate before you intubate.**
]
<br>
[[Prioritize Resuscitation (Fluids/Blood) ->Case2ResusDelayed]]
](else:)[
**Current Status:**
BP: 98/60 mmHg, heart rate: 95
<br>
**Meds:** Octreotide and ceftriaxone are running.
[[Call Hepatology. He is now ready for the EGD. ->Case2CallGI]]
]
}{(set: $stability to (max: 0, $stability - 30)) <!-- D24: was absolute 40 -->
(set: $status to "Critical")
}
<div style="background-color: #330000; color: #ff9999; padding: 15px; border: 2px solid #ff0000; text-align: center; border-radius: 5px;">
<h2 style="margin: 0; letter-spacing: 2px;">STATUS: UNSTABLE</h2>
<span style="font-size: 0.9em;">RESUSCITATION IN PROGRESS</span>
</div>
<br>
{
You are at the head of the bed, managing the airway with a Bag-Valve-Mask.
<br><hr>
(if: $fluids is false)[
(set: $transcript to it + " Case 2 Decision: Attempted intubation in a hypotensive patient before resuscitating the patient with blood/fluids. ")
<strong style="color: #ffaa00;">CRITICAL WARNING:</strong>
The patient is hypotensive (BP 70/40). The induction drugs caused vascular collapse because the "tank was empty."
<br>
You cannot safely attempt intubation again without restoring preload.
<br>
[["Start Massive Transfusion Protocol (Fluids/Blood)."->Case2Resus]]
]
(else:)[
<strong style="color: #00ff00;">VOLUME STATUS IMPROVING:</strong>
Fluids and blood are running wide open. The MAP is rising (>65 mmHg).
<br><br>
(if: $meds is false)[
<em>Note: Octreotide and antibiotics are not yet running, but the airway is the priority now.</em>
<br><br>
(link: "Start octreotide/antibiotics while I intubate.")[
(set: $intubated to true)
(goto: "Case2Meds")
]
<br>
]
[[He is volume optimized. Attempt intubation again.->Case2Intubate]]
]
}{(set: $stability to (max: 0, $stability - 20))
(set: $cred to (max: 0, $cred - 15))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Premature EGD (Patient Unstable/Inadequate IV Access) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
}
<h3>THE WRONG PRIORITY</h3>
{
You grab the phone. "Get Hepatology down here! He's bleeding out!" you yell.
<br>
The Hepatology fellow picks up. You start frantically reading the vitals. "BP is 80s, HR 120s..."
<br>
The fellow cuts you off. "Whoa, slow down. Does he have access? Is he resuscitated? I can't scope a dead patient. Call me back when he has two large-bore IVs and a MAP > 65."
<br>
*Click.*
<br>
You turn back to the patient. You wasted precious minutes on the phone.
<br><br>
<div style="background-color: #330000; border: 1px solid #ff0000; padding: 10px; border-radius: 5px;">
<strong style="color: #ff5555;">STATUS CHANGE</strong><br>
Mr. Smith is now slumped over.
<br>
<strong>BP:</strong> 72/40 mmHg
<br>
<strong>HR:</strong> 145 bpm
<br>
<strong>Mental Status:</strong> Unresponsive.
</div>
<br>
Nurse Sarah looks at the small 22-gauge IV in the patient's hand.
<br><br>
"Doctor, this line is too small for rapid blood, but **it works for induction meds**," she states.
<br><br>
"I can push the induction drugs and we can tube him *right now*, or we can stop and drill an IO or start large bore IVs first. What do you want to do?"
<br><br>
[[Option A: "He isn't protecting his airway! The IV that he has is good enough. We need to intubate him now!"|Case2Airway]]
<br>
[[Option B: "No! The induction will kill him. We need better access first for resuscitation!"|Case2ResusDelayed]]
}{
(set: $caseName to "Case 2")
(set: $clinicalTopic to "GI Bleed")
(set: $hadPreviousFailure to (cond: (history:) contains "CodeBlue", true, false))
(set: $didIntubateEarly to (cond: (history:) contains "Case2Airway", "Yes", "No"))
(set: $didDelayMeds to (cond: $meds is false, "Yes", "No"))
(set: $intubated to (cond: $intubated is true, true, false))
(if: $intubated is false)[
(set: $stability to (min: 100, $stability + 10))
]
(set: $finalScore to $stability)
<!-- 9C-2: tier on EXCESS over this case's unavoidable floor, not raw $cost. -->
(set: _excess to $cost - $optimalCost)
(if: _excess < 0)[(set: _excess to 0)]
(set: $costColor to "#00FF00")
(if: _excess > 300)[(set: $costColor to "#FFCC00")]
(if: _excess > 1500)[(set: $costColor to "#FF0000")]
}
<h3>THE TEAM ARRIVES</h3>
{
The doors to the ICU room slide open. The Hepatology fellow, attending, and endoscopy nurse roll the endoscopy cart in. They look at the monitor and then at you.
<br><br>
<!-- Student review, round 2 (phone screenshot): this row was `space-around` with no
wrapping, and the AIRWAY cell read "Did not require intubation". At 390px that long
label squeezed the first two cells until they sat flush and "105/65" and "90" read
as the single number 105/6590. Wrap on narrow screens, give each cell a floor so it
cannot be crushed, and shorten the label. -->
<div style="background-color: #222; border: 1px solid #555; border-radius: 5px; padding: 10px; display: flex; flex-wrap: wrap; justify-content: space-around; align-items: center; gap: 8px 14px; margin-bottom: 15px;">
<div style="text-align: center; flex: 1 1 auto; min-width: 80px;">
<span style="color: #aaa; font-size: 0.8em;">BP</span><br>
<span style="color: #00FF00; font-weight: bold; font-size: 1.1em; white-space: nowrap;">105/65</span>
</div>
<div style="text-align: center; flex: 1 1 auto; min-width: 80px;">
<span style="color: #aaa; font-size: 0.8em;">HR</span><br>
<span style="color: #00FF00; font-weight: bold; font-size: 1.1em; white-space: nowrap;">90</span>
</div>
<div style="text-align: center; flex: 1 1 auto; min-width: 110px;">
<span style="color: #aaa; font-size: 0.8em;">AIRWAY</span><br>
(if: $intubated is true)[
<span style="color: #FFFF00; font-size: 0.9em; white-space: nowrap;">Intubated</span>
](else:)[
<span style="color: #00FF00; font-weight: bold; font-size: 0.9em; white-space: nowrap;">Not intubated</span>
]
</div>
</div>
(if: $stability > 80)[
"Excellent resuscitation." The attending nods. "You bought us time to fix this."
](else:)[
"He's stable enough," the attending says, looking at the time. "But it was tight. Let's move."
]
<!-- Student review, round 2: this paragraph ran straight on from the attending's line
with no space. The whole block is inside { } collapsing markup, which removes the
newline entirely rather than reducing it to a space, so the break has to be
explicit. -->
<br><br>
The fellow inserts the scope. On the screen, you see large, tortuous veins in the distal esophagus, one of which has a 'nipple' sign with no active bleeding.
<br><br>
The "nipple" sign indicates a **recent** variceal bleed. Because you lowered the portal pressure with octreotide and appropriately volume resuscitated the patient, the bleeding has been stabilized.
<br><br>
**Outcome:** The Hepatology team successfully band the varices endoscopically. Mr. Smith survives the night.
}
<br>
[[View Case Performance & Summary ->Case2Summary]]{(set: $meds to false)
(set: $stability to (max: 0, $stability - 15))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Forgot Meds (Prompted by Fellow) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
}
"Get Hepatology down here."
You page the fellow. Five minutes later, the Hepatology fellow calls back and you present the case.
"Okay, I'm on my way," she says. "Did you start the octreotide and antibiotics?"
You pause. In the heat of the moment, you <em>forgot the pharmacologic control</em>.
"Uh, starting them now," you lie.
The fellow sighs. "Please do. **Remember** - octreotide reduces splanchnic blood flow through vasoconstriction and can help stabilize variceal bleeding. And don't forget the antibiotics—bacterial infection is a huge mortality driver here and these patients are at risk for spontaneous bacterial peritonitis."
<em>Click.</em>
You hang up and turn to the nurse. "We need to start octreotide starting with a 50mcg IV bolus then 50mcg/hr," you order quickly.
"Got it," the nurse says, punching it into the pump. "And which antibiotic do you want for prophylaxis?"
{(link: "Ceftriaxone 1g IV")[
(set: $meds to true)
(set: $abx to "ceftriaxone")
(set: $cost to $cost + 20)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Selected Ceftriaxone (Prompted by Nurse) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(goto: "Case2EndoPrep")
]
(link: "Piperacillin-tazobactam 3.375g IV")[
(set: $meds to true)
(set: $abx to "piperacillin")
(set: $cost to $cost + 100)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Selected piperacillin-tazobactam (Too broad-spectrum/Prompted by Nurse) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case2EndoPrep")
]
(link: "Vancomycin 1g IV")[
(set: $meds to true)
(set: $abx to "vancomycin")
(set: $cost to $cost + 50)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Selected Vancomycin (Wrong Coverage/Prompted by Nurse) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case2EndoPrep")
]}{
(set: $intubated to true)
}
<h3>AIRWAY MANAGEMENT</h3>
{
<!-- D53: the (if: $fluids is true) wrapper and its (else:) collapse branch were
removed. This passage is only ever entered from Case2Resus, Case2ResusDelayed or
Case2Crash, and both resuscitation passages set $fluids to true on the way
through, so the condition was true at every possible entry and the (else:) - the
preload-collapse scene that set failReason to PrematureIntubation and offered
"Start CPR" into Case2Crash - could never execute. $fluids is only reset to false
at Case2Start, which is upstream of the bedside decision, so a retry did not
change it either. The same lesson is still taught, reachably, by
Case2Bedside -> Case2Airway -> CodeBlue. Dropping the wrapper as well as the dead
branch means this passage cannot render empty if that state ever shifts. -->
(set: _intubationType to "")
(if: $stability >= 50)[
(set: $cred to (min: 100, $cred + 5))
(set: _intubationType to "Elective Intubation (Safe/Optimized)")
<strong style="color: #00FF00">DECISION: ELECTIVE AIRWAY PROTECTION</strong>
<br>
"Smart," the respiratory therapist says. "Let's protect him before he vomits again."
<br><br>
<div style="border-left: 4px solid #00BFFF; background: rgba(0,191,255,0.08); padding: 10px;">
<strong>Dr. Sato:</strong> "Airway protection before endoscopy is <em>reasonable</em> for a bleed
like his — not mandatory, since he was protecting his own airway and had stopped actively
vomiting. What makes it safe here is that you resuscitated first. Induction agents drop the
pressure of a patient who has nothing in the tank; you gave him something in the tank."
</div>
](else:)[
(set: $stability to (max: 0, $stability - 10))
(set: _intubationType to "Urgent Intubation (High Risk/Decompensated)")
<strong style="color: #FFCC00">DECISION: URGENT AIRWAY</strong>
<br>
You are forced to take urgent action to protect the patient's airway because of his decompensation. It is risky.
]
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: " + _intubationType + " | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
<br><br>
You decide that with the ongoing hematemesis and his change in mental status, the airway is no longer safe.
<br><br>
"We need to intubate him for safety. Prepare etomidate and rocuronium," you announce.
<hr>
Because you have already started fluid and blood resuscitation, Mr. Smith **tolerates the induction.**
<br><br>
<strong>BP:</strong> Dips to 85/50 mmHg momentarily.
<br>
<strong>Action:</strong> You push a bolus of fluids, and the pressure rebounds to 100/60 mmHg.
<br><br>
<i>"I'm in."</i> You pass the endotracheal tube on the first attempt.
<div style="background-color: #333; padding: 10px; margin-top: 15px; border-left: 4px solid #00FF00;">
<strong>Clinical Insight:</strong>
By resuscitating first, you maintained his preload. This prevented the post-intubation hemodynamic collapse often seen in hypovolemic patients.
</div>
<br>
Nurse Sarah secures the tube. "Okay, he's intubated and stable. What should we do next?"
<br><br>
[["He needs antibiotics and octreotide" ->Case2Management]]
[["Page Hepatology for endoscopy" ->Case2ForgotMeds]]
}(set: $meds to true)
You order the following medications:
Pantoprazole 40mg IV BID
Octreotide: 50mcg IV Bolus + 50mcg/hr infusion.
You turn to the patient's nurse. "We need to start prophylactic antibiotics as soon as possible. It improves survival."
Nurse Sarah: "Got it. Which antibiotic do you want me to hang?"
<br>
{
[[Vancomycin 1g IV|Case2WrongAbx]]
[[Ceftriaxone 1g IV|Case2CorrectAbx]]
[[Piperacillin-Tazobactam 3.375g IV|Case2OverkillAbx]]
}{(set: $meds to true)}
You turn to Nurse Sarah. "We need to lower portal pressure and prevent infection. Please start him on octreotide and put him on IV antibiotics."
She asks for clarification. "I have the octreotide bolus and drip ready. Which antibiotic do you want to start?"
<br>
{
[[Ceftriaxone 1g IV|Case2CorrectAbx]]
[[Vancomycin 1g IV|Case2WrongAbx]]
[[Piperacillin-Tazobactam 3.375g IV|Case2OverkillAbx]]}{
(set: $caseName to "Case 2")
(set: $abxStatus to "Overkill")
(set: $abx to "piperacillin-tazobactam")
(set: $stability to (max: 0, $stability - 5))
(set: $cred to (max: 0, $cred - 5))
(set: $meds to true)
(set: $cost to $cost + 100)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Piperacillin-Tazobactam (Antibiotic Stewardship Error) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
}
{<h3>ANTIBIOTIC STEWARDSHIP ALERT</h3>
<br>
You order piperacillin-tazobactam. The nurse hangs the bag.
<br>
"Okay, broad spectrum it is," she says. "Running it now."
<br>
<div style="border-left: 4px solid #FFCC00; padding: 10px; margin: 15px 0; background-color: rgba(255, 204, 0, 0.1);">
<strong>Clinical Note:</strong> This is <strong>overkill</strong>. While piperacillin-tazobactam covers the necessary organisms, you do not need anti-pseudomonal coverage for SBP prophylaxis.
<br><br>
Ceftriaxone is the standard of care. Using piperacillin-tazobactam here contributes to antibiotic resistance without improving patient outcomes.
</div>
<hr style="border-color: #444;">
{
(if: $intubated is true)[
<strong>STATUS: INTUBATED</strong>
<br>
The patient is sedated. The antibiotics are in, but the primary issue—<strong>hemorrhagic shock</strong>—remains the priority.
(if: $stability < 50)[
<br>
<div class="critical-damage">WARNING: HYPOTENSION</div>
His BP is soft <strong>(85/45)</strong>. Broad-spectrum antibiotics won't fix his blood pressure.
]
]
(else:)[
(if: $stability >= 50)[
<strong>STATUS: ACTIVE OBSERVATION</strong>
<br>
Mr. Smith retches but doesn't vomit. He is still able to protect his airway for now.
<br><br>
]
(else:)[
<div class="critical-damage">STATUS: ACTIVE HEMATEMESIS</div>
<br>
Mr. Smith vomits a large clot. He is pale, diaphoretic, and looks tired.
<br><br>
<strong>The airway is becoming a major concern.</strong>
]
]
}
<br>
(if: $stability <= 0)[
[[Code Blue! ->CodeBlue]]
]
(else:)[
[[Consider Next Steps ->Case2Resus]]
]}{
(set: $caseName to "Case 2")
}
{(if: $fluids is true)[
<h3>RESUSCITATION STATUS</h3>
You double check the patient's IVs. The large-bore IVs are flushable.
(if: $intubated)[
The patient is sedated and the endotracheal tube is secured 23cm at the teeth.
]
(else:)[
The patient is anxious but protecting his airway for now.
]
<br><br>
<strong>Current Vitals:</strong>
<br>
BP: 94/60 mmHg (Stable)
<br>
HR: 105 bpm
<br>
<br>
Nurse Sarah looks at you.
(if: $intubated)[
"Okay, airway is secured and volume is better. What's the next move?"
]
(else:)[
"Okay, volume status is better. What is the next move?"
]
<br><br>
(if: $meds is false)[
[["Start Octreotide and Antibiotics."->Case2Meds]]
<br>
]
[["Tell Hepatology to get here immediately."->Case2CallGI]]
(if: $intubated is false)[
[["I'm still worried about his airway. Let's intubate."->Case2Intubate]]
]
]
(else:)[
(set: $fluids to true)
(set: $stability to (min: 100, $stability + 10))
<h3>VASCULAR ACCESS & RESUSCITATION</h3>
(if: $status is "Critical")[
(set: $cost to $cost + 1500)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Patient became critical, unable to get IV access so require IO placement. | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
<div style="border-left: 5px solid #ff0000; padding-left: 10px; background-color: rgba(50, 0, 0, 0.5);">
<strong>EMERGENCY ACCESS</strong>
<br><br>
"I can't get an IV!" the nurse exclaims. "His veins are collapsed!"
<br><br>
You grab the intraosseous (IO) drill. <em>Whirrrr. Chunk.</em>
<br>
You drill into the patient's left proximal tibia. "We have access! Pressure bag the blood!"
<br><br>
Within moments, the blood flows in. The MAP rises to 65 mmHg.
</div>
<br>
[[Return to Airway ->Case2Crash]]
]
(else:)[
(set: $cost to $cost + 100)
(set: $cred to (min: 100, $cred + 5))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Placed 2x Large Bore IVs (Resuscitation) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
<br>
You ignore the panic in the room and focus on access. A 22-gauge in the hand is useless for rapid transfusion.
<br><br>
You successfully place an **18G IV** in the right antecubital fossa and an **18G** in the left forearm. You order a rapid bolus of 500mL crystalloid and call the blood bank to type and cross 2 units of blood.
<br><br>
While the fluid bolus is running, you reassess.
<br>
<strong>BP:</strong> 94/60 mmHg (Improved)
<br>
<strong>HR:</strong> 105 bpm
<br>
<strong>Mental Status:</strong>
(if: $intubated is true)[
Sedated and Intubated.
]
(else:)[
Anxious, but following commands and protecting his airway.
]
<br><br>
Nurse Sarah looks relieved. "Okay, lines are in. Fluids running. Blood is ready for transfusion. Hepatology is on the phone asking for an update."
<br><br>
(if: $meds is false)[
[["He is doing better at the moment. We are going to start octreotide/antibiotics."->Case2Meds]]
]
[["Tell Hepatology the patient is ready for endoscopy."->Case2CallGI]]
(if: $intubated is false)[
[["I'm worried about his airway, we need to intubate him."->Case2Intubate]]
]
]
]}{
(set: $stability to (max: 0, $stability - 10))
(set: $fluids to true)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Delayed Resuscitation (Did not initially prioritize volume resuscitation and establishing appropriate IV access) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
}
Mr. Smith is now more diaphoretic, and his radial pulses are thready.
You struggle to find a vein in his collapsed vasculature but eventually manage to secure two 18-gauge IVs. You pressure-bag 1 liter of crystalloid and call the blood bank to start the Massive Transfusion Protocol.
**10 Minutes Later:**
BP: 88/50 mmHg (Marginally improved)
HR: 115 bpm
Mental Status: Lethargic. He is responding to voice but drifting off.
You have access, but you are playing from behind. What should you do next?
{
(if: $meds is true)[
[[He is stabilized. Call Hepatology back.|Case2CallGI]]
](else:)[
[[Start him on octreotide and IV antibiotics first.|Case2Meds]]
]
<!-- Student review: a reviewer intubated the same patient twice. Both branches of
Case2Resus already gate this on $intubated; this link was the one that was missed,
and it is reachable again via Case2Intubate -> Case2Management -> antibiotics ->
Prioritize Resuscitation. Case2Crash's "attempt intubation again" is
deliberately NOT gated - that one is a genuine retry after a failed attempt. -->
(if: $intubated is false)[
[[He's getting sleepy. We need to secure the airway first.|Case2Intubate]]
]
(else:)[
<em>He is already intubated and sedated. The airway is secure.</em>
]
}{
(if: (history:) contains "Case2Bedside")[
(set: $transcript to "Case 2 (RETRY ATTEMPT) Started: Variceal Bleed. Context: patient in the ICU with massive upper GI bleeding (esophageal variceal hemorrhage). Initial vitals: BP 74/40, HR 128, mottled and cool peripherally. Vomited blood at home and in triage but not actively vomiting; alert, GCS 15, protecting his own airway. ")
]
(else:)[
(set: $transcript to "Case 2 Started: Variceal Bleed. Context: patient in the ICU with massive upper GI bleeding (esophageal variceal hemorrhage). Initial vitals: BP 74/40, HR 128, mottled and cool peripherally. Vomited blood at home and in triage but not actively vomiting; alert, GCS 15, protecting his own airway. ")
]
(set: $caseName to "Case 2")
(set: $stability to 100)
(set: $cost to 0)
(set: $optimalCost to 1700)
(set: $turnCount to 0)
<!-- 9A-2 reputation persistence: reputation now CARRIES across cases. A single
soft floor keeps a bad case visibly damaging without locking the learner out
of a good ending. Floor = 45, no recovery bump. -->
(set: $cred to (max: 45, $cred))
(set: $status to "Alive")
(set: $outcome to "Ongoing")
(set: $failReason to "None")
(set: $clinDisplay to "")
(set: $fluids to false)
(set: $meds to false)
(set: $abx to 0)
(set: $intubated to false)
(set: $fluidStatus to "None")
(set: $abxStatus to "None")
(set: $didIntubateEarly to 0)
(set: $didDelayMeds to 0)
<script>
window.startTime = Date.now();
</script>
}
Time: 12:35 AM
Location: ED Hallway
You look at the pager. It's the ICU this time.
ICU Nurse: "Doctor, we need you. Bed 8 is crashing and vomiting blood..."
[[Run to ICU->Case2Bedside]]{
(set: $caseName to "Case 2")
(set: $abxStatus to "Wrong")
(set: $abx to "vancomycin")
(set: $stability to (max: 0, $stability - 15))
(set: $cred to (max: 0, $cred - 10))
(set: $meds to true)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered vancomycin for SBP prophylaxis in the setting of upper GI bleed in cirrhosis which is the incorrect antibiotic. | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
}
<h3>INCORRECT ANTIBIOTIC CHOICE</h3>
{
You order vancomycin. The nurse looks confused, as this is not standard protocol for variceal bleeds, but hangs the bag anyway.
<div style="border-left: 4px solid #FFCC00; padding: 10px; margin: 15px 0; background-color: rgba(255, 204, 0, 0.1);">
<strong>Clinical Error:</strong> Vancomycin covers gram-positives organisms (including MRSA). The threat in GI bleed is <strong>gram-negative</strong> gut flora (e.g., E. Coli) causing SBP. You have left him exposed to infection, increasing the mortality risk.
</div>
<hr style="border-color: #444;">
{
(if: $intubated is true)[
<strong>STATUS: INTUBATED</strong>
<br>
The patient is sedated on the ventilator.
(if: $stability < 50)[
<br>
<div class="critical-damage">WARNING: HYPOTENSION</div>
However, the blood pressure is dropping <strong>(BP 82/45)</strong>. The wrong antibiotic won't fix the hemodynamics.
]
]
(else:)[
(if: $stability >= 50)[
<strong>STATUS: ACTIVE OBSERVATION</strong>
<br>
Mr. Smith retches but doesn't vomit. He is still able to protect his airway for now.
<br><br>
]
(else:)[
<div class="critical-damage">STATUS: ACTIVE HEMATEMESIS</div>
<br>
Mr. Smith vomits a large clot. He is pale, diaphoretic, and looks tired.
<br><br>
<strong>The airway is becoming a major concern.</strong>
]
]
}
<br>
(if: $stability <= 0)[
[[Code Blue! ->CodeBlue]]
]
(else:)[
[[Consider Next Steps ->Case2Resus]]
]}{
(set: $stability to 0)
(set: $status to "Dead")
(set: $cred to 0)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Patient Died (Code Blue) | STABILITY: 0 | CRED: 0]")
(set: $outcome to "Death")
<div style="max-width: 600px; margin: 0 auto; font-family: sans-serif; background-color: #111; padding: 2px; border: 1px solid #444; border-radius: 8px;">
<div style="background-color: #900; color: #fff; padding: 10px; text-align: center; border-radius: 6px 6px 0 0;">
<h1 style="margin: 0; font-size: 2em; letter-spacing: 2px; text-transform: uppercase;">CODE BLUE</h1>
<div style="font-family: monospace; font-size: 1.2em; margin-top: 5px; color: #ffcccc;">TIME OF DEATH: 01:30</div>
</div>
<div style="padding: 20px; color: #eee; line-height: 1.5; border-bottom: 1px solid #444;">
"We lost his pulse!"
<br><br>
The monitor shows <strong>PEA</strong> (Pulseless Electrical Activity). The rhythm is visible, but the heart is empty.
<br>
<br>
"Starting compressions!" Nurse Sarah begins CPR. You push epinephrine, but the tank is dry. Despite 20 minutes of ACLS, spontaneous circulation never returns.
</div>
<div style="background-color: #222; padding: 20px; border-radius: 0 0 6px 6px;">
<h3 style="margin: 0 0 10px 0; color: #ff5555; text-transform: uppercase; border-bottom: 1px solid #444; padding-bottom: 5px;">Root Cause Analysis</h3>
<strong style="color: #fff;">The Fatal Error:</strong>
<ul style="margin-top: 5px; margin-bottom: 15px; color: #ccc; padding-left: 20px;">
<li><strong>Called Hepatology Too Early?</strong> You wasted the "Golden Hour" on the phone while the patient bled out.</li>
<li><strong>Intubated Too Early?</strong> Sedation + Positive Pressure collapsed his venous return (preload) before you replaced the intravascular volume.</li>
</ul>
<div style="background-color: #333; padding: 10px; border-left: 4px solid #00BFFF; color: #ddd;">
<strong style="color: #00BFFF;">KEY LEARNING POINT</strong>
<br>
In significant upper GI hemorrhage where patients are protecting their airway, <strong>resuscitate before you intubate</strong>.
<br>
Mr. Smith was protecting his own airway, so blood products had to come first: sedation and positive pressure before volume is replaced collapse his venous return. When the airway is genuinely compromised, as with active massive hematemesis or a patient who cannot protect it, intubation cannot wait, and resuscitation runs alongside it.
</div>
</div>
</div>
<br>
<div style="text-align: center;">
[[Restart Case ->SubmitData]]
</div>
}{
(set: $historyCase1 to $transcript)
(set: $caseName to "Case 2")
(set: $stability to 100)
(set: $cost to 0)
(set: $optimalCost to 1700)
(set: $exam_count to 0)
(set: $turnCount to 0)
(set: $outcome to "Ongoing")
(set: $failReason to "None")
<!-- 9A-2 reputation persistence: reputation now CARRIES across cases. A single
soft floor keeps a bad case visibly damaging without locking the learner out
of a good ending. Floor = 45, no recovery bump. -->
(set: $cred to (max: 45, $cred))
(set: $fluids to false)
(set: $meds to false)
(set: $intubated to false)
(set: $status to "Alive")
(set: $fluidStatus to "None")
(set: $abxStatus to "None")
}
Time: 12:30 AM
Location: Break Room
You finish your documentation on the first patient. You grab a lukewarm coffee and sit down for a moment of peace.
Just as you close your eyes, the pager goes off again.
[[Check Pager ->Case2Start]]{(set: $stability to (max: 0, $stability - 15)) (set: $cost to $cost + 900)
(set: $cred to (max: 0, $cred - 10))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | EVENT: Case 1: ordered CT head before examining patient, low-yield study and expensive overall. Also - did not initially go to the ER and examine the patient. " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")}
Time: 8:00 PM
The CT head is negative for acute bleed. However, the patient was waiting in the scanner hallway for 90 minutes without treatment before getting scanned. He is now tachycardic (HR 120).
**Feedback**: While a CT isn't "wrong," it delayed the physical exam. In altered mental status, the bedside exam is your most cost-effective tool.
[[Go to the Bedside ->Bedside]]<h3>Differential Diagnosis</h3>
{
(if: $diagnosisErrors > 0)[
<span style="color: yellow">The nurse looks at you expectantly. "Doctor, we need a decision. He is getting worse."</span>
]
(else:)[
You review the patient's presentation: History of alcohol use, cirrhosis, tachycardia, tremor, and agitation.
]
}
{
(if: $diagnosisErrors is 0)[
<div id="reasoning-container" style="background: #222; padding: 20px; border-left: 5px solid #FFD700; border-radius: 5px; margin-bottom: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.5);">
<strong style="color: #FFD700; font-size: 1.1em;">🧠 Clinical Reasoning Checkpoint</strong>
<br>
<span style="font-size: 0.9em; color: #ccc;">
Based on what you know so far, you must commit to a diagnosis.
<br><em>What is your top differential diagnosis (choose only one) and <strong>why</strong>?</em>
<br><strong style="color: #FFD700;">Include the why:</strong> name the findings that point to this diagnosis. Your reasoning is what Dr. Sato scores, not the diagnosis alone. One or two sentences is enough.
</span>
<br>
<textarea id="diff-input" maxlength="1000" rows="3" style="width: 95%; padding: 10px; border-radius: 5px; border: 1px solid #555; background: #111; color: #e0e0e0; font-family: sans-serif; font-size: 1em;" placeholder="My top diagnosis is ... because ..."></textarea>
<div id="cc-c1" style="font-size: 0.85em; color: #888; margin-top: 6px;">0 / 20 characters minimum</div>
<button id="commit-btn" style="margin-top: 15px; padding: 10px 25px; background-color: #FFD700; color: #000; font-weight: bold; border: none; border-radius: 4px; cursor: pointer; transition: background 0.3s;">
<i class="fa-solid fa-lock"></i> LOCK IN & REVEAL OPTIONS
</button>
</div>
]
<div id="hidden-choices" style="display:none; animation: fade-in 0.8s;">
<strong>Select the diagnosis that best fits your reasoning:</strong>
<br>
|choices>[
{
(link: "Alcohol Withdrawal")[
(if: $diagnosisErrors is 0)[
(set: $cred to (min: 100, $cred + 20))
(set: $stability to (min: 100, $stability + 5))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Correctly Diagnosed Alcohol Withdrawal (First Try) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(replace: ?choices)[
<div style="border: 2px solid #00FF00; background-color: rgba(0, 255, 0, 0.1); padding: 15px; border-radius: 5px;">
<h3 style="margin-top: 0; color: #00FF00;">✔ ALCOHOL WITHDRAWAL</h3>
"That fits everything," the nurse says...
<br>
[[Order Treatment ->Withdrawal]]
</div>
]
](else:)[
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Correctly Diagnosed Alcohol Withdrawal (After Initial Errors) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(replace: ?choices)[
<div style="border: 2px solid #00FF00; background-color: rgba(0, 255, 0, 0.1); padding: 15px; border-radius: 5px;">
<h3 style="margin-top: 0; color: #00FF00;">✔ ALCOHOL WITHDRAWAL</h3>
Finally, the diagnosis fits.
<br><br>
[[Order Treatment ->Withdrawal]]
</div>
]
]
]
(if: $checkedPanic is not true)[
(link: "Panic Attack")[
(set: $choice to "Panic Attack")
(set: $checkedPanic to true)
(set: $transcript to $transcript + "Case 1: Incorrectly selected Panic Attack. ")
(goto: "DiagnosisWrong")
]
]
(if: $checkedHE is not true)[
(link: "Hepatic Encephalopathy")[
(set: $choice to "hepatic encephalopathy")
(set: $checkedHE to true)
(set: $transcript to $transcript + "Case 1: Incorrectly selected hepatic encephalopathy. ")
(goto: "DiagnosisWrong")
]
]
(if: $checkedMeningitis is not true)[
(link: "Meningitis")[
(set: $choice to "Meningitis")
(set: $checkedMeningitis to true)
(set: $transcript to $transcript + "Case 1: Incorrectly selected Meningitis. ")
(goto: "DiagnosisWrong")
]
]
}
]
</div>
}<!-- The focus-guard script and its keyframes live outside the collapsing block above,
so the blank lines around them rendered as trailing line breaks under the answer
options. Wrapping them collapses that dead space; the script itself is unchanged. -->
{<script>
(function(){
// Live-test fix: keep this reasoning checkpoint anchored at the top of the page.
// Harlowe's engine runs passage.find("input, textarea").first().focus() 100ms
// after every passage render, and re-applies that focus when the passage
// transition finishes. On a long passage that scrolls the answer box into view
// and hides everything above it, so the learner lands mid-page. Refuse the
// programmatic focus until the learner actually reaches for the box: a pointer
// press on the box, or any keypress (which is what tabbing into it looks like),
// marks real engagement first, so mouse and keyboard entry both still work.
const scrollBox = document.getElementById("diff-input");
if (scrollBox) {
let engaged = false;
const engage = function() { engaged = true; };
["pointerdown", "mousedown", "touchstart"].forEach(function(ev) {
scrollBox.addEventListener(ev, engage, true);
});
document.addEventListener("keydown", engage, true);
scrollBox.addEventListener("focus", function() {
if (engaged) { return; }
scrollBox.blur();
window.scrollTo(0, 0);
});
}
window.scrollTo(0, 0);
const btn = document.getElementById("commit-btn");
const input = document.getElementById("diff-input");
const flagShort = window.hnsAttachCounter && window.hnsAttachCounter("diff-input", "cc-c1", 20);
const choices = document.getElementById("hidden-choices");
const reasoningContainer = document.getElementById("reasoning-container");
// LOGIC:
// If "reasoning-container" exists, it means Harlowe rendered it (Errors == 0).
// In this case, we KEEP choices hidden and wait for the click.
if (reasoningContainer) {
// Clear previous reasoning (Prevent Ghost Data)
window.studentReasoningText = "";
btn.addEventListener("click", function() {
// 1. Validation
// Trimmed length and the 20-character floor - see the note on the Case 2 checkpoint.
if (input.value.trim().length < 20) {
if (flagShort) { flagShort(); }
return;
}
// 2. Lock UI
input.disabled = true;
input.style.backgroundColor = "#444";
input.style.color = "#aaa";
input.style.cursor = "not-allowed";
btn.style.display = "none";
// 3. Reveal Options
choices.style.display = "block";
// 4. Save Data
window.studentReasoningText = "CASE 1 DIFFERENTIAL: " + input.value;
// 9H-1 / DATA-6: mirror into Harlowe state. window.* globals are wiped by a
// page reload, but Harlowe restores its own variables from sessionStorage - so
// without this the typed reasoning is silently replaced by the "no reasoning"
// fallback and THAT string is written to the Form and sent to Gemini as the
// student's answer.
if (window.Harlowe && window.Harlowe.API_ACCESS) {
try { window.Harlowe.API_ACCESS.STATE.variables.studentReasoning = window.studentReasoningText; } catch(e) {}
}
try { sessionStorage.setItem("hns_reasoning", window.studentReasoningText); } catch(e) {}
});
} else {
// If "reasoning-container" DOES NOT exist, it means Harlowe skipped it (Errors > 0).
// Therefore, this is a RETRY. We must reveal the choices immediately.
if(choices) {
choices.style.display = "block";
}
}
})();
</script>
<style>
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
</style>}{
(set: $diagnosisErrors to $diagnosisErrors + 1)
(set: $stability to (max: 0, $stability - 15))
(set: $cred to (max: 0, $cred - 15))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Incorrectly Diagnosed " + $choice + " | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
}
<h3>Incorrect Diagnosis: $choice</h3>
{
(if: $choice is "Panic Attack")[
(set: $cost to $cost + 50)
You try to talk the patient down, leaning in close to offer reassurance.
<br>
"Sir," you say, "You're safe. Just take some nice deep breaths in and out."
<br>
He stares at you with wide, wild eyes. He is diaphoretic, tachypneic, and not redirectionable.
<br>
"Doctor," the nurse warns, stepping back. "<em>I don't think he likes you being that close.</em>"
]
(else-if: $choice is "hepatic encephalopathy")[
(set: $cost to $cost + 150)
You suspect the confusion is driven by elevated ammonia levels.
<br>
(if: $transcript contains "lactulose")[
"Doctor," the nurse says, checking the medication log. "We already gave the lactulose you ordered when he first arrived. He's had two bowel movements since then, but he is getting <strong>more agitated</strong>, not less."
]
(else:)[
(if: $transcript contains "asterixis" or $checkedAsterixis is true)[
You think back to your earlier physical exam.
<br>
When you checked for <strong>asterixis</strong>, you did <em>not</em> see the characteristic slow, negative myoclonus (the "flap"). Instead, his hands exhibited a <strong>fine, high-frequency kinetic tremor</strong>.
]
(else:)[
You attempt to check for <strong>asterixis</strong> now. You reach out to extend his wrist.
<br><br>
"Get off me!" he screams, jerking his arm away violently.
<br><br>
He is now too agitated to cooperate with an accurate neurological exam. You missed your window to assess this when he was more calm.
]
]
]
(else-if: $choice is "Meningitis")[
(set: $cost to $cost + 200)
You consider a lumbar puncture, but the patient has no nuchal rigidity (stiff neck), and his mental status is fluctuating too rapidly. He isn't able to follow your directions to stay still.
]
}
{<div class="critical-damage">
<strong>STABILITY CRITICAL</strong>
</div>}
{
(if: $cred < 40 and $statusAlertShown is false)[
(set: $statusAlertShown to true)
<div style="border: 1px solid #FF0000; background-color: rgba(255, 0, 0, 0.1); padding: 10px; margin-bottom: 15px; border-radius: 5px;">
<strong style="color: #FF0000">STATUS CHANGE: MICRO-MANAGED</strong>
<br>
The Charge Nurse stops writing and looks at you over her glasses.
<br>
"Doctor, I'm not comfortable with these orders," she says firmly. "I'm paging your attending to come to the bedside."
<br>
<em>You have lost your autonomy.</em>
</div>
]
}
{
(if: $diagnosisErrors is 1)[
The patient's tremor is worsening. He begins picking at the air, mumbling about spiders on the ceiling.
]
(else-if: $diagnosisErrors is 2)[
The patient tries to climb out of bed. "I have to get to work!" he shouts, swinging his legs over the side rail. The nurse struggles to keep him in bed.
]
(else:)[
He is drenched in sweat. The monitor alarms as his heart rate spikes to 145. You are losing control of the situation.
]
}
<br>
You are running out of time.
<!-- A1 / D54: these two branches used to run stability-first, which made
Case1AttendingIntervention unreachable for every learner. The takeover needs
stability > 60 AND reputation < 40 at the same moment, i.e. a gap of at least 30
between them - but no action in Case 1 widens that gap (a wrong diagnosis costs
-15/-15, the ammonia trap -20/-15, a CT head -15/-10, an exam -5/0, asterixis
-5/+5), so the starting gap of 30 is the largest a learner ever has, and the
-15/-15 steps run 85/55 -> 70/40 -> 55/25 without ever landing on 65/35. The
scene had never played once in 662 submitted rows.
Reputation is now tested first, so losing the room is what summons the attending.
`and $stability > 40` keeps the patient's condition primary: a plain reorder moved
64 of the 96 reachable states to the takeover and fired it at stability as low as
5, which would have made the seizure rare and had Dr. Sato arrive for a quiet word
over a dying patient. With the clause, the split across those 96 states is
Re-evaluate 17 (unchanged), Seizure/Restraints 57, takeover 22 - and the takeover
only fires between stability 45-55 and reputation 25-35, which is the premise the
scene is written on. Do not drop the stability clause. -->
{
(if: $cred < 40 and $stability > 40)[
<br>
The doors to the room swing open. Your attending Dr. Sato strides in, looking at the monitor and the distressed patient. She does not look pleased.
<br><br>
[[Step aside for the Attending ->Case1AttendingIntervention]]
]
(else-if: $stability <= 60)[
(if: $choice is "Panic Attack")[
Suddenly, the patient screams. He mistakes your stethoscope for a snake and lunges at your face!
[[Protect yourself ->Restraints]]
](else:)[
Suddenly, the patient's eyes roll back. His body goes rigid.
<span style="color: red; font-size: 1.5em; font-weight: bold;">HE IS SEIZING.</span>
[[Manage Airway & Seizure ->Seizure]]
]
]
(else:)[
[[Re-evaluate the Differentials ->Diagnosis]]
]
}{
<!-- ANALYSIS INTENT (documentation only, no runtime effect):
* Design is single-group and post-test-heavy - a feasibility/acceptability
study, not a controlled evaluation. Frame findings accordingly.
* The confidence items are ORDINAL 1-5 with anchored endpoints only.
Analyse the paired pre/post ratings with a Wilcoxon signed-rank test and
report medians with IQR - not means with SD, and not a paired t-test.
* Confidence change is a SELF-EFFICACY signal, not a learning outcome.
* The AI reasoning_score is an UNVALIDATED EXPLORATORY metric. Report it
descriptively only; do not use it for inferential claims without the
human-rater validation described in Appendix 1 of the master audit.
* Scores collected before the Tier 8 scoring fixes (temperature, failure
sentinel, clamping) are NOT comparable with later ones - exclude them. -->
(set: $feed_realism to "0")
(set: $feed_mechanics to "0")
(set: $feed_format to "0")
<div id="survey-hidden-data" style="display:none;">
<span id="survey-session-id">$sessionID</span>
<span id="cf-assess-pre">$conf_assess_pre</span>
<span id="cf-assess-post">$conf_assess_post</span>
<span id="cf-bleed-pre">$conf_bleed_pre</span>
<span id="cf-bleed-post">$conf_bleed_post</span>
<span id="cf-sbp-pre">$conf_sbp_pre</span>
<span id="cf-sbp-post">$conf_sbp_post</span>
</div>
<div style="max-width: 700px; margin: 0 auto; background-color: #222; padding: 20px; border-radius: 8px; border: 1px solid #444; font-size: 0.9em; box-shadow: 0 4px 15px rgba(0,0,0,0.5);">
<h2 style="color: #00BFFF; text-align: center; margin: 0 0 20px 0;">MODULE FEEDBACK</h2>
<p style="color: #ccc; line-height: 1.6; margin: 0 0 18px 0;">
<strong>Last thing </strong><strong>— how was the module?</strong>
</p>
<div style="background-color: #333; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
<div style="border-bottom: 2px solid #555; padding-bottom: 5px; margin-bottom: 5px;">
<strong style="color: #00BFFF;">PART 1: GAME EVALUATION</strong>
</div>
<!-- Student review, round 2 (phone screenshot): each row was a non-wrapping flex with
the label at `flex: 1` and the select pinned at `flex: 0 0 160px`. At 390px the
label plus a fixed 160px plus the card's padding exceeded the viewport and the
dropdowns were pushed off the right edge. The rows now wrap and the select drops
to its own line under the question, which is the same behaviour .conf-row already
uses on the confidence grid. -->
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 6px 12px; padding: 8px 0; border-bottom: 1px solid #444;">
<div style="flex: 1 1 210px; min-width: 0; color: #eee; padding-right: 10px; font-size: 0.95em;">1. Scenarios felt realistic/relevant.</div>
<div style="flex: 1 1 160px; min-width: 150px; text-align: right;">
<select id="feed_realism" style="width:100%; padding:5px; background:#222; color:#fff; border:1px solid #555; border-radius:4px;">
<option value="0">Select...</option><option value="1">Strongly Disagree</option><option value="2">Disagree</option><option value="3">Neutral</option><option value="4">Agree</option><option value="5">Strongly Agree</option>
</select>
</div>
</div>
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 6px 12px; padding: 8px 0; border-bottom: 1px solid #444;">
<div style="flex: 1 1 210px; min-width: 0; color: #eee; padding-right: 10px; font-size: 0.95em;">2. The debriefs/immediate feedback helped me learn.</div>
<div style="flex: 1 1 160px; min-width: 150px; text-align: right;">
<select id="feed_mechanics" style="width:100%; padding:5px; background:#222; color:#fff; border:1px solid #555; border-radius:4px;">
<option value="0">Select...</option><option value="1">Strongly Disagree</option><option value="2">Disagree</option><option value="3">Neutral</option><option value="4">Agree</option><option value="5">Strongly Agree</option>
</select>
</div>
</div>
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 6px 12px; padding: 8px 0;">
<div style="flex: 1 1 210px; min-width: 0; color: #eee; padding-right: 10px; font-size: 0.95em;">3. I prefer this over traditional classroom-based study.</div>
<div style="flex: 1 1 160px; min-width: 150px; text-align: right;">
<select id="feed_format" style="width:100%; padding:5px; background:#222; color:#fff; border:1px solid #555; border-radius:4px;">
<option value="0">Select...</option><option value="1">Strongly Disagree</option><option value="2">Disagree</option><option value="3">Neutral</option><option value="4">Agree</option><option value="5">Strongly Agree</option>
</select>
</div>
</div>
</div>
<div style="background-color: #333; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
<div style="border-bottom: 2px solid #555; padding-bottom: 5px; margin-bottom: 10px;">
<strong style="color: #00BFFF;">PART 2: ADDITIONAL FEEDBACK (Optional)</strong>
</div>
<div style="color: #aaa; font-size: 0.9em; margin-bottom: 10px;">
Did you encounter any bugs? Any incorrect data presented in the AI feedback? Do you have suggestions for improvement?
</div>
<textarea id="feed_open" maxlength="1000" rows="4" style="width: 100%; padding: 10px; background: #222; color: #eee; border: 1px solid #555; border-radius: 4px; font-family: sans-serif; resize: vertical;" placeholder="Type your feedback here..."></textarea>
</div>
<div style="text-align: center; margin-top: 15px;">
<button id="final-submit-btn" onclick="submitSurveyData()" style="padding: 15px 30px; background-color: #00BFFF; color: #000; font-weight: bold; font-size: 1.1em; border: none; border-radius: 5px; cursor: pointer; transition: background 0.3s; width: 100%;">
SUBMIT EVALUATION & END SHIFT
</button>
<div id="survey-loading" style="display:none; color: #00BFFF; margin-top: 10px;">
<i class="fa-solid fa-circle-notch fa-spin"></i> Uploading feedback...
</div>
<div id="survey-success" style="display:none; margin-top: 15px; color: #00FF00;">
<strong><i class="fa-solid fa-check-circle"></i> Success!</strong>
<br>
<div id="next-link-container" style="margin-top: 10px; display:inline-block; border: 1px solid #00FF00; padding: 10px 20px; border-radius: 4px;">
(link-goto: "▶ CONTINUE TO FINAL DEBRIEF", "GameConclusion")
</div>
</div>
<div id="survey-error" style="display:none; color: #FF4444; margin-top: 10px;">
Submission failed. Please check connection.
</div>
</div>
</div>
<script>
// Live-test fix (same defect as the three reasoning checkpoints): Harlowe's engine
// runs passage.find("input, textarea").first().focus() 100ms after every passage
// render and re-applies it when the transition ends. Focusing a field scrolls it
// into view, so this page opened part-way down with the survey scrolled past its own confidence and rating questions.
// Refuse the programmatic focus until the learner actually reaches for the field:
// a pointer press on it, or any keypress (which is what tabbing in looks like),
// marks real engagement first, so mouse and keyboard entry both still work.
const scrollBox = document.getElementById("feed_open");
if (scrollBox) {
let engaged = false;
const engage = function() { engaged = true; };
["pointerdown", "mousedown", "touchstart"].forEach(function(ev) {
scrollBox.addEventListener(ev, engage, true);
});
document.addEventListener("keydown", engage, true);
scrollBox.addEventListener("focus", function() {
if (engaged) { return; }
scrollBox.blur();
window.scrollTo(0, 0);
});
}
window.scrollTo(0, 0);
// ================= CONFIGURATION =================
const FORM_URL = "https://docs.google.com/forms/d/e/1FAIpQLSfjS-eNL1lzxO_zoFtRCYoaxzFKeO9MR_3amvKLDx5H8gui2A/formResponse";
// EXISTING ENTRY IDs
const ENTRY_SESSION = "entry.1983371625";
const ENTRY_CASE = "entry.2131634372";
// SURVEY FIELDS
const ENTRY_CONF_ALC_PRE = "entry.298622903";
const ENTRY_CONF_ALC_POST = "entry.1603956548";
const ENTRY_CONF_BLEED_PRE= "entry.492643514";
const ENTRY_CONF_BLEED_POST="entry.1664098211";
const ENTRY_CONF_SBP_PRE = "entry.651017328";
const ENTRY_CONF_SBP_POST = "entry.503158557";
const ENTRY_FEED_REALISM = "entry.790646152";
const ENTRY_FEED_MECHANICS= "entry.1829309342";
const ENTRY_FEED_FORMAT = "entry.1888688461";
// --- NEW OPEN FEEDBACK FIELD (UPDATE THIS!) ---
const ENTRY_FEED_OPEN = "entry.1891285061";
// D78: survey rows carried no player hash, so the six confidence ratings - the
// study's primary outcome - joined to a participant on Session ID alone. D59 shows
// session IDs can collide, and a collision would merge two learners' confidence
// data with no second key to catch it. Same entry ID the case and board rows use.
const ENTRY_PLAYER_ID = "entry.919823415";
// =================================================
window.submitSurveyData = function() {
// S5 double-submit guard: the button is hidden further down, but only AFTER the
// values are read, so a fast double-click could POST the same record twice.
if (window.surveySubmitted) { return; }
window.surveySubmitted = true;
// 1. Grab Session ID
const sessionID = document.getElementById("survey-session-id").innerText;
// 2. Grab Survey Values
const c_ass_pre = document.getElementById("cf-assess-pre").innerText.trim();
const c_ass_post = document.getElementById("cf-assess-post").innerText.trim();
const c_ble_pre = document.getElementById("cf-bleed-pre").innerText.trim();
const c_ble_post = document.getElementById("cf-bleed-post").innerText.trim();
const c_sbp_pre = document.getElementById("cf-sbp-pre").innerText.trim();
const c_sbp_post = document.getElementById("cf-sbp-post").innerText.trim();
const f_real = document.getElementById("feed_realism").value;
const f_mech = document.getElementById("feed_mechanics").value;
const f_form = document.getElementById("feed_format").value;
// Grab Open Feedback (Optional)
const f_open = document.getElementById("feed_open").value;
// 3. Validation (Only check mandatory fields)
if(c_ass_pre == "0" || c_ass_post == "0" || c_ble_pre == "0" || c_ble_post == "0" || c_sbp_pre == "0" || c_sbp_post == "0") {
// The double-submit guard above was set BEFORE this check and was not cleared
// here, so one failed validation latched it true for the rest of the session:
// every later press returned at the guard and the learner could never submit,
// even after completing the section. That costs the survey row, the six
// confidence ratings AND the evaluation, since GameConclusion is reachable
// only from here. Release the guard on every path that does not POST.
window.surveySubmitted = false;
alert("Please complete the Clinical Confidence section.");
return;
}
const btn = document.getElementById("final-submit-btn");
const loading = document.getElementById("survey-loading");
const successMsg = document.getElementById("survey-success");
btn.style.display = "none";
loading.style.display = "block";
// 4. Construct Form Data
const formData = new FormData();
formData.append(ENTRY_SESSION, sessionID);
formData.append(ENTRY_CASE, "User Feedback Survey");
formData.append(ENTRY_CONF_ALC_PRE, c_ass_pre);
formData.append(ENTRY_CONF_ALC_POST, c_ass_post);
formData.append(ENTRY_CONF_BLEED_PRE, c_ble_pre);
formData.append(ENTRY_CONF_BLEED_POST, c_ble_post);
formData.append(ENTRY_CONF_SBP_PRE, c_sbp_pre);
formData.append(ENTRY_CONF_SBP_POST, c_sbp_post);
formData.append(ENTRY_FEED_REALISM, f_real);
formData.append(ENTRY_FEED_MECHANICS, f_mech);
formData.append(ENTRY_FEED_FORMAT, f_form);
// Append Open Feedback
formData.append(ENTRY_FEED_OPEN, f_open);
// Same recovery chain the case rows use (D16): window first, then the
// sessionStorage copy, which is what survives a reload.
let _survPid = "guest_unknown";
let _sp = "";
try { _sp = sessionStorage.getItem("hns_playerid") || ""; } catch(e) {}
const _okPid = v => typeof v === "string" && v && v !== "loadingid"
&& v !== "loadingID" && v !== "undefined";
if (_okPid(window.playerID)) { _survPid = window.playerID; }
else if (_okPid(_sp)) { _survPid = _sp; }
formData.append(ENTRY_PLAYER_ID, _survPid);
// D82: the guard at the top of this function is window.surveySubmitted, a window
// global that a reload wipes - the same gap D75 closed for the case rows. There is a
// second route to a duplicate too: the 12s watchdog below deliberately re-arms the
// Retry button, but mode:"no-cors" cannot distinguish a slow-but-accepted POST from a
// lost one, so a learner who waited and pressed Retry wrote the record twice. Fixing
// it by weakening the watchdog would trade a duplicate for a dead end, so the fix
// belongs here instead. These six confidence ratings are the study's primary outcome
// and D78 has only just given these rows a player hash to join on.
//
// Placed AFTER validation on purpose: a failed validation must consume nothing (D63).
// Armed in .then(), never before the POST - see the note in SubmitData (D75).
let _survKey = "";
try {
_survKey = "hns_survey_sent_" + sessionID;
if (sessionStorage.getItem(_survKey)) {
console.warn("Duplicate survey submission suppressed (reload or retry).");
if (loading) { loading.style.display = "none"; }
if (successMsg) { successMsg.style.display = "block"; }
setTimeout(function() {
const nextLink = document.querySelector('#next-link-container tw-link');
if (nextLink) { nextLink.click(); }
}, 1200);
return;
}
} catch (e) { _survKey = ""; }
// 5. Send
// Watchdog: a hung POST settles neither .then nor .catch, leaving the spinner up
// and the continue link (which lives inside #survey-success) unreachable. Reveal a
// way forward and say plainly that the upload may not have completed.
let surveySettled = false;
const surveyWatchdog = setTimeout(function() {
if (surveySettled) { return; }
surveySettled = true;
if (loading) { loading.style.display = "none"; }
const err = document.getElementById("survey-error");
if (err) { err.style.display = "block"; }
const heading = successMsg ? successMsg.querySelector("strong") : null;
if (heading) { heading.innerText = "Your feedback may not have been saved."; }
if (successMsg) { successMsg.style.display = "block"; }
window.surveySubmitted = false;
if (btn) { btn.style.display = "block"; btn.innerText = "Retry Submission"; }
}, 12000);
fetch(FORM_URL, { method: "POST", mode: "no-cors", body: formData })
.then(() => {
// D82: the row has reached Google. Armed here and nowhere earlier, so a reload
// during a FAILING post still re-sends rather than being silently suppressed.
try { if (_survKey) { sessionStorage.setItem(_survKey, "1"); } } catch (e) {}
if (surveySettled) { return; }
surveySettled = true;
clearTimeout(surveyWatchdog);
loading.style.display = "none";
successMsg.style.display = "block";
setTimeout(() => {
const nextLink = document.querySelector('#next-link-container tw-link');
if(nextLink) {
nextLink.click();
}
}, 1200);
})
.catch(err => {
if (surveySettled) { return; }
surveySettled = true;
clearTimeout(surveyWatchdog);
console.error(err);
loading.style.display = "none";
document.getElementById("survey-error").style.display = "block";
// Allow the retry to actually re-submit; the guard at the top of this
// function would otherwise reject every attempt after the first.
window.surveySubmitted = false;
btn.style.display = "block";
btn.innerText = "Retry Submission";
});
}
</script>}{
<!-- D50: this passage is tagged FOOTER, not header, despite its name. Harlowe
renders header passages BEFORE the passage body, so the HUD was computed from
state the body had not yet changed and every penalty or reward reached the
learner one screen late (on TrapAmmonia the HUD read 100/70 while that same
turn's transcript recorded 80/55). A footer passage renders AFTER the body, so
the values below are the ones the body just set. .hud-bar is position:fixed and
tw-story reserves 80px for it, so moving it down the DOM changes nothing
visually. Do not retag this back to header. -->
(if: $stability is not a number)[ (set: $stability to 100) ]
(set: $stabColor to "#00FF00")
(if: $stability <= 60)[(set: $stabColor to "#FFCC00")]
(if: $stability <= 30)[(set: $stabColor to "#FF0000")]
(set: $repColor to "#00FF00")
<!-- A5: yellow begins BELOW the starting reputation (70), not below 80. With the old
threshold the HUD opened in a warning colour before the learner had done anything.
Red is unchanged. Keep this number equal to Init's starting $cred. -->
(if: $cred < 70)[(set: $repColor to "#FFCC00")]
(if: $cred < 40)[(set: $repColor to "#FF0000")]
<!-- 9C-2 EXCESS-BASED COST TIERING. Every cost display in the story reads these
three variables, so the HUD and the summaries can no longer disagree.
_excess = spend above this case's unavoidable floor, floored at 0.
<= 300 GREEN Efficient
<= 1500 YELLOW Moderate
else RED Excessive -->
(set: _excess to $cost - $optimalCost)
(if: _excess < 0)[(set: _excess to 0)]
(set: $costColor to "#00FF00")
(set: $costDisplay to "$")
(if: _excess > 300)[
(set: $costColor to "#FFCC00")
(set: $costDisplay to "$$$")
]
(if: _excess > 1500)[
(set: $costColor to "#FF0000")
(set: $costDisplay to "$$$$$")
]
<!-- The stability tier is carried by the bar alone (length + colour). The
STABLE/GUARDED/CRITICAL word that used to sit beside it was removed after
live testing: it wrapped the HUD onto a second line on narrow screens.
$stabColor above is the single source of truth for the tier. -->
<div class="hud-bar">
<div class="hud-item case-chip">
<!-- $caseName is unset until Case 1 begins, and an unset Harlowe variable is
the number 0 - concatenating it would raise a type error, so guard on type. -->
(if: $caseName is a string)[(print: "<span style='color: #888; font-weight: bold; white-space: nowrap;'>" + $caseName + " / 3</span>")]
</div>
<div class="hud-item left-item">
<!-- margin-right removed: .hud-item already supplies the gap, and the two together
were double-spacing the label from the bar on a screen with none to spare. -->
<span style="color: #00FF00; font-size: 0.9em;">STABILITY</span>
<!-- Kept on ONE line deliberately: Harlowe renders every newline inside a printed
string as a <br>, and the multi-line version of this emitted four of them, which
made this cell 48px tall inside a 60px strip. -->
(print: "<div class='hud-track'><div class='hud-fill' style='width: " + (str: $stability) + "%; background-color: " + $stabColor + ";'></div></div>")
</div>
<div class="hud-item center-item">
(print: "<span style='color: " + $repColor + "; font-weight: bold;'>REP: " + (str: $cred) + "%</span>")
</div>
<div class="hud-item right-item">
(print: "<span style='color: " + $costColor + "; font-weight: bold;'>COST: " + $costDisplay + "</span>")
</div>
</div>
}{
(set: $partA to (random: 10000, 99999))
(set: $partB to (random: 10000, 99999))
(set: $sessionID to (str: $partA) + (str: $partB))
(set: $playerID to "loadingid")
(set: $c1_rep to 0, $c1_stab to 0, $c1_cost to 0)
(set: $c2_rep to 0, $c2_stab to 0, $c2_cost to 0)
(set: $c3_rep to 0, $c3_stab to 0, $c3_cost to 0)
<!-- 9A-1 SEVERITY -> DELTA RUBRIC (for maintainers). Keep new penalties on this
scale so equivalent errors score equivalently across the three cases:
Excellent judgment stability +10 cred +10..+15 (asterixis exam; early tap)
Minor inefficiency stability -5 cred -5 (pip-tazo over ceftriaxone)
Wrong but recoverable stability -10 cred -10 (vancomycin for SBP)
Time-critical delay stability -15 cred -10 (forgot octreotide/abx)
Trap fallen for stability -20 cred -15 (ammonia trap; NS bolus)
Critical miss stability -20 cred -25 (missed paracentesis)
Near-miss harm stability -30 cred -20 (premature intubation)
Stability RE-INITIALISES per case (100 for Cases 1-2, 90 for Case 3) and every
within-case mutation is relative, so errors compound. Reputation CARRIES across
cases with a soft floor of 45 - see 9A-2. -->
(set: $stability to 100)
(set: $cred to 70)
(set: $cost to 0)
<!-- 9C-2: $optimalCost is the UNAVOIDABLE spend for a correct run of this case.
Cost is tiered on EXCESS over this figure, never on raw $cost - the three cases
have very different floors (C1 $0, C2 ~$1,700, C3 ~$1,250), so a flat threshold
marked a PERFECT Case 2 run as yellow. Recalibrate if 9C-1 costs change. -->
(set: $optimalCost to 0)
(set: $stabColor to "#00FF00")
(set: $exam_count to 0)
(set: $checkedPupils to false)
(set: $checkedAsterixis to false)
(set: $checkedAbdomen to false)
(set: $checkedPanic to false, $checkedHE to false, $checkedMeningitis to false)
(set: $choice to "None")
(set: $consent to true)
(set: $abx to "None")
(set: $diagnosisErrors to 0)
(set: $statusAlertShown to false)
(set: $transcript to "Simulation Started. ")
(set: $studentReasoning to "")
(set: $turnCount to 0)
(set: $outcome to "Ongoing")
(set: $failReason to "None")
<!-- D101(c): recomputed from scratch in Case3Summary; initialised here so a replay in
the same tab cannot inherit the previous run's gating. -->
(set: $careErrors to 0)
<!-- Per-case uncorrected-error counts, read by the HONORS gate in GameConclusion. Each
case summary recomputes its own; initialised here so a replay in the same tab
cannot inherit the previous run's. -->
(set: $c1CareErrors to 0)
(set: $c2CareErrors to 0)
(set: $c3CareErrors to 0)
(set: $careLead to "")
(set: $careNote to "")
(set: $careTail to "")
}
<script>
const WORKER_URL = "https://hepatology-backend.f-pradhan.workers.dev";
// Initialize Global Variable (Safe for Harlowe)
window.playerID = "loadingid";
// D57 follow-up: the board-review escape hatch keys are written to sessionStorage so
// they survive a mid-review reload, but nothing ever cleared them. A second run in
// the same tab therefore inherited the first run's five-minute clock and consecutive
// failure count, so the hatch could appear on question 1 - which is exactly the
// opt-out the five-question requirement exists to remove. Init runs ONCE per story
// start and is NOT re-run on a mid-session reload (see D16), so clearing here resets
// the clock for a genuinely new attempt while still surviving a reload mid-review.
try {
sessionStorage.removeItem("hns_board_start");
sessionStorage.removeItem("hns_board_fails");
// D81: the persisted board score and the sent-marker belong to one attempt only.
// Init runs once per story start and NOT on a mid-session reload, so clearing here
// resets a genuinely new run without disturbing recovery after a reload.
sessionStorage.removeItem("hns_board_correct");
sessionStorage.removeItem("hns_board_total");
sessionStorage.removeItem("hns_board_sent");
// PRELOAD: warmed questions belong to one attempt. Without this a replay in the
// same tab would serve the previous run's questions - stale, and already seen.
for (var _i = sessionStorage.length - 1; _i >= 0; _i--) {
var _k = sessionStorage.key(_i);
if (_k && (_k.indexOf("hns_bq_") === 0 || _k.indexOf("hns_fb_") === 0)) { sessionStorage.removeItem(_k); }
}
} catch(e) {}
// D83: the only one of the four Worker calls without a deadline. A HANG settles
// neither branch, so window.playerID stays "loadingid" forever AND the .catch below -
// the one that assigns the distinguishable guest_NNNN id - never runs. Every row in
// that session then posts "guest_unknown", which collapses separate failed sessions
// into one indistinguishable value. Same 45s AbortController that runAI(),
// generateLetter() and fetchQuestion() already use. An abort REJECTS the promise, so
// it routes into the existing .catch - no new behaviour, just a new way to reach it.
const idController = new AbortController();
const idTimeout = setTimeout(() => idController.abort(), 45000);
fetch(WORKER_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "identify" }),
signal: idController.signal
})
.then(response => {
// The response was previously parsed without checking status. A Worker error -
// most commonly a missing PLAYER_ID_SALT, which makes identify throw - still
// returns JSON, so data.playerID came back undefined, window.playerID was set to
// undefined, and every Form row was written "guest_unknown" while the catch
// block that exists for exactly this never ran.
if (!response.ok) { throw new Error("identify HTTP " + response.status); }
return response.json();
})
.then(data => {
const id = data && typeof data.playerID === "string" ? data.playerID.trim() : "";
if (!id) { throw new Error("identify returned no playerID: " + JSON.stringify(data).slice(0, 120)); }
// Save to WINDOW, bypassing Harlowe/Twine variable issues
window.playerID = id;
// D16: Init runs ONCE. On a mid-session reload Harlowe restores the current passage
// without re-running Init, so window.playerID was lost and every later Form row was
// written as "guest_unknown". sessionStorage survives the reload.
try { sessionStorage.setItem("hns_playerid", id); } catch(e) {}
console.log("Player Identified:", id);
})
.catch(err => {
// A distinguishable id, so a failed identify is visible in the data as its own
// category rather than blending into the generic fallback.
console.error("ID Check Failed:", err);
window.playerID = "guest_" + Math.floor(Math.random() * 10000);
try { sessionStorage.setItem("hns_playerid", window.playerID); } catch(e) {}
})
.finally(() => { clearTimeout(idTimeout); });
</script>{<div style="max-width: 620px; margin: 0 auto; padding: 24px; background: #1a1a1a; border: 1px solid #333; border-radius: 8px;">
<h2 style="color: #00BFFF; text-align: center; margin: 0 0 20px 0;">About this activity</h2>
<p style="color: #ddd; line-height: 1.7;">
This is a voluntary educational <strong>quality improvement</strong> activity. As a
quality improvement project at UNMC, it did not require Institutional Review Board
review. At other organizations, Institutional Review Board approval was obtained
where required.
</p>
<p style="color: #ddd; line-height: 1.7;">
<strong>Participation is entirely voluntary.</strong>
</p>
<p style="color: #ddd; line-height: 1.7;">
Your responses are <strong>confidential and de-identified</strong>.
</p>
<div style="text-align: center; margin-top: 26px;">
[[Begin ->Demo]]
</div>
</div>}{
(set: $conf_assess_pre to "Select...")(set: $conf_assess_post to "Select...")
(set: $conf_bleed_pre to "Select...")(set: $conf_bleed_post to "Select...")
(set: $conf_sbp_pre to "Select...")(set: $conf_sbp_post to "Select...")
}
{<div style="max-width: 700px; margin: 0 auto; padding: 20px; background: #1a1a1a; border: 1px solid #333; border-radius: 8px;">
<!-- D104: this heading used to read "before you see your results", which was true of
ONE of the three rows. The grid sat between Case3Outcome and Case3Summary, so
Cases 1 and 2 had already been summarised, scored and fed back on, while Case 3 had
not - three items rated under two different conditions inside one instrument, and
not comparable even within a single learner. Moved to after Case 3's SubmitData:
all three rows are now rated after that case's summary, feedback and score.
Uniformly post-feedback is analysable; unevenly post-feedback is not. -->
<h3 style="color: #00BFFF; margin: 0 0 12px 0;">Quick self-check — before the board questions.</h3>
<p style="color: #ccc; line-height: 1.6; font-size: 0.95em;">
For each case, rate your confidence
<strong>before you started this module</strong> and <strong>now</strong>.
</p>
{<div class="conf-grid">
<div class="conf-row conf-head">
<div class="conf-label">Clinical Case</div>
<div class="conf-cell conf-cell-before">Before</div>
<div class="conf-cell">Now</div>
</div>
<div class="conf-row">
<div class="conf-label">Differentiating Alcohol Withdrawal from Hepatic Encephalopathy</div>
<div class="conf-cell conf-cell-before">
<span class="conf-tag">Before</span>
(dropdown: bind $conf_assess_pre, "Select...", "1", "2", "3", "4", "5")
</div>
<div class="conf-cell">
<span class="conf-tag">Now</span>
(dropdown: bind $conf_assess_post, "Select...", "1", "2", "3", "4", "5")
</div>
</div>
<div class="conf-row">
<div class="conf-label">Managing Variceal Hemorrhage</div>
<div class="conf-cell conf-cell-before">
<span class="conf-tag">Before</span>
(dropdown: bind $conf_bleed_pre, "Select...", "1", "2", "3", "4", "5")
</div>
<div class="conf-cell">
<span class="conf-tag">Now</span>
(dropdown: bind $conf_bleed_post, "Select...", "1", "2", "3", "4", "5")
</div>
</div>
<div class="conf-row">
<div class="conf-label">Diagnosing SBP & AKI</div>
<div class="conf-cell conf-cell-before">
<span class="conf-tag">Before</span>
(dropdown: bind $conf_sbp_pre, "Select...", "1", "2", "3", "4", "5")
</div>
<div class="conf-cell">
<span class="conf-tag">Now</span>
(dropdown: bind $conf_sbp_post, "Select...", "1", "2", "3", "4", "5")
</div>
</div>
<div class="conf-legend">1 = Not at all confident · 5 = Very confident</div>
</div>}
<div style="text-align: center; margin-top: 22px;">
{(live: 0.4s)[
(if: $conf_assess_pre is not "Select..." and $conf_assess_post is not "Select..."
and $conf_bleed_pre is not "Select..." and $conf_bleed_post is not "Select..."
and $conf_sbp_pre is not "Select..." and $conf_sbp_post is not "Select...")[
[[Continue ->BoardReview]]
(stop:)
]
(else:)[
<span style="color: #888; font-size: 0.9em;">Answer all six items to continue.</span>
]
]}
</div>
</div>}{<script>
window.startTime = Date.now();
</script>}
<h1 style="color: #00FF00; text-align: center; margin: 0 0 22px 0; font-size: 2em; line-height: 1.2; letter-spacing: 1px;">Hepatology Night Shift</h1>
Welcome to this "choose your own adventure" style game to simulate three common clinical scenarios with the Hepatology theme.
In this game, you are the resident on call for a night shift at a busy hospital and you get to make key decisions that affect your patient's stability, while tracking your 'reputation' and the cost of your decisions.
[[Let's get started|StudyInfo]]
{
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #333; font-size: 0.85em; color: #888; line-height: 1.6;">
<strong>Hepatology Night Shift</strong>
<br>
Created by Dr. Faruq Pradhan, Transplant Hepatologist.
<br>
CC BY-NC-SA
<a href="mailto:liver@fpradhan.com" style="color: #00BFFF; text-decoration: none; transition: color 0.2s;">
<i class="fa-solid fa-envelope"></i> CONTACT
</a>
<br><br>
<em style="font-size: 0.9em; color: #666;">This simulation is for educational purposes only and does not constitute medical advice.</em>
</div>}{
(set: $caseName to "Case 1")
(set: $choice to "BetaBlocker")
(set: $stability to (max: 0, $stability - 25))
(set: $cred to (max: 0, $cred - 15))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Beta-Blockers (Error) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
}
<h4>BETA-BLOCKER ADMINISTERED</h4>
"Let's give him metoprolol 5mg IV," you say. "We need to get that heart rate down."
The nurse pushes the drug. You watch the monitor.
<br>
<strong>Heart Rate:</strong> Drops from 135 → 85 bpm.
<br>
<strong>Blood Pressure:</strong> Drops from 160/90 → 120/80 mmHg.
<br>
You let out a breath of relief. The numbers look perfect.
<br>
<div style="border-left: 4px solid #FFCC00; background: rgba(255, 204, 0, 0.1); padding: 10px;">
<strong>"Doctor, he's not calming down!"</strong>
<br>
While the monitor looks better, Mr. Ess looks <em>worse</em>. The beta-blocker has blunted his autonomic response (HR/BP), masking the severity of the withdrawal.
<br>
Inside his brain, the <strong>glutamate storm</strong> is raging unchecked.
</div>
Suddenly, Mr. Ess rips off his leads. "Get them off me!" he screams, staring wildly at the empty corner of the room. He swings his fist at the nurse.
<br>
[[Call Security! ->Seizure]]{(set: $stability to (max: 0, $stability - 20))
(set: $cred to (max: 0, $cred - 20))
(set: $cost to $cost + 600)
(set: $caseName to "Case 1")
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Security Called (Combative Patient) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
<h2 style="color: red;">SECURITY CALLED</h2>
}
**"Security to Bay 4!"**
Four guards arrive and physically restrain him to the bed. It is a chaotic, ugly scene. The patient fights against the leather straps, risking self-injury.
The nurse looks at you, breathless. "He's going to hurt himself or us if we don't get things better soon. We need to **treat the cause for his agitation**! We still have IV access for now."
(if: $cred < 40)[
<div style="border: 1px solid #FF0000; background-color: rgba(255, 0, 0, 0.1); padding: 10px; margin-bottom: 15px; border-radius: 5px;">
<strong style="color: #FF0000">STATUS CHANGE: MICRO-MANAGED</strong>
<br>
"Doctor, things should have never escalated to this point," the Charge Nurse says, stepping close to you. I will be speaking to your attending and filing an incident report."
<br>
</div>
]
What is your next action? Focus on treating the **underlying cause** for the agitation.
|choices>[
{
(link: "Give lorazepam 4mg IV")[
(set: $stability to (max: 0, $stability - 10))
(set: $cred to (min: 100, $cred + 10))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Administered Lorazepam (Correct Sedation) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(replace: ?choices)[
<h3 style="color: #00FF00">Sedation Successful</h3>
You administer the benzodiazepine. Within minutes, the patient's thrashing stops. His muscles relax, and his heart rate trends down from 140 to 110.
<br><br>
"I'm glad that worked," the nurse says, wiping her forehead. "He must have been in alcohol withdrawal."
<br><br>
[[Finish Case ->Case1Success]]
]
]
(link: "Give haloperidol 5mg IM")[
(set: $choice to "haloperidol")
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Haloperidol (Lowered Seizure Threshold, incorrect choice) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(goto: "Seizure")
]
(link: "Paralyze and Intubate")[
(set: $cost to $cost + 400)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Intubated for Agitation Control | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(set: $failReason to "Intubated for agitation")
(replace: ?choices)[
<h3 style="color: #FF0000">Airway Secured</h3>
You decide not to take any chances with the airway. You order etomidate and succinylcholine.
The patient is intubated without complication, but he is now on a ventilator and requires a heavily sedated ICU admission.
**Cost Impact:** Significant increase due to mechanical ventilation.
[[Finish Case ->Case1Success]]
]
]
}
]{
(set: $stability to (max: 0, $stability - 40))
(set: $cred to (max: 0, $cred - 25))
(set: $cost to $cost + 900)
(set: $diagnosisErrors to 5)
(set: $turnCount to $turnCount + 1)
(set: $failReason to "Seizure")
}
Mr. Ess's eyes roll back. His entire body goes rigid (tonic phase), followed by violent rhythmic jerking (clonic phase).
"He's seizing!" the nurse yells. "Call the rapid response team!"
<div class="critical-damage">CRITICAL ALERT: RAPID RESPONSE ACTIVATED</div>
{
(if: $cred < 40)[
<div style="border: 1px solid #FF0000; background-color: rgba(255, 0, 0, 0.1); padding: 10px; margin-bottom: 15px; border-radius: 5px;">
<br>
The rapid response team rushes into the room. The attending pushes past you to the head of the bed.
<br><br>
"I'm now running this situation." she shouts. "You—stand back and tell me what happened."
<br><br>
<em>You have lost control of the case.</em>
</div>
]
}
{<!-- This passage always consumes a turn at the top, but only the two drug-precipitated
routes below used to write a transcript record. Learners who reached the seizure any
other way (wrong-diagnosis escalation, security call) had the single most important
adverse event in Case 1 missing from their log, plus an unexplained -40 stability /
-25 cred / +900 cost step. The (else:) records it unconditionally and prints nothing,
so the passage reads the same to the learner. Keep the (else:) hook directly after the
preceding hook - Harlowe only allows whitespace between them, not a comment. -->
(if: $choice is "haloperidol")[
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Seizure (Precipitated by incorrect medication choice of haloperidol) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
<span style="color: #ff4444; font-style: italic;">Haloperidol lowers the seizure threshold—this could have been prevented.</span>
<br><br>
]
(else-if: $choice is "BetaBlocker")[
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Seizure (Precipitated by incorrect medication choice of Beta Blockers) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
<span style="color: #ff4444; font-style: italic;">The beta-blockers masked the warning signs until the withdrawal became catastrophic.</span>
<br><br>
]
(else:)[
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Seizure (Untreated alcohol withdrawal - diagnostic delay) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
]
}
{
(if: $cred >= 40)[
The patient is seizing because of alcohol withdrawal. You know you must stop the seizure with lorazepam, which will also treat the withdrawal.
<br><br>
[[Administer lorazepam 4mg IV ->Case1Success]]
](else:)[
Your Attending yells orders over the noise. "Push 4mg lorazepam now! We need to call Pharmacy for levetiracetam just in case the lorazepam isn't enough."
<br><br>
You stand against the wall, watching the team work to stabilize the patient.
<br><br>
[[Observe Stabilization ->Case1Success]]
]
}{(set: $caseName to "Case 1")}
Time: 6:00 PM
Location: Resident Lounge
<h2 style="color: #ffcc00;"><i class="fa-solid fa-pager"></i> INCOMING PAGE</h2>
Your pager buzzes. It is the Emergency Department.
ED Resident: "Hey, I have Mr Ess, a 55-year-old man with cirrhosis who continues to drink alcohol here in bed 4. He's acting bizarre, agitated, and combative. I checked an ammonia level and it's 95 (high). I think he has hepatic encephalopathy. I'm planning on admitting him to medicine if you think that's appropriate."
What is your initial move?
<br>
{[[Treat him for hepatic encephalopathy with lactulose/rifaximin, then call me back.|TrapAmmonia]]
[[Let me come down and see him first.|Bedside]]
[[Get a CT Head first to rule out a bleed or other cause for confusion first, then call me back.|Defensive]]}{
(if: $caseName is "Case 1")[
(set: $c1_rep to $cred)
(set: $c1_stab to $stability)
(set: $c1_cost to $cost)
]
(else-if: $caseName is "Case 2")[
(set: $c2_rep to $cred)
(set: $c2_stab to $stability)
(set: $c2_cost to $cost)
]
(else-if: $caseName is "Case 3")[
(set: $c3_rep to $cred)
(set: $c3_stab to $stability)
(set: $c3_cost to $cost)
]
<!-- B1 / D5 follow-up: derived here, ABOVE the $outcome branch, and for every Case 2
row regardless of outcome. D6/9H made this history-keyed but left it inside the
(else:) of the $outcome check, which a death always skips - so a learner who
intubated early and DIED for it, the single row the measure exists to describe,
recorded "N/A". Wiring the field to the Form made that visible: 11 of 24 Case 2
rows in a 14-run stress batch came back N/A, and every one of them had
failReason PrematureIntubation. -->
(if: $caseName is "Case 2")[
(set: $didIntubateEarly to (cond: (history:) contains "Case2Airway", "Yes", "No"))
(set: $didDelayMeds to (cond: $meds is true, "No", "Yes"))
]
(else:)[
(set: $didIntubateEarly to "N/A")
(set: $didDelayMeds to "N/A")
]
(if: $outcome is 0)[ (set: $outcome to "Success") ]
(if: $failReason is 0)[ (set: $failReason to "None") ]
(if: $playerID is 0)[ (set: $playerID to "guest_error") ]
(if: $abx is 0)[ (set: $abx to "None") ]
(if: $outcome is "Death")[
(set: $finalDecision to "DIED: " + $failReason)
(set: $dataMetric1 to "Critical Fail")
]
(else-if: $outcome is "AttendingTakeover")[
(set: $finalDecision to "INTERVENTION: " + $failReason)
(set: $dataMetric1 to "Needs Remediation")
]
(else:)[
(if: $caseName is "Case 1")[
(set: $finalDecision to $choice)
(set: $dataMetric1 to $diagnosisErrors)
]
(else-if: $caseName is "Case 2")[
<!-- D6 / 9H: keyed off the history, not $status is "Dead". The old "Yes" assignment
sat inside the (else:) of the $outcome check, which a death always skips - so it
could never fire, and a learner who intubated prematurely but SURVIVED (via
Case2Abort) was recorded "No". -->
(if: $status is "Dead")[
(set: $finalDecision to "DIED: Premature Intubation")
(set: $dataMetric1 to "Failed")
]
(else:)[
(set: $finalDecision to $abx)
(set: $dataMetric1 to (cond: $meds is true, "Immediate", "Delayed/Missed"))
]
]
(else:)[
(set: $finalDecision to $abx)
(set: $dataMetric1 to "Correct Workup")
(if: $case3Retry is true)[ (set: $dataMetric1 to "Retried: Missed Para") ]
]
]
}
{
<div id="hidden-data" style="display:none;">
<span id="d-transcript">$transcript</span>
<span id="d-stability">$stability</span>
<span id="d-cred">$cred</span>
<span id="d-cost">$cost</span>
<span id="d-caseName">$caseName</span>
<span id="d-sessionID">$sessionID</span>
<span id="d-failReason">$failReason</span>
<!-- B1 / D5: computed in Case2Start, Case2EndoPrep and the block at the top of this
passage, but until now they had nowhere to go. Both are already normalised to
Yes / No / N-A by the time this renders, N-A being every non-Case-2 row. -->
<span id="d-intubateEarly">$didIntubateEarly</span>
<span id="d-delayMeds">$didDelayMeds</span>
</div>
<div id="loading-state" style="text-align: center; padding-top: 50px; color: #888;">
<div style="font-size: 1.5em; margin-bottom: 20px; color: #00BFFF;">
<i class="fa-solid fa-user-doctor"></i> Dr. Sato is reviewing your case...
</div>
<div style="display: inline-block; width: 40px; height: 40px; border: 4px solid rgba(0, 191, 255, 0.3); border-radius: 50%; border-top-color: #00BFFF; animation: spin 1s ease-in-out infinite; margin-top: 20px;"></div>
<style>@keyframes spin { to { transform: rotate(360deg); } }</style>
<br><br>
<div id="error-box" style="display:none; margin-top:20px; padding:15px; background:#330000; border:1px solid #ff4444; color:#ffaaaa; max-width:500px; margin-left:auto; margin-right:auto; border-radius: 5px;">
<strong id="error-title">Feedback unavailable</strong><br>
<span id="error-details">Your case data was still saved.</span><br><br>
<span style="color:#ccc; font-size:0.8em;">Please proceed to next case.</span>
</div>
</div>
<div id="results-container" style="display: none;">
<h2 style="text-align: center; color: #fff;">ATTENDING EVALUATION</h2>
<div style="background-color: #222; padding: 25px; border-radius: 8px; border-left: 5px solid #00BFFF; margin-bottom: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.3);">
<strong style="color: #00BFFF; font-size: 1.2em; display: block; margin-bottom: 10px;">
<i class="fa-solid fa-comment-medical"></i> Dr. Sato's Feedback:
</strong>
<p id="ai-feedback-text" style="line-height: 1.6; font-size: 1.05em; color: #eee;"></p>
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #444; font-size: 0.9em; color: #aaa;">
<strong>Your Reasoning:</strong><br>
"<span id="display-student-reasoning" style="font-style: italic; color: #FFCC00;"></span>"
</div>
</div>
<div style="display: flex; gap: 15px; margin-bottom: 30px; justify-content: center; flex-wrap: wrap;">
<div style="background: #1a1a1a; padding: 15px; border-radius: 5px; text-align: center; border: 1px solid #333; min-width: 140px;">
<div style="color: #aaa; font-size: 0.8em; margin-bottom: 5px;">REASONING SCORE</div>
<span id="score-reasoning" style="font-size: 2.2em; font-weight: bold; color: #fff;">-</span><span style="font-size: 1.5em; color: #555;">/5</span>
</div>
<div style="background: #1a1a1a; padding: 15px; border-radius: 5px; text-align: center; border: 1px solid #333; min-width: 140px;">
<div style="color: #aaa; font-size: 0.8em; margin-bottom: 5px;">FINAL STABILITY</div>
<strong id="display-stability" style="font-size: 2.2em; color: #fff;">(print: $stability)%</strong>
</div>
<div style="background: #1a1a1a; padding: 15px; border-radius: 5px; text-align: center; border: 1px solid #333; min-width: 140px;">
<div style="color: #aaa; font-size: 0.8em; margin-bottom: 5px;">REPUTATION</div>
<strong id="display-cred" style="font-size: 2.2em; color: #fff;">(print: $cred)%</strong>
</div>
</div>
<div style="text-align: center;">
(if: $caseName is "Case 1")[
[[<i class="fa-solid fa-coffee"></i> Take a Break (Proceed to Case 2) ->CoffeeBreak]]
]
(else-if: $caseName is "Case 2")[
(if: $status is "Dead" or $outcome is "Death")[
[[<i class="fa-solid fa-rotate-left"></i> Patient Expired. Try Again. ->Case2Start]]
]
(else:)[
[[<i class="fa-solid fa-coffee"></i> Shift Change (Proceed to Case 3) ->CoffeeBreak2]]
]
]
(else:)[
<!-- PRELOAD-1: the Case 3 results screen is the only route into board review.
Preparing the topics here rather than on arrival lets question 1 be generated
while the learner reads Dr. Sato's feedback - measured at ~9.5s to generate,
against a screen people spend considerably longer on. -->
(display: "BoardTopics")
<span id="pre-board-topic" style="display:none;">$firstTopic</span>
<span id="pre-board-session" style="display:none;">$sessionID</span>
<!-- D104: this link now goes to ConfidenceCheck, which continues to BoardReview.
The confidence grid moved here so all three of its rows are rated under the
same conditions - see the note on ConfidenceCheck itself. The BoardTopics
warm above is unaffected and in fact gains the ~1 minute the grid takes. -->
[[<i class="fa-solid fa-flag-checkered"></i> 📝 Adaptive USMLE-Style Board Exam Questions ->ConfidenceCheck]]
]
</div>
</div>
<script>
// ================= CONFIGURATION =================
const FORM_URL = "https://docs.google.com/forms/d/e/1FAIpQLSfjS-eNL1lzxO_zoFtRCYoaxzFKeO9MR_3amvKLDx5H8gui2A/formResponse";
const ENTRY_SESSION = "entry.1983371625";
const ENTRY_CASE = "entry.2131634372";
const ENTRY_STABILITY = "entry.1048306896";
const ENTRY_CRED = "entry.1221637260";
const ENTRY_COST = "entry.1523741928";
const ENTRY_TRANSCRIPT = "entry.1945997268";
const ENTRY_REASONING_TXT = "entry.1964375275";
const ENTRY_REASONING_SCORE = "entry.1996926996";
const ENTRY_FAIL_REASON = "entry.877931387";
const ENTRY_PLAYER_ID = "entry.919823415";
const ENTRY_DEMO_GENDER = "entry.883460807";
const ENTRY_DEMO_STATE = "entry.801180899";
const ENTRY_DEMO_YEAR = "entry.1154694716";
const ENTRY_DEMO_ROTATIONS = "entry.1976986987";
const ENTRY_DEMO_SPECIALTY = "entry.1989326819";
// B1 / D5 and B2 / D21. IDs supplied by the author from the prefilled-link URL for the
// same form the three ENTRY blocks above already post to (1FAIpQLSfjS-...).
const ENTRY_INTUBATE_EARLY = "entry.1788204165";
const ENTRY_DELAY_MEDS = "entry.270996122";
const ENTRY_SCORING_MODEL = "entry.251942890";
// =================================================
// UX-05: scroll to top on entry. Previously this also focused a hidden
// ghost input, which stole focus from the page for no user-visible benefit.
setTimeout(function() { window.scrollTo(0, 0); }, 100);
// D84(b) / D19: these six reads sit OUTSIDE any try, at the very top of the script, so
// one missing node killed the script before window.runAI was even defined - no feedback,
// no Form row, and a permanent "Analyzing..." spinner. GameConclusion was hardened this
// way after exactly that (its own perpetual evaluation spinner); this passage was not,
// and it is the passage that writes the case row.
function readEl(id) {
const el = document.getElementById(id);
if (!el) { console.warn("SubmitData: missing element #" + id); return ""; }
return el.innerText;
}
const transcript = readEl("d-transcript");
const stability = readEl("d-stability");
const cred = readEl("d-cred");
const cost = readEl("d-cost");
// These two are join keys, so a blank would be an unjoinable row. The sentinels are
// self-describing and greppable, and reuse the string SubmitBoardData already uses for
// the same field rather than inventing a second convention.
const caseName = readEl("d-caseName") || "error_no_case";
const sessionID = readEl("d-sessionID") || "error_no_session";
// B1 / D5. readEl returns "" for a missing node (D84), which is the right value here:
// a blank drops out of analysis on its own, whereas a fabricated "No" would read as a
// learner who did not intubate early.
const intubateEarly = readEl("d-intubateEarly");
const delayMeds = readEl("d-delayMeds");
// D16 (completed): _storedPid was read here and then never used - the sessionStorage
// fallback was wired into SubmitBoardData but not into this passage, so case rows
// could not recover the id after a reload or a slow identify call. "undefined" is
// screened as a string too, because a failed identify used to store it literally.
let playerID = "guest_unknown";
let _storedPid = "";
try { _storedPid = sessionStorage.getItem("hns_playerid") || ""; } catch(e) {}
const _validPid = v => typeof v === "string" && v && v !== "loadingid" && v !== "loadingID" && v !== "undefined";
if (_validPid(window.playerID)) {
playerID = window.playerID;
} else if (_validPid(_storedPid)) {
playerID = _storedPid;
}
let failReason = "None";
if(document.getElementById("d-failReason")) {
failReason = document.getElementById("d-failReason").innerText;
}
// 9H-1 / DATA-6: read the durable copies FIRST. window.studentReasoningText is wiped
// by a page reload, and the old fallback silently wrote "No reasoning provided by
// student." to the Form AND sent it to Gemini as if it were the answer.
// PRELOAD-3: resolved by the shared helper in the story script, so the warm on the case
// summary screen and this passage derive byte-identical strings. If they diverged the
// cache key would simply miss and a live call would be made - correct, just slower.
const _r = window.hnsResolveReasoning();
let studentReasoning = _r.studentReasoning;
const promptReasoning = _r.promptReasoning;
// D84(b): same reason as the six reads above - this line is outside any try.
const _srEl = document.getElementById("display-student-reasoning");
if (_srEl) { _srEl.innerText = studentReasoning; }
// T2 failure sentinel. Written to the reasoning_score Form field when the AI call
// fails or returns an unusable value. An empty string is used rather than 0 or -1 so
// the column stays numeric-or-blank: blanks drop out of analysis naturally, whereas a
// 0 or -1 silently biases every mean downward. Exclude blanks; never treat as a score.
const SCORE_SENTINEL = "";
const systemPrompt = window.hnsFeedbackPrompt(caseName, transcript, promptReasoning);
window.runAI = async function() {
const errorBox = document.getElementById("error-box");
if(errorBox) errorBox.style.display = "none";
try {
// PRELOAD-3: one shared implementation, so a warmed reply and a live one cannot
// come from different prompts. Returns a cached reply when the case, transcript and
// reasoning are identical to the warm; joins an in-flight warm rather than issuing a
// second call; otherwise behaves exactly as the direct fetch did, 45s abort included.
const data = await window.hnsFeedback({
caseName: caseName,
transcript: transcript,
stability: stability,
failReason: failReason,
sessionID: sessionID
});
if (!data.candidates || data.candidates.length === 0) {
throw new Error("AI returned empty response. Check API Quotas.");
}
let rawText = data.candidates[0].content.parts[0].text;
rawText = rawText.replace(/```json/g, "").replace(/```/g, "").trim();
const aiResult = JSON.parse(rawText);
// T3 clamp + validate. reasoning_score is a 1-5 ordinal; anything outside that
// range ("4/5", "four", 7, NaN) must NOT be stored as a score. Un-clampable values
// become the AI_ERROR sentinel, never 0 - see T2.
let score = parseInt(aiResult.reasoning_score, 10);
if (isNaN(score) || score < 1 || score > 5) { score = SCORE_SENTINEL; }
// B2 / D21 / W2: the Worker returns the model that actually produced this score, so
// a reasoning_score stays attributable to the instrument that generated it. Only the
// success path knows it - the catch below sends "" rather than a guess, so a blank
// means "no scoring call completed" and never names the wrong model.
const scoringModel = (data && typeof data.scoring_model === "string")
? data.scoring_model : "";
// D84(a): write the row BEFORE touching the DOM. updateDisplay() writes six nodes
// with no null check and used to run first, so one missing node threw and the Form
// row was never written - on the SUCCESS path as much as the failure path.
// submitData() reads only aiScore, so the order is free to swap.
submitData(score, scoringModel);
updateDisplay(score, aiResult.feedback);
} catch (error) {
console.error("AI Error:", error);
// D84(a): and here it matters more. updateDisplay() is called again from inside this
// catch, so a DOM fault threw straight back out of the catch and skipped the line
// below - the one line that exists to save the row when the AI call fails.
// T2: a connection/parse failure is NOT a score of 0. Storing 0 made failures
// indistinguishable from genuine low scores and silently depressed every mean.
submitData(SCORE_SENTINEL, "");
updateDisplay(SCORE_SENTINEL, "Dr. Sato is currently in a procedure and cannot provide detailed feedback right now. However, your clinical data has been recorded.");
if(errorBox) {
errorBox.style.display = "block";
// Surface the real reason. This box only appears when feedback has ALREADY
// failed, and a fixed "Connection Issue" string made every cause look the
// same - a dead model name, an HTTP 400 from the Worker and a genuinely
// dropped connection were indistinguishable, so a failure seen on a live
// run could not be diagnosed afterwards.
const detail = String(error && error.message ? error.message : error);
const detailEl = document.getElementById("error-details");
if (detailEl) {
detailEl.innerText = "Your case data was still saved. Reason: " + detail;
}
}
}
};
// D84(c): every node guarded. With D84(a) putting submitData() first this can no longer
// cost the Form row, so what it buys now is losing ONE field instead of the whole
// results panel - including the two display:none flips at the end, which are what
// actually reveal the panel to the learner.
function updateDisplay(score, feedback) {
const scoreSpan = document.getElementById("score-reasoning");
// T3: show the sentinel as "N/A", never as 0 - a learner should not read a
// system failure as a score of zero.
if (scoreSpan) {
scoreSpan.innerText = (score === SCORE_SENTINEL || score === null || score === undefined)
? "N/A" : score;
if(score >= 4) scoreSpan.style.color = "#00FF00";
else if (score >= 2) scoreSpan.style.color = "#FFCC00";
else scoreSpan.style.color = "#FF4444";
}
const stabVal = parseInt(stability) || 0;
const stabSpan = document.getElementById("display-stability");
if (stabSpan) {
if(stabVal >= 80) stabSpan.style.color = "#00FF99";
else if (stabVal >= 50) stabSpan.style.color = "#FFCC00";
else stabSpan.style.color = "#FF4444";
}
const credVal = parseInt(cred) || 0;
const credSpan = document.getElementById("display-cred");
if (credSpan) {
if(credVal >= 80) credSpan.style.color = "#00BFFF";
else if (credVal >= 50) credSpan.style.color = "#FFCC00";
else credSpan.style.color = "#FF4444";
}
const fbEl = document.getElementById("ai-feedback-text");
if (fbEl) { fbEl.innerText = feedback; }
const loadEl = document.getElementById("loading-state");
if (loadEl) { loadEl.style.display = "none"; }
const resEl = document.getElementById("results-container");
if (resEl) { resEl.style.display = "block"; }
}
let dataSubmitted = false;
function submitData(aiScore, scoringModel) {
// S5 double-submit guard: submitData() is called from both runAI()'s success and
// catch paths, so a single visit could otherwise POST the same row twice.
// NOTE: this flag is scoped to the script, which Harlowe re-executes on every
// entry to SubmitData. That is deliberate - a Case 2 death followed by a retry
// MUST still produce two Form rows, so the guard must not persist across visits.
if (dataSubmitted) { return; }
dataSubmitted = true;
// D75: the guard above is script-scoped and Harlowe re-executes this script on a page
// reload, so a reload re-POSTed the whole row - 17 byte-identical duplicates across 13
// sessions in the live sheet, one of them the same Case 1 row three times. The guard
// must NOT block a Case 2 death-and-retry, which has to produce a second row, so key on
// the transcript: a genuine retry has a different one, an identical re-send does not.
let _sentKey = "";
try {
let _h = 0, _src = String(caseName) + "|" + String(transcript);
for (let i = 0; i < _src.length; i++) { _h = ((_h << 5) - _h + _src.charCodeAt(i)) | 0; }
_sentKey = "hns_sent_" + sessionID + "_" + caseName + "_" + (_h >>> 0);
if (sessionStorage.getItem(_sentKey)) {
console.warn("Duplicate submission suppressed for " + caseName + " (reload).");
return;
}
} catch (e) { _sentKey = ""; }
const formData = new FormData();
formData.append(ENTRY_SESSION, sessionID);
formData.append(ENTRY_CASE, caseName);
formData.append(ENTRY_STABILITY, stability);
formData.append(ENTRY_CRED, cred);
formData.append(ENTRY_COST, cost);
formData.append(ENTRY_TRANSCRIPT, transcript);
formData.append(ENTRY_REASONING_TXT, studentReasoning);
formData.append(ENTRY_REASONING_SCORE, aiScore);
formData.append(ENTRY_FAIL_REASON, failReason);
formData.append(ENTRY_PLAYER_ID, playerID);
let demo = { gender: "N/A", state: "N/A", year: "N/A", rotations: "N/A", specialty: "N/A" };
try {
const stored = sessionStorage.getItem("medStudentDemoData");
if(stored) demo = JSON.parse(stored);
} catch(e) { console.error("Could not load demographics", e); }
formData.append(ENTRY_DEMO_GENDER, demo.gender);
formData.append(ENTRY_DEMO_STATE, demo.state);
formData.append(ENTRY_DEMO_YEAR, demo.year);
formData.append(ENTRY_DEMO_ROTATIONS, demo.rotations);
formData.append(ENTRY_DEMO_SPECIALTY, demo.specialty);
// B1 / D5 and B2 / D21.
formData.append(ENTRY_INTUBATE_EARLY, intubateEarly);
formData.append(ENTRY_DELAY_MEDS, delayMeds);
formData.append(ENTRY_SCORING_MODEL, scoringModel || "");
// A live stress run lost two of five submissions to net::ERR_CONNECTION_CLOSED.
// mode:"no-cors" resolves opaque for ANY response, so a rejection here means the
// request never reached Google at all - the row was gone and the learner still saw
// "Submitted". Retry twice with backoff; a rejected fetch got no response, so a
// retry cannot duplicate an accepted row.
// The duplicate guard is armed only once a response has actually come back. It used
// to be armed BEFORE the POST, on the reasoning that no-cors makes success and failure
// indistinguishable - but that got the priority backwards and cost a live row: a chaos
// run reloaded while a Case 1 POST was failing, the pre-armed key suppressed the
// re-send, and the row was lost for good. Before the guard existed, that same reload
// would have recovered it.
//
// Under mode:"no-cors" the promise RESOLVES when a response is received (opaque, but a
// response) and REJECTS only on a network failure. So .then() is the one moment we know
// the row reached Google, and the only safe moment to block a re-send. The residual
// window - a reload after the request leaves but before it resolves - now fails toward
// a duplicate rather than a loss. That is the right direction: a duplicate is
// byte-identical and can be dropped at analysis, while a lost row is invisible in the
// sheet and the KV backup carries no reputation, cost or score.
(function postWithRetry(attempt) {
fetch(FORM_URL, { method: "POST", mode: "no-cors", body: formData })
.then(function() {
try { if (_sentKey) { sessionStorage.setItem(_sentKey, "1"); } } catch (e) {}
})
.catch(function(err) {
console.warn("Form POST attempt " + attempt + " failed: " + err);
if (attempt < 3) {
setTimeout(function() { postWithRetry(attempt + 1); }, attempt * 1500);
} else {
console.error("Form POST failed after 3 attempts; this row is lost.");
}
});
})(1);
}
window.runAI();
// PRELOAD-1: warm the first board question. Deliberately AFTER runAI() so it never
// competes with the feedback call the learner is actually waiting on, and wrapped so a
// failure here cannot affect the case row - this block is optional comfort only. No
// cache entry on failure means board review fetches on arrival exactly as before.
(function warmFirstBoardQuestion() {
try {
const topicEl = document.getElementById("pre-board-topic");
const sessEl = document.getElementById("pre-board-session");
if (!topicEl) { return; }
const topic = topicEl.innerText.trim();
if (!topic || topic === "0") { return; }
const sess = (sessEl ? sessEl.innerText.trim() : "") || "s";
const key = "hns_bq_" + sess + "_" + topic;
if (sessionStorage.getItem(key)) { return; }
const ctrl = new AbortController();
const t = setTimeout(function () { ctrl.abort(); }, 45000);
fetch("https://hepatology-backend.f-pradhan.workers.dev", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic: topic }),
signal: ctrl.signal
}).then(function (r) {
clearTimeout(t);
if (!r.ok) { throw new Error("HTTP " + r.status); }
return r.text();
}).then(function (txt) {
const q = JSON.parse(txt);
if (q.error) { throw new Error(q.error); }
const optsOk = Array.isArray(q.options) && q.options.length === 5;
const idxOk = Number.isInteger(q.correct_index) && q.correct_index >= 0 && q.correct_index <= 4;
if (!optsOk || !idxOk) { throw new Error("malformed item"); }
sessionStorage.setItem(key, JSON.stringify(q));
console.log("Board question 1 preloaded: " + topic);
}).catch(function (e) {
console.warn("Board preload skipped: " + e);
});
} catch (e) {
console.warn("Board preload skipped: " + e);
}
})();
</script>
}{(set: $stability to (max: 0, $stability - 20)) (set: $cred to (max: 0, $cred - 15))(set: $cost to $cost + 500)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | EVENT: Case 1: Fell for the 'ammonia trap' by falsely assuming an elevated ammonia is diagnostic of hepatic encephalopathy and treating with lactulose. Also - did not initially go to the ER to evaluate the patient initially. | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
(set: $failReason to "AmmoniaTrap")
}
Time: 8:00 PM
You wake up to a "Code Gray" (Combative Patient) overhead page.
The nursing notes show the patient received 2 doses of lactulose but became increasingly agitated, sweating profusely, and eventually had to be physically restrained.
**Feedback**: You fell for the "ammonia trap." High ammonia is common in chronic liver disease and does not automatically mean hepatic encephalopathy. By anchoring on the lab value, you missed the clinical picture. **Always evaluate the patient first.**
[[Go to the Bedside Now ->Bedside]]{<h3>Medication Orders</h3>
Based on your assessment, the patient is in moderate to severe alcohol withdrawal. He is tremulous and diaphoretic.
What is your first-line therapy?
<br>
(link: "Lorazepam (benzodiazepine)")[(set: $choice to "Lorazepam")(goto: "Case1Treatment")]
(link: "Haloperidol (antipsychotic)")[(set: $choice to "haloperidol")(goto: "Seizure")]
(link: "Metoprolol (beta-blocker)")[(set: $choice to "BetaBlocker")(goto: "MedicationWrong")]
}{
(set: $caseName to "Case 2")
(if: $status is not "Dead")[
(set: $outcome to "Success")
(set: $failReason to "None")
]
(set: $hadPreviousFailure to ((history:) contains "CodeBlue"))
(if: $intubated is not true)[ (set: $intubated to false) ]
<!-- ROUTE FLAGS (round-3 review, P1). Read from $transcript, NOT (history:). Case 2 can
be replayed after a death, and (history:) is never reset, so it would carry a first
attempt's errors into the retry's summary. Case2Start resets $transcript for every
attempt, which makes it exactly one attempt wide. Matched on the ACTION strings the
error passages write; learner-typed reasoning never enters $transcript. Computed
before this passage appends its own END SUMMARY line. -->
(set: $c2Intubate to $transcript contains "Premature Intubation")
(set: $c2Vanc to ($transcript contains "Vancomycin") or ($transcript contains "vancomycin"))
(set: $c2Pip to ($transcript contains "Piperacillin") or ($transcript contains "piperacillin"))
(set: $c2ForgotMeds to $transcript contains "Forgot Meds")
(set: $c2EndoFirst to ($transcript contains "Premature EGD") or ($transcript contains "Premature Consult"))
<!-- Errors per the author's list. Vancomycin COUNTS even when the fellow corrects it:
the learner chose it. Used by the Status marker and the HONORS gate. -->
(set: $c2CareErrors to 0)
(if: $c2Intubate)[(set: $c2CareErrors to $c2CareErrors + 1)]
(if: $c2Vanc)[(set: $c2CareErrors to $c2CareErrors + 1)]
(if: $c2Pip)[(set: $c2CareErrors to $c2CareErrors + 1)]
(if: $c2ForgotMeds)[(set: $c2CareErrors to $c2CareErrors + 1)]
(if: $c2EndoFirst)[(set: $c2CareErrors to $c2CareErrors + 1)]
<!-- STATUS sentence: one line per error that happened, so a learner with two errors is
told about both. No severity words - the marker carries severity. -->
(set: $c2Note to "")
(if: $c2Intubate)[(set: $c2Note to $c2Note + "Intubation before resuscitation precipitated a crash that needed rescue resuscitation. ")]
(if: $c2EndoFirst)[(set: $c2Note to $c2Note + "Endoscopy was called before he had been resuscitated. ")]
(if: $c2ForgotMeds)[(set: $c2Note to $c2Note + "Octreotide and antibiotics were delayed until prompted. ")]
(if: $c2Vanc)[(set: $c2Note to $c2Note + "Vancomycin was chosen for prophylaxis, but it does not cover the gram-negative organisms that cause SBP after a variceal bleed" + (cond: $abx is "ceftriaxone", "; the fellow corrected it to ceftriaxone. ", ". "))]
(if: $c2Pip)[(set: $c2Note to $c2Note + "Piperacillin-tazobactam was broader than prophylaxis requires" + (cond: $abx is "ceftriaxone", "; the fellow changed it to ceftriaxone. ", ". "))]
(if: $c2Note is "")[(set: $c2Note to "Resuscitated first, with the bleeding controlled at endoscopy.")]
<!-- MANAGEMENT: what he actually received. Case2Summary is reached only from
Case2EndoPrep, where the varices are always banded. -->
(set: _abxName to (cond: $abx is "ceftriaxone", "ceftriaxone", $abx is "vancomycin", "vancomycin", $abx is "piperacillin", "piperacillin-tazobactam", "an antibiotic"))
(if: $meds is true)[(set: $c2Mgmt to "Volume resuscitation, octreotide and " + _abxName + "; endoscopic band ligation.")]
(else:)[(set: $c2Mgmt to "Volume resuscitation and endoscopic band ligation; octreotide and antibiotics were not given.")]
(if: $intubated is true)[(set: $c2Mgmt to $c2Mgmt + " Intubated.")]
(set: _repText to "")
(set: _clinText to "")
(set: _costText to "")
<!-- 9C-2: tier on EXCESS over this case's unavoidable floor, not raw $cost. -->
(set: _excess to $cost - $optimalCost)
(if: _excess < 0)[(set: _excess to 0)]
(set: $costColor to "#00FF00")
(if: _excess > 300)[(set: $costColor to "#FFCC00")]
(if: _excess > 1500)[(set: $costColor to "#FF0000")]
(if: $cred >= 80)[(set: $credColor to "#00FF00")]
(else-if: $cred >= 50)[(set: $credColor to "#FFCC00")]
(else:)[(set: $credColor to "#FF0000")]
(if: $stability >= 80)[(set: $stabilityColor to "#00FF00")]
(else-if: $stability >= 50)[(set: $stabilityColor to "#FFCC00")]
(else:)[(set: $stabilityColor to "#FF0000")]
(if: $cred >= 80)[
(set: _repText to "PROFESSIONAL: Trusted (Excellent guideline adherence).")
]
(else-if: $cred >= 50)[
(set: _repText to "PROFESSIONAL: Average (Supervision Required).")
]
(else:)[
(set: _repText to "PROFESSIONAL: Needs More Supervision (Safety/Communication Errors).")
]
(if: $intubated is false and $stability > 80)[
(set: _clinText to "CLINICAL: Mastery (Hemodynamic optimization first; airway protection reasonable but optional given a protected airway and no active hematemesis).")
]
(else-if: $intubated is true and $stability > 60)[
(set: _clinText to "CLINICAL: Effective (Airway secured after resuscitation - a reasonable choice, not a failure).")
]
(else-if: $stability > 80)[
(set: _clinText to "CLINICAL: Expert (Good Sequence).")
]
(else-if: $stability > 50)[
(set: _clinText to "CLINICAL: Competent (Minor Delays).")
]
(else:)[
(set: _clinText to "CLINICAL: Concerns Noted (Adverse Events).")
]
(if: _excess <= 300)[
(set: _costText to "COST: Efficient (High Value Care).")
]
(else-if: _excess <= 1500)[
(set: _costText to "COST: Acceptable.")
]
(else:)[
(set: _costText to "COST: Wasteful (Unnecessary Interventions).")
]
(if: $hadPreviousFailure)[
(set: _clinText to _clinText + " [NOTE: Redeemed after previous Code Blue].")
]
(set: $turnCount to $turnCount + 1)
<!-- Student review: this rating was written to the transcript and never shown.
Learners saw "patient is stable" after a drug-induced seizure, a
wrong-spectrum antibiotic or an unnecessary MRI, and read it as approval. The
game had judged them correctly all along and simply never said so. Mirrored to
a story variable because _temp does not cross the { } hook boundary. -->
(set: $clinDisplay to _clinText)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: CASE 2 END SUMMARY. " + _repText + " " + _clinText + " " + _costText + " | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
<!-- PRELOAD-3: placed HERE, after the END SUMMARY append and after
$caseName is set, so the warm sees exactly the state SubmitData will.
Earlier in the passage it saw an unset case name and a transcript
missing its final turn, and the cache silently never matched. -->
(display: "FeedbackWarm")
}
{
<div style="background-color: #333; padding: 15px; border-radius: 8px;">
<h3 style="margin-top: 0; text-align: center; letter-spacing: 2px;">CASE 2 SUMMARY</h3>
{(if: $clinDisplay is not 0 and $clinDisplay is not "")[
<div style="background: #1a1a1a; border-left: 4px solid #00BFFF; padding: 10px 14px; margin-bottom: 18px; color: #ddd; font-size: 0.95em;">(print: $clinDisplay)</div>
]}
<div style="display: flex; justify-content: space-around; background: #222; padding: 15px; border-radius: 5px; margin-bottom: 20px; border: 1px solid #444;">
<div style="text-align: center;"><span style="font-size: 0.8em; color: #aaa; text-transform: uppercase;">Stability</span><br>(print: "<strong style='font-size: 1.2em; color: " + $stabilityColor + ";'>" + (text: $stability) + "%</strong>")</div>
<div style="text-align: center;"><span style="font-size: 0.8em; color: #aaa; text-transform: uppercase;">Cost</span><br>(print: "<strong style='font-size: 1.2em; color: " + $costColor + ";'>$" + (text: $cost) + "</strong>")</div>
<div style="text-align: center;"><span style="font-size: 0.8em; color: #aaa; text-transform: uppercase;">Reputation</span><br>(print: "<strong style='font-size: 1.2em; color: " + $credColor + ";'>" + (text: $cred) + "%</strong>")</div>
</div>
<div style="background: rgba(255, 255, 255, 0.05); padding: 20px; border-radius: 8px; border: 1px solid #333;">
<!-- Same template and marker scale as Cases 1 and 3 (round-3 review, P1). This used to
be headed "Post-Shift Update", had no Disposition, and banded Status on stability
alone, so a learner who chose vancomycin was told only that he was stable. -->
<h3 style="margin-top: 0; color: #00BFFF; border-bottom: 1px solid #444; padding-bottom: 10px;">Post-Shift Log</h3>
<p><strong>Patient:</strong> Mr. Smith (ICU Bed 8)</p>
<p><strong>Diagnosis:</strong> Acute Variceal Hemorrhage</p>
<p><strong>Management:</strong> (print: $c2Mgmt)</p>
<p><strong>Status:</strong>
(if: $stability > 70 and $c2CareErrors is 0)[<span style="color: #00FF00">✔ STABLE.</span>]
(else-if: $stability > 70)[<span style="color: #FFCC00">⚠ STABLE, WITH RESERVATIONS.</span>]
(else-if: $stability > 40)[<span style="color: #FFCC00">⚠ GUARDED.</span>]
(else:)[<span style="color: #FF0000">✖ CRITICAL.</span>]
(print: $c2Note)
</p>
<!-- He is in ICU Bed 8 from the first screen of this case, so this is true of every
route that reaches the summary. -->
<p><strong>Disposition:</strong> Remains in the ICU for post-banding monitoring.</p>
(if: $hadPreviousFailure)[
<div style="margin-top: 15px; border-top: 1px dashed #555; padding-top: 10px;">
<em style="color: #00FF00;">* Previous Code Blue Error Corrected.</em>
</div>
]
</div>
<br>
<div style="text-align: center;">
<p style="font-size: 0.9em; color: #aaa; font-style: italic;">You are now heading to meet Dr. Sato to present the case.</p>
<div class="btn-tech-outline">
[[Present Case to Dr. Sato ->SubmitData]]
</div>
</div>
</div>}{
(set: $finalAvgRep to (round: ($c1_rep + $c2_rep + $c3_rep) / 3))
(set: $finalAvgStab to (round: ($c1_stab + $c2_stab + $c3_stab) / 3))
(set: $finalTotalCost to ($c1_cost + $c2_cost + $c3_cost))
}
{
<div id="conclusion-hidden-data" style="display:none;">
<span id="c1-rep">$c1_rep</span><span id="c1-stab">$c1_stab</span>
<span id="c2-rep">$c2_rep</span><span id="c2-stab">$c2_stab</span>
<span id="c3-rep">$c3_rep</span><span id="c3-stab">$c3_stab</span>
<span id="c1-err">$c1CareErrors</span><span id="c2-err">$c2CareErrors</span><span id="c3-err">$c3CareErrors</span>
<span id="c-cred">$finalAvgRep</span>
<span id="c-stability">$finalAvgStab</span>
<span id="c-cost">$finalTotalCost</span>
<span id="c-transcript">$transcript</span>
<span id="c-session-id">$sessionID</span>
<!-- 9H-4: expose the history checks to JS. (history:) is never reset by the retry. -->
<span id="c-had-codeblue">(if: (history:) contains "CodeBlue")[yes](else:)[no]</span>
<span id="c-had-airway">(if: (history:) contains "Case2Airway")[yes](else:)[no]</span>
</div>
<div id="conclusion-container" style="max-width: 700px; margin: 0 auto; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; display: flex; flex-direction: column; gap: 20px;">
<div style="text-align: center;">
<h1 style="margin: 0 0 10px 0; color: #00BFFF; text-transform: uppercase; letter-spacing: 2px;">Shift Complete</h1>
<hr style="width: 60%; border: 0; border-top: 1px solid #555; margin: 0 auto;">
</div>
<div style="background-color: #222; border: 1px solid #444; padding: 20px; border-radius: 10px; text-align: left; line-height: 1.5; color: #eee; box-shadow: 0 4px 10px rgba(0,0,0,0.5);">
<p style="margin: 0;">
Thank you for playing <strong>Hepatology Night Shift</strong>. Dr. Sato’s evaluation of your shift is below.
</p>
</div>
<div id="letter-loading" style="text-align: center; padding: 30px; color: #aaa; background: rgba(0,0,0,0.2); border-radius: 8px;">
<i class="fa-solid fa-file-contract" style="font-size: 2.5em; color: #00BFFF; margin-bottom: 15px;"></i>
<br>
<strong>Dr. Sato is drafting your Rotation Evaluation...</strong>
<br>
<span style="font-size: 0.8em; color: #666;">Analyzing Diagnostic Reasoning & Differentials...</span>
<br><br>
<div style="display: inline-block; width: 30px; height: 30px; border: 3px solid #333; border-top-color: #00BFFF; border-radius: 50%; animation: spin 1s ease-in-out infinite;"></div>
</div>
<div id="letter-content" style="display: none; background-color: #fff; color: #000; padding: 0; border: 1px solid #ccc; box-shadow: 0 5px 25px rgba(0,0,0,0.5);">
<div style="background-color: #eee; border-bottom: 2px solid #000; padding: 15px; display: flex; justify-content: space-between; align-items: center;">
<div>
<strong style="font-size: 1.1em; display: block; color:#000;">CLINICAL ROTATION EVALUATION</strong>
<span style="font-size: 0.8em; text-transform: uppercase; color: #444;">Dept of Medicine: Hepatology</span>
</div>
<div style="text-align: right; font-size: 0.8em; color:#000;">
<strong>Confidential</strong><br>
Form GME-25
</div>
</div>
<div style="display: flex; border-bottom: 1px solid #ccc;">
<div style="flex: 1; padding: 10px; border-right: 1px solid #ccc;">
<span style="font-size: 0.7em; color: #666; display: block;">RESIDENT</span>
<strong style="color:#000;">Internal Medicine Resident</strong>
</div>
<div style="flex: 1; padding: 10px; border-right: 1px solid #ccc;">
<span style="font-size: 0.7em; color: #666; display: block;">EVALUATOR</span>
<strong style="color:#000;">Hana Sato, MD</strong>
</div>
<div style="flex: 1; padding: 10px;">
<span style="font-size: 0.7em; color: #666; display: block;">FINAL GRADE</span>
<strong id="final-grade-box" style="color: #000;">PENDING</strong>
</div>
</div>
<div style="padding: 15px; border-bottom: 1px solid #eee;">
<span style="font-size: 0.7em; color: #666; display: block; margin-bottom: 5px;">CASE PERFORMANCE BREAKDOWN</span>
<table style="width: 100%; font-size: 0.9em; border-collapse: collapse; color: #333;">
<tr style="background-color: #f5f5f5; border-bottom: 2px solid #ddd;">
<th style="text-align: left; padding: 6px;">Case Scenario</th>
<th style="text-align: right; padding: 6px;">Competency Rating</th>
</tr>
<tr style="border-bottom: 1px solid #ddd;">
<td style="padding: 6px;">1. Alcohol Withdrawal</td>
<td style="text-align: right; font-weight: bold;" id="disp-c1">-</td>
</tr>
<tr style="border-bottom: 1px solid #ddd;">
<td style="padding: 6px;">2. Variceal Bleed</td>
<td style="text-align: right; font-weight: bold;" id="disp-c2">-</td>
</tr>
<tr style="border-bottom: 1px solid #ddd;">
<td style="padding: 6px;">3. SBP / AKI</td>
<td style="text-align: right; font-weight: bold;" id="disp-c3">-</td>
</tr>
</table>
</div>
<div style="padding: 25px;">
<span style="font-size: 0.8em; color: #666; display: block; margin-bottom: 5px; text-transform: uppercase; font-weight: bold;">Summative Evaluation Comments:</span>
<div id="ai-letter-text" style="font-family: 'Times New Roman', serif; line-height: 1.5; font-size: 1.1em; color: #000; min-height: 100px;">
</div>
</div>
<div style="background-color: #f9f9f9; padding: 15px; border-top: 1px solid #ccc; font-size: 0.9em; display: flex; align-items: center;">
<div style="flex: 1;">
<span style="font-size: 0.7em; color: #666; display: block;">OVERALL DESCRIPTOR</span>
<strong id="descriptor-text" style="color: #000080;">-</strong>
</div>
<div style="flex: 1; text-align: right;">
<span style="font-family: 'Brush Script MT', cursive; font-size: 1.4em; margin-left: 10px; color:#000;">Hana Sato, MD</span>
</div>
</div>
</div>
<div style="text-align: center; margin-top: 25px;">
<span style="display: inline-block; border: 1px solid #00BFFF; padding: 12px 25px; border-radius: 5px; transition: background 0.3s; background-color: rgba(0, 191, 255, 0.1);">
(link: "Start New Game (Replay)")[ (reload:) ]
</span>
</div>
<div style="margin-top: 10px; padding-top: 20px; border-top: 1px solid #333; font-size: 0.85em; color: #888; line-height: 1.6; text-align: center;">
<strong>Hepatology Night Shift</strong>
<br>
Created by Dr. Faruq Pradhan, Transplant Hepatologist.
<br>
CC BY-NC-SA
<a href="mailto:liver@fpradhan.com" style="color: #00BFFF; text-decoration: none; transition: color 0.2s; margin-left: 5px;">
<i class="fa-solid fa-envelope"></i> CONTACT
</a>
<br><br>
<em style="font-size: 0.9em; color: #666;">This simulation is for educational purposes only and does not constitute medical advice.</em>
</div>
</div>
<script>
// ================= CONFIGURATION =================
const WORKER_URL = "https://hepatology-backend.f-pradhan.workers.dev";
setTimeout(function() {
generateLetter();
}, 100);
// HELPER: Calculate Rating based on Reputation & Stability
// Round-3 review, P8(a): a case with an error still standing at its end cannot be
// HONORS, however well the numbers recovered. The reported case: an early tap and correct
// albumin return a vancomycin run to 100% stability and 100% reputation - the ceiling
// erases the penalty - and it was graded HONORS, identical to a flawless run. Stored
// numbers are untouched; only this label is gated. The per-case error counts are the
// same ones that drive each Post-Shift Log's amber marker, so the grade and the log can
// never disagree. Nothing here is posted to the Form - grades are computed on this page
// and shown here and in the letter prompt only.
// The OVERALL grade passes no error count and is unchanged: it averages the three cases.
function getGrade(rep, stab, errs) {
if (rep >= 80 && stab >= 80 && !(errs > 0)) return "HONORS";
if (rep >= 70 && stab >= 50) return "HIGH PASS";
if (rep >= 50) return "PASS";
return "NEEDS IMPROVEMENT";
}
// HELPER: Color Coding
function getColor(grade) {
if(grade === "HONORS") return "#00AA00"; // Green
if(grade === "HIGH PASS") return "#0000AA"; // Blue
if(grade === "PASS") return "#555500"; // Dark Yellow
return "#AA0000"; // Red
}
// HELPERS: null-safe DOM access. D19 / the perpetual "Dr. Sato is drafting your
// Rotation Evaluation..." spinner was one unguarded write to a node that does not
// exist - it threw before the letter was ever requested, and because the throw
// happened outside the try there was nothing to catch it and nothing to reveal the
// letter. A renamed or deleted node must cost one blank field, never the evaluation.
function readEl(id) {
const el = document.getElementById(id);
return el ? el.innerText.trim() : "";
}
function readNum(id) { return parseInt(readEl(id)) || 0; }
function writeEl(id, value, color) {
const el = document.getElementById(id);
if (!el) { console.warn("GameConclusion: missing element #" + id); return; }
el.innerText = value;
if (color) { el.style.color = color; }
}
// Always run: the spinner must never outlive generateLetter, on any path.
function revealLetter() {
const loading = document.getElementById("letter-loading");
const content = document.getElementById("letter-content");
if (loading) { loading.style.display = "none"; }
if (content) { content.style.display = "block"; }
}
async function generateLetter() {
// The ENTIRE body runs inside one try. Steps 1-3 used to sit outside it, so any DOM
// fault killed the function before the request was made and left the spinner up
// permanently. Every exit path below ends in revealLetter().
try {
// 1. Gather Data Safely from Hidden Spans
const sessionID = readEl("c-session-id");
const c1_rep = readNum("c1-rep");
const c1_stab = readNum("c1-stab");
const c2_rep = readNum("c2-rep");
const c2_stab = readNum("c2-stab");
const c3_rep = readNum("c3-rep");
const c3_stab = readNum("c3-stab");
const avgRep = readNum("c-cred");
const avgStab = readNum("c-stability");
const totalCost = readNum("c-cost");
const transcript = readEl("c-transcript");
// 2. Calculate Individual Grades
const g1 = getGrade(c1_rep, c1_stab, readNum("c1-err"));
const g2 = getGrade(c2_rep, c2_stab, readNum("c2-err"));
const g3 = getGrade(c3_rep, c3_stab, readNum("c3-err"));
const gFinal = getGrade(avgRep, avgStab);
// 3. Update UI Table
writeEl("disp-c1", g1, getColor(g1));
writeEl("disp-c2", g2, getColor(g2));
writeEl("disp-c3", g3, getColor(g3));
// Author direction: the evaluation names NO dollar figure. Resource stewardship is
// addressed qualitatively in Dr. Sato's paragraph, whose prompt already forbids
// specific amounts. totalCost is still sent to the Worker in clinical_data below -
// that is the analytics record, not something the learner reads.
writeEl("final-grade-box", gFinal, getColor(gFinal));
// 4. Construct AI Prompt
// 9H-4 / DATA-9: a Case 2 death is invisible here otherwise. The death row submits
// correctly, but Case2Start resets $transcript/$outcome/$failReason on the retry, so the
// letter would describe only the successful second attempt. (history:) is never reset,
// so derive it from there - no new state required.
const _died = document.getElementById("c-had-codeblue")
? document.getElementById("c-had-codeblue").innerText.trim() === "yes" : false;
const _prematureIntub = document.getElementById("c-had-airway")
? document.getElementById("c-had-airway").innerText.trim() === "yes" : false;
const deathNote = _died
? ("\\n NOTE: this resident's patient DIED in Case 2" +
(_prematureIntub ? " from intubation attempted before resuscitation" : "") +
"; they then repeated the case. The transcript below is the successful retry and does NOT" +
" show the death. Address it directly in the evaluation.")
: "";
const prompt = `
ROLE: You are Dr. Hana Sato, a Transplant Hepatologist and Residency Program Evaluator.
TASK: Write the "Summative Comments" paragraph for an Internal Medicine Resident's End-of-Rotation Evaluation.
RESIDENT PERFORMANCE BREAKDOWN:
- Case 1 (Alcohol Withdrawal): ${g1}
- Case 2 (Variceal Bleed): ${g2}
- Case 3 (SBP/AKI): ${g3}
- Overall Grade: ${gFinal}
${deathNote}
FULL TRANSCRIPT OF ACTIONS:
${transcript}
INSTRUCTIONS:
1. Write ONE dense, formal paragraph (approx 4-5 sentences).
2. TONE: Formal GME evaluation language.
3. CONTENT:
- **Diagnostic Reasoning:** Evaluate if the resident's actions reflected an appropriate differential diagnosis. Did they anchor on incorrect diagnoses (e.g., treating HE as stroke)? Did their differentials 'fit' the case acuity?
- **Resource Stewardship:** Comment on cost-effectiveness (Value-Based Care) without including specific dollar amounts.
- **Grade Context:**
- If 'NEEDS IMPROVEMENT': Cite specific diagnostic errors or safety lapses.
- If 'HONORS': Praise the sophistication and breadth of their differentials.
4. IDENTITY: Do NOT use gendered pronouns. Use "The resident", "The learner", or "This trainee".
5. CONSTRAINT: Do NOT refer to PGY-level (e.g., "PGY-1" or "Intern") as this breaks immersion. Focus on the learner-provider differential.
OUTPUT: Text only. No markdown formatting.
`;
// A hang is not an error: without a deadline the await never settles, the catch
// never runs and the spinner stays up forever. Same 45s AbortController the
// per-case feedback and board-review calls already use.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 45000);
// MODIFIED PAYLOAD: Included session_id and clinical_data for database storage
const response = await fetch(WORKER_URL, {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionID,
// Student review, round 2: the letter used to render as raw JSON on screen.
// The Worker routed on `contents && !topic`, which is exactly this request's
// shape, so the letter was served by the per-case SCORING branch and had
// responseMimeType:"application/json" plus the {reasoning_score, feedback}
// schema forced onto it. This field selects the Worker's plain-prose route.
task: "letter",
clinical_data: {
avg_rep: avgRep,
avg_stab: avgStab,
cost: totalCost,
transcript: transcript
},
contents: [{ parts: [{ text: prompt }] }]
})
});
clearTimeout(timeoutId);
if (!response.ok) { throw new Error("Worker returned HTTP " + response.status); }
const data = await response.json();
if (!data.candidates || !data.candidates.length) {
throw new Error("Worker returned no candidates: " + JSON.stringify(data).slice(0, 200));
}
let aiText = data.candidates[0].content.parts[0].text;
// DEFENSIVE UNWRAP. The Worker fix above is the real repair, but the two halves
// deploy independently: a learner whose browser holds a cached copy of the old page
// sends no `task` field and still lands in the scoring branch, and a new page against
// a Worker that has not been redeployed gets the same result. In both cases aiText is
// the literal string {"reasoning_score": 80, "feedback": "..."} and the learner reads
// JSON. Render the feedback field instead, and log loudly so the cause is not silent.
const rawLetter = String(aiText == null ? "" : aiText).trim();
if (rawLetter.charAt(0) === "{" && rawLetter.indexOf("feedback") !== -1) {
let unwrapped = "";
try {
const parsed = JSON.parse(rawLetter);
if (parsed && typeof parsed.feedback === "string") { unwrapped = parsed.feedback; }
} catch (e) {
// Truncated or otherwise unparseable - recover the prose by hand rather than
// showing the learner a broken object.
const m = rawLetter.match(/"feedback"\s*:\s*"([\s\S]*?)(?:"\s*\}\s*$|$)/);
if (m) {
unwrapped = m[1].replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
}
}
if (unwrapped.trim()) {
aiText = unwrapped;
console.warn("GameConclusion: the Worker answered the letter with the scoring " +
"schema; unwrapped .feedback. Redeploy worker/hepatology-backend.js.");
}
}
// Update UI with AI Response
// SEC: the letter prompt carries the learner's own transcript and reasoning, so
// this text is partly learner-influenced. The prompt asks for plain text, so
// escape it and restore only the line breaks rather than trusting any markup.
const letterEl = document.getElementById("ai-letter-text");
if (letterEl) {
letterEl.innerHTML = String(aiText == null ? "" : aiText)
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
.replace(/"/g, """).replace(/'/g, "'")
.replace(/\n/g, "<br>");
}
// Update Descriptor based on Final Grade
let descriptor = "COMPETENT";
if (gFinal === "HONORS") descriptor = "OUTSTANDING";
else if (gFinal === "HIGH PASS") descriptor = "STRONG";
else if (gFinal === "NEEDS IMPROVEMENT") descriptor = "NEEDS IMPROVEMENT";
writeEl("descriptor-text", descriptor);
// Reveal Letter
revealLetter();
} catch (error) {
console.error("Rotation evaluation failed:", error);
// Fallback Text. The grades table above is already filled in from local state,
// so the learner still gets their result - only Dr. Sato's prose is missing.
const letterEl = document.getElementById("ai-letter-text");
if (letterEl) {
letterEl.innerHTML = "The resident participated in the Hepatology Night Shift simulation. <br><br> (System Error: unable to generate the narrative evaluation. Details: "
+ String(error && error.message ? error.message : error).replace(/</g, "<") + ")";
}
revealLetter();
}
}
</script>
<style>
@keyframes spin { to { transform: rotate(360deg); } }
</style>
}{
(if: (history:) contains "Case3MissedParaFail" or (history:) contains "Case3Death")[
(set: $case3Retry to true)
(set: $transcript to "Case 3 (RETRY ATTEMPT) Started: SBP/AKI. Patient context: cirrhosis, ascites, abdominal pain, confusion (A&Ox2 with asterixis), AKI (Cr 2.1), afebrile at 37.8 with BP 105/65. ")
]
(else:)[
(set: $case3Retry to false)
(set: $transcript to "Case 3 Started: SBP/AKI. Patient context: cirrhosis, ascites, abdominal pain, confusion (A&Ox2 with asterixis), AKI (Cr 2.1), afebrile at 37.8 with BP 105/65. ")
]
(set: $caseName to "Case 3")
(set: $stability to 90) (set: $cost to 0)
(set: $optimalCost to 1250)
(set: $turnCount to 0)
<!-- 9A-2 reputation persistence: reputation now CARRIES across cases. A single
soft floor keeps a bad case visibly damaging without locking the learner out
of a good ending. Floor = 45, no recovery bump. -->
(set: $cred to (max: 45, $cred))
(set: $outcome to "Ongoing")
(set: $failReason to "None")
(set: $missedPara to false)
(set: $paraByAttending to false)
(set: $abxStatus to "None")
(set: $fluidStatus to "None")
(set: $abx to "None")
(set: $fluids to false)
(set: $meds to false)
<script>
window.startTime = Date.now();
</script>
}
<h3>CASE 3: THE 03:00 ADMISSION</h3>
<strong>Time:</strong> 03:01 AM
<br>
<strong>Location:</strong> Resident Workroom
<br>
You dial the extension on the pager display.
<br>
"Hey, sorry to hit you again," the ED resident says. "I have one more for you. Mr. Chen, a 58-year-old man with known decompensated cirrhosis. He came in for vague abdominal pain and malaise."
<br>
"Vitals are stable-ish, afebrile. He's a bit confused but protecting his airway. I think he's just dehydrated, but his belly is huge. Can you come admit him?"
<br>
You hang up, down the last dregs of your cold coffee, and head down to the ED.
<br><br>
[[Perform Bedside Exam ->Case3Exam]]{
(set: $cost to $cost + 150)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Reviewed Admission Labs (demonstrating AKI) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
}
<h3>ADMISSION LABS</h3>
<!-- The lab panel is block-level HTML laid out with flex. Harlowe turns every source
newline outside a collapsing block into a <br>, so these 48 lines of markup were
adding ~18 empty line breaks between the cards. The braces collapse the markup
whitespace only - no text, no layout and no prose changes. -->
{<div style="display: flex; flex-wrap: wrap; gap: 10px; font-family: monospace; font-size: 0.9em;">
<div style="flex: 1; min-width: 130px; background-color: #222; border: 1px solid #555; padding: 10px; border-radius: 4px;">
<strong style="color: #aaa; border-bottom: 1px solid #444; display: block; margin-bottom: 5px; text-align: center;">HEMATOLOGY</strong>
<div style="display: flex; background-color: #444400; justify-content: space-between;">
<span>WBC:</span> <strong style="color: #ffcc00;">11.5 H</strong>
</div>
<div style="display: flex; justify-content: space-between;">
<span>Hgb:</span> <span>9.8 L</span>
</div>
<div style="display: flex; background-color: #444400; justify-content: space-between;">
<span>Plt:</span> <span style="color: #ffcc00;">85 L</span>
</div>
</div>
<div style="flex: 1; min-width: 130px; background-color: #222; border: 1px solid #555; padding: 10px; border-radius: 4px;">
<strong style="color: #aaa; border-bottom: 1px solid #444; display: block; margin-bottom: 5px; text-align: center;">RENAL CHEMISTRY</strong>
<div style="display: flex; background-color: #444400; justify-content: space-between;">
<span>Na:</span> <span style="color: #ffcc00;">128 L</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>K:</span> <span>4.2</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>BUN:</span> <span>45 H</span>
</div>
<div style="display: flex; justify-content: space-between; background-color: #440000; padding: 0 3px;">
<span>Cr:</span> <strong style="color: #ff4444;">2.1 H !</strong>
</div>
<div style="font-size: 0.8em; color: #888; text-align: right;">(Baseline Cr : 0.9)</div>
</div>
<div style="flex: 1; min-width: 130px; background-color: #222; border: 1px solid #555; padding: 10px; border-radius: 4px;">
<strong style="color: #aaa; border-bottom: 1px solid #444; display: block; margin-bottom: 5px; text-align: center;">LIVER CHEMISTRIES</strong>
<div style="display: flex; background-color: #444400; justify-content: space-between;">
<span>T.Bili:</span> <span style="color: #ffcc00;">3.8 H</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>AST:</span> <span>85</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>ALT:</span> <span>42</span>
</div>
<div style="display: flex; justify-content: space-between; background-color: #444400; border-top: 1px dashed #444; margin-top: 2px; padding-top: 2px;">
<span>INR:</span> <span style="color: #ffcc00;">1.9 H</span>
</div>
<div style="display: flex; justify-content: space-between; background-color: #444400; padding: 0 3px;">
<span>Alb:</span> <strong style="color: #ffcc00;">2.8 L</strong>
</div>
</div>
</div>}
<br>
{<div style="border-left: 3px solid #00BFFF; padding-left: 10px; margin-bottom: 15px;">}
<strong>Assessment:</strong>
<br>
<strong>MELD 3.0 Score: 31</strong>
<em>(Calculated using Cr 2.1, Bili 3.8, INR 1.9, Na 128, Alb 2.8, Sex: Male)</em>
<br>
The combination of acute kidney injury, coagulopathy, and jaundice suggests <strong>acute-on-chronic liver failure</strong>.
</div>
The nurse looks at the chart. "With that creatinine and the belly pain, do you want to order any imaging?"
<br><br>
<strong>Imaging Choice:</strong>
<br>
[[Abdominal Ultrasound with Doppler ->Case3ImgUS]]
[[CT Abdomen/Pelvis with IV Contrast ->Case3ImgCT]]
[[MRI Abdomen with IV Contrast ->Case3ImgMRI]]{
(set: $fluids to true)
(set: $missedPara to true)
(set: $abx to "None")
(set: $stability to (max: 0, $stability - 15))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered IV Fluids (Error - Worsened Ascites/Missed SBP Diagnosis) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
<div style="max-width: 600px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px;">
<div style="background-color: #222; border: 1px solid #444; padding: 20px; border-radius: 10px;">
<h3 style="color: #00BFFF; margin-top: 0;">Orders Placed: IV Fluids</h3>
<p>
You order a 1L bolus of normal saline. The nurse hangs the bag, and the fluids begin to run wide open.
</p>
<p>
You feel satisfied that you are addressing his kidney injury and decide to check on your other patients while the fluids go in.
</p>
</div>
<div style="text-align: center; margin-top: 20px;">
(link: "Advance Time (2 Hours)")[ (goto: "Case3MissedParaFail") ]
</div>
</div>
}{
(set: $meds to true)
(set: $missedPara to true)
(set: $abx to "None")
(set: $stability to (max: 0, $stability - 20))
(set: $cred to (max: 0, $cred - 10))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Diuretics (Error - Worsened AKI/Missed SBP Diagnosis) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
}
{<div style="max-width: 600px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px;">
<div style="background-color: #222; border: 1px solid #444; padding: 20px; border-radius: 10px;">
<h3 style="color: #00BFFF; margin-top: 0;">Orders Placed: Diuretics</h3>
<p>
Reasoning that the ascites represents volume overload, you order IV furosemide.
</p>
<p>
"Sounds good," the nurse says. "Should I also monitor his urine output and page you in a couple of hours?"
</p>
<p>
"Yes please," you reply. "Let's see how much he diureses over the next few hours."
</p>
</div>
<div style="text-align: center; margin-top: 20px;">
(link: "Advance Time (2 Hours)")[ (goto: "Case3MissedParaFail") ]
</div>
</div>}{
(set: $stability to (min: 100, $stability + 10))
(set: $cred to (min: 100, $cred + 10))
(set: $cost to $cost + 300)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Performed Diagnostic Paracentesis (Correct Diagnostic Step) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
}
<h3>DIAGNOSTIC PARACENTESIS</h3>
You verify consent and after numbing the area, you insert the needle into the patient's right lower quadrant. After entering the peritoneal cavity, you pull back yellow, cloudy-appearing fluid. You inoculate blood culture bottles at the bedside rather than sending the syringe down in a sterile container, which substantially improves the culture yield. You also send fluid for cell count with differential, total protein, albumin for the SAAG, glucose, and LDH.
<br>
{<div style="display: flex; flex-wrap: wrap; gap: 15px; align-items: stretch;">
<div style="flex: 1; min-width: 280px; background-color: #222; padding: 15px; border: 1px solid #555; border-left: 5px solid #00BFFF; border-radius: 4px;">}
<strong style="color: #00BFFF; display: block; border-bottom: 1px solid #444; margin-bottom: 10px;">FLUID ANALYSIS</strong>
<div style="font-family: monospace; font-size: 1.1em; line-height: 1.6;">
<strong>Color:</strong> Cloudy / Yellow<br>
<strong>WBC:</strong> <span style="color: #ffcc00;">850 /mm3</span><br>
<strong>PMN %:</strong> 90%<br>
<strong>Total Protein:</strong> 0.8 g/dL<br>
<strong>Albumin:</strong> 0.8 g/dL (SAAG 2.0)<br>
<strong>Glucose:</strong> 96 mg/dL<br>
<strong>LDH:</strong> 95 U/L<br>
<strong>Gram stain:</strong> No organisms seen<br>
</div>
</div>
{ <div style="flex: 1; min-width: 280px; background-color: #333; padding: 15px; border: 1px dashed #666; border-radius: 4px; display: flex; flex-direction: column; justify-content: center;">}
<strong style="color: #aaa; border-bottom: 1px solid #555; margin-bottom: 10px; display: block;">DIAGNOSTIC CRITERIA</strong>
<div style="font-size: 0.95em; color: #ddd;">
To diagnose SBP, calculate the <strong>Absolute Neutrophil Count (ANC)</strong>.
<br>
<div style="background: #222; padding: 5px; text-align: center; border-radius: 3px; font-family: monospace;">
Formula: Total WBC × PMN%
</div>
<br>
<em>Diagnostic Threshold for SBP: ≥ 250 cells/mm3</em>
<br><br>
<span style="font-size: 0.9em; color: #bbb;">The SAAG of 2.0 g/dL (serum albumin minus ascitic albumin) confirms portal-hypertensive ascites. The low total protein, normal glucose, and low LDH all fit <strong>spontaneous bacterial peritonitis</strong>.</span>
</div>
</div>
</div>
<br>
{
(if: $missedPara is true)[
<div style="border-left: 4px solid red; padding-left: 10px; margin-top: 20px;">
<strong style="color: red;">CLINICAL UPDATE:</strong>
<br>
While you are calculating the ANC, the nurse hangs another bag of fluids. The patient's BP is hovering at 85/50. You have lost the "Golden Hour" to identify the infection early.
</div>
]
(else:)[
(set: $cred to (min: 100, $cred + 10))
"Good work." Your attending nods. "All patients with ascites who come through the ER need a diagnostic paracentesis."
]
}
<br>
[[Calculate and Treat ->Case3Calc]]<h3>INTERPRETATION</h3>
<strong>Calculation:</strong> 850 x 0.90 = <strong>765 PMNs</strong>.
<strong style="color: #FFCC00">DIAGNOSIS: SPONTANEOUS BACTERIAL PERITONITIS (SBP)</strong>
**Let's Recap**: Mr. Chen has spontaneous bacterial peritonitis and an acute kidney injury (Cr 2.1). You now must select an antibiotic regimen.
<br>
<strong>Which antibiotic is best for treating this condition:</strong>
<br>
{
(link: "Ceftriaxone 2g IV")[
(set: $abxStatus to "Correct")
(set: $cost to $cost + 200)
(set: $stability to (min: 100, $stability + 10))
(set: $cred to (min: 100, $cred + 5))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Ceftriaxone (Correct SBP Treatment) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case3Fluids")
]
(link: "Piperacillin-Tazobactam 3.375g IV")[
(set: $abxStatus to "Nephrotox")
(set: $cost to $cost + 600)
(set: $stability to (max: 0, $stability - 5))
(set: $cred to (max: 0, $cred - 5))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Piperacillin-Tazobactam (Overkill/Nephrotoxicity Risk) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case3Fluids")
]
(link: "Vancomycin 15mg/kg IV")[
(set: $abxStatus to "WrongSpectrum")
(set: $cost to $cost + 400)
(set: $stability to (max: 0, $stability - 15))
(set: $cred to (max: 0, $cred - 10))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Vancomycin (Incorrect Spectrum - No Gram Neg Coverage) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case3Fluids")
]
}{
(set: $caseName to "Case 3")
(set: _profRating to "")
(set: _clinRating to "")
(set: _costRating to "")
<!-- 9C-2: tier on EXCESS over this case's unavoidable floor, not raw $cost. -->
(set: _excess to $cost - $optimalCost)
(if: _excess < 0)[(set: _excess to 0)]
(set: $costColor to "#00FF00")
(if: _excess > 300)[(set: $costColor to "#FFCC00")]
(if: _excess > 1500)[(set: $costColor to "#FF0000")]
(if: $cred >= 80)[(set: $credColor to "#00FF00")]
(else-if: $cred >= 50)[(set: $credColor to "#FFCC00")]
(else:)[(set: $credColor to "#FF0000")]
(if: $stability >= 80)[(set: $stabilityColor to "#00FF00")]
(else-if: $stability >= 50)[(set: $stabilityColor to "#FFCC00")]
(else:)[(set: $stabilityColor to "#FF0000")]
(if: $cred >= 80)[
(set: _profRating to "PROFESSIONAL: Trusted (Increased Autonomy).")
]
(else-if: $cred >= 50)[
(set: _profRating to "PROFESSIONAL: Average (Supervision Required).")
]
(else:)[
(set: _profRating to "PROFESSIONAL: Needs More Supervision (Safety Errors/Loss of Autonomy).")
]
(if: $paraByAttending is true)[
<!-- The learner never performed the tap: after two misses the attending did it.
$case3Retry is true on that path too, so it must be tested FIRST or the
summary claims a recovery that did not happen. -->
<!-- D51b(c): this branch is tested first, so before the qualifier below every
learner on this path was rated identically no matter which antibiotic they
chose after the attending's tap - the decision A2 added could not reach
clinical_rating at all. A suffix rather than a new tier: the tap was still
missed, and that is what the tier records. -->
(set: _clinRating to "CLINICAL: Not Achieved (Paracentesis Performed by Attending After Two Missed Attempts)")
(if: $abxStatus is "Correct")[
(set: _clinRating to _clinRating + " - correct antibiotic once the diagnosis was made.")
]
(else-if: $abxStatus is "Nephrotox")[
(set: _clinRating to _clinRating + " - over-broad antibiotic once the diagnosis was made.")
]
(else-if: $abxStatus is "WrongSpectrum")[
(set: _clinRating to _clinRating + " - incorrect antibiotic spectrum once the diagnosis was made.")
]
(else:)[
(set: _clinRating to _clinRating + ".")
]
]
(else-if: $case3Retry is true)[
(set: _clinRating to "CLINICAL: Recovered (Missed Para Initially, Corrected on Remediation).")
]
(else-if: $abxStatus is "Correct" and $fluidStatus is "Correct")[
(set: _clinRating to "CLINICAL: Mastery (Optimal SBP Management).")
]
(else-if: $abxStatus is "Nephrotox" and $fluidStatus is "Correct")[
(set: _clinRating to "CLINICAL: Effective but Poor Stewardship (Piperacillin-Tazobactam Overkill).")
]
(else-if: $fluidStatus is "NS" or $fluidStatus is "D5W")[
(set: _clinRating to "CLINICAL: Physiologically Incorrect (Wrong Fluids for HRS).")
]
(else:)[
(set: _clinRating to (cond: $abxStatus is "WrongSpectrum",
"CLINICAL: Unsafe (Antibiotic Had No Gram-Negative Coverage - Infection Untreated).",
"CLINICAL: Needs Improvement (Wrong Abx/Fluids)."))
]
<!-- Author direction: skipping a required diagnostic must not be rewarded as
efficiency. A learner who never tapped the patient spends well under the
$1,250 floor, so _excess is 0 and the old rule handed them the best possible
cost rating for the one omission the case exists to teach. The rating is
floored at "Acceptable" whenever the paracentesis was not performed; a
genuinely wasteful run still reads Wasteful, and the underspend is named so
the learner sees why it is not praise. -->
(if: _excess <= 300 and $missedPara is not true)[
(set: _costRating to "COST: Efficient (High Value Care).")
]
(else-if: _excess <= 300)[
(set: _costRating to "COST: Acceptable (Low spend, but the diagnostic paracentesis was never performed).")
]
(else-if: _excess <= 1500)[
(set: _costRating to "COST: Acceptable.")
]
(else:)[
(set: _costRating to "COST: Wasteful (Unnecessary Interventions).")
]
(set: $turnCount to $turnCount + 1)
<!-- Student review: this rating was written to the transcript and never shown.
Learners saw "patient is stable" after a drug-induced seizure, a
wrong-spectrum antibiotic or an unnecessary MRI, and read it as approval. The
game had judged them correctly all along and simply never said so. Mirrored to
a story variable because _temp does not cross the { } hook boundary. -->
(set: $clinDisplay to _clinRating)
<!-- D101 option (c), author direction. A reviewer reported 100% stability, 100%
reputation and a green "STABLE" after ordering vancomycin for SBP. Traced: the
penalty IS applied and is then erased by the cap. Case 3 starts at 90 stability,
a correct early tap adds +10/+10 with (min: 100, ...), so a learner arriving with
good reputation is ALREADY at the ceiling on both before the antibiotic decision.
Vancomycin costs -15/-10, correct albumin then adds +15/+10, and the clamp absorbs
the difference exactly. Nothing is missing from the arithmetic - the ceiling is
doing this.
The numbers are deliberately NOT changed: they are collected columns, and altering
them would split Stability and Reputation across two scoring rules mid-study, and
would have to be done to Cases 1 and 2 as well (Case 1 has the identical shape -
lorazepam's +20 clamped at 100) or the three cases would score differently.
So the DESCRIPTOR is gated instead. Detection is POSITIVE - it tests for the
specific error values rather than for the absence of "Correct" - so a route that
simply never set $fluidStatus cannot be mistaken for a mistake. That is the same
rule BoardTopics uses to build $weaknessList. Story variables, not _temp: the
disposition line is in a different hook. -->
(set: $careErrors to 0)
(if: $missedPara is true)[(set: $careErrors to $careErrors + 1)]
(if: $abxStatus is "WrongSpectrum" or $abxStatus is "Nephrotox")[(set: $careErrors to $careErrors + 1)]
(if: $fluidStatus is "NS" or $fluidStatus is "D5W")[(set: $careErrors to $careErrors + 1)]
<!-- Author direction: the gated line first read "he recovered in spite of X, not because
of it", which was the same sentence for every error and told a learner nothing about
WHY. It is built from three parts now.
LEAD names what actually moved the numbers, and only when that is true: the albumin
is the reason a run with a wrong antibiotic still ends at the stability cap, and
saying so is the difference between "you got away with it" and a teaching point.
Set only when the fluid was right AND the antibiotic was not - claiming the albumin
fixed him on a run where the fluid was also wrong would be false.
NOTE names the error that still stands, most serious first, and says what it means
clinically rather than labelling it.
TAIL names a SECOND error when there is one, because NOTE only reports the first and
a learner who got both the antibiotic and the fluid wrong should not be told about
one of them. -->
(set: $careLead to "")
(if: $fluidStatus is "Correct" and ($abxStatus is "WrongSpectrum" or $abxStatus is "Nephrotox"))[
(set: $careLead to "The albumin is what moved his numbers — that is volume support, not source control. ")
]
(set: $careNote to "")
(if: $missedPara is true)[
(set: $careNote to "The paracentesis was performed for you, so the diagnosis — and with it the antibiotic — came later than it should have. These numbers do not show that delay.")
]
(else-if: $abxStatus is "WrongSpectrum")[
(set: $careNote to "Vancomycin has no activity against the enteric gram-negative organisms that cause spontaneous bacterial peritonitis, so the infection itself is still untreated.")
]
(else-if: $abxStatus is "Nephrotox")[
(set: $careNote to "Piperacillin-tazobactam will treat the infection, but it is considerably broader than spontaneous bacterial peritonitis requires, and that extra exposure is not free in a patient who arrived with an acute kidney injury.")
]
(else-if: $fluidStatus is "NS" or $fluidStatus is "D5W")[
(set: $careNote to "The antibiotic is treating the infection, but the fluid chosen does nothing for the effective arterial underfilling driving his kidney injury — albumin is what protects the kidneys here.")
]
(set: $careTail to "")
(if: $missedPara is true and ($abxStatus is "WrongSpectrum" or $abxStatus is "Nephrotox"))[
(set: $careTail to " The antibiotic chosen afterwards was not the right one either.")
]
(else-if: ($abxStatus is "WrongSpectrum" or $abxStatus is "Nephrotox") and ($fluidStatus is "NS" or $fluidStatus is "D5W"))[
(set: $careTail to " The fluid was wrong for this physiology as well.")
]
<!-- Exported for the HONORS gate in GameConclusion, under the same name pattern as
Cases 1 and 2. -->
(set: $c3CareErrors to $careErrors)
<!-- MANAGEMENT (round-3 review, P1): the old "Treatment Plan" printed a literal
"Antibiotics initiated. + Albumin expansion." - generic, and with a stray plus sign.
This names what was actually given. -->
(set: _tap to (cond: $paraByAttending is true, "Diagnostic paracentesis (performed by the attending)", "Diagnostic paracentesis"))
(set: _abxGiven to (cond: $abxStatus is "Correct", "ceftriaxone", $abxStatus is "Nephrotox", "piperacillin-tazobactam", $abxStatus is "WrongSpectrum", "vancomycin", "no antibiotic"))
(set: _fluidGiven to (cond: $fluidStatus is "Correct", "25% albumin", $fluidStatus is "NS", "normal saline", $fluidStatus is "D5W", "D5W", ""))
(set: $c3Mgmt to _tap + "; " + _abxGiven + (cond: _fluidGiven is "", ".", "; " + _fluidGiven + "."))
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: CASE 3 END SUMMARY. " + _profRating + " " + _clinRating + " " + _costRating + " | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
<!-- PRELOAD-3: placed HERE, after the END SUMMARY append and after
$caseName is set, so the warm sees exactly the state SubmitData will.
Earlier in the passage it saw an unset case name and a transcript
missing its final turn, and the cache silently never matched. -->
(display: "FeedbackWarm")
}
{
<div style="background-color: #333; padding: 20px; border-radius: 8px; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;">
<h3 style="margin-top: 0; text-align: center; letter-spacing: 2px; color: #fff;">CASE 3 PERFORMANCE REVIEW</h3>
{(if: $clinDisplay is not 0 and $clinDisplay is not "")[
<div style="background: #1a1a1a; border-left: 4px solid #00BFFF; padding: 10px 14px; margin-bottom: 18px; color: #ddd; font-size: 0.95em;">(print: $clinDisplay)</div>
]}
<div style="display: flex; justify-content: space-around; background: #222; padding: 15px; border-radius: 5px; margin-bottom: 25px; border: 1px solid #444; box-shadow: 0 4px 8px rgba(0,0,0,0.3);">
<div style="text-align: center;"><span style="font-size: 0.8em; color: #aaa; text-transform: uppercase;">Stability</span><br>(print: "<strong style='font-size: 1.4em; color: " + $stabilityColor + ";'>" + (text: $stability) + "%</strong>")</div>
<div style="text-align: center;"><span style="font-size: 0.8em; color: #aaa; text-transform: uppercase;">Cost</span><br>(print: "<strong style='font-size: 1.4em; color: " + $costColor + ";'>$" + (text: $cost) + "</strong>")</div>
<div style="text-align: center;"><span style="font-size: 0.8em; color: #aaa; text-transform: uppercase;">Reputation</span><br>(print: "<strong style='font-size: 1.4em; color: " + $credColor + ";'>" + (text: $cred) + "%</strong>")</div>
</div>
<div style="background: rgba(255, 255, 255, 0.05); padding: 25px; border-radius: 8px; border-left: 5px solid #00BFFF; margin-bottom: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.3);">
<div style="margin-bottom: 20px;">
<!-- Same template and marker scale as Cases 1 and 2 (round-3 review, P1). The
coloured marker has moved from Disposition to Status: Status is how he is,
Disposition is only where he goes. The worst tier is now the red one - this
used to show "⚠ CRITICAL" in amber and "✖ UNSTABLE" in red. -->
<h3 style="margin-top: 0; color: #00BFFF; border-bottom: 1px solid #444; padding-bottom: 10px;">Post-Shift Log</h3>
<p><strong>Patient:</strong> Mr. Chen (admitted from the ED)</p>
<p><strong>Diagnosis:</strong> Spontaneous Bacterial Peritonitis (SBP) with Acute Kidney Injury</p>
<p><strong>Management:</strong> (print: $c3Mgmt)</p>
<p><strong>Status:</strong>
(if: $stability > 70 and $careErrors is 0)[<span style="color: #00FF00">✔ STABLE.</span> (print: (cond: $fluidStatus is "Correct", "Infection treated, with albumin supporting renal perfusion.", "Infection treated."))]
(else-if: $stability > 70)[<span style="color: #FFCC00">⚠ STABLE, WITH RESERVATIONS.</span> (print: $careLead + $careNote + $careTail) The stability figure above tracks his physiology, not the quality of the plan.]
(else-if: $stability > 40)[<span style="color: #FFCC00">⚠ GUARDED.</span> (print: (cond: $careErrors > 0, $careNote + $careTail, "Still under close observation."))]
(else:)[<span style="color: #FF0000">✖ CRITICAL.</span> (print: (cond: $careErrors > 0, $careNote + $careTail, "Hemodynamically unstable."))]
</p>
<p><strong>Disposition:</strong>
(if: $stability > 70)[Admitted to the medicine floor.](else:)[Admitted to the MICU for stabilization.]
</p>
</div>
<div style="padding: 15px; background-color: #222; border-radius: 4px; font-size: 0.9em; color: #aaa;">
<strong>Your Recorded Decision Logic:</strong><br>
"<span id="c3-reasoning-display" style="font-style: italic; color: #FFCC00;"></span>"
</div>
(if: $paraByAttending is true)[
<div style="margin-top: 15px; border-top: 1px dashed #FF4444; padding-top: 10px;">
<strong style="color: #FF4444;">✖ Remediation Status: NOT ACHIEVED</strong><br>
<span style="font-size: 0.9em;">The diagnostic paracentesis was never performed by you. After two
attempts that prioritised other treatment, your attending performed it at the bedside. In cirrhosis
with ascites and a rising creatinine the tap comes before the treatment — a fever is not required.</span>
</div>
]
(else-if: $case3Retry is true)[
<div style="margin-top: 15px; border-top: 1px dashed #FFCC00; padding-top: 10px;">
<strong style="color: #FFCC00;">⚠ Remediation Status: RECOVERED</strong><br>
<span style="font-size: 0.9em;">You initially missed the safety-critical step (early paracentesis), but successfully corrected your workflow on the second attempt.</span>
</div>
]
</div>
<div style="text-align: center; margin-top: 30px;">
<p style="font-size: 0.9em; color: #aaa; font-style: italic;">You are now heading to meet Dr. Sato to present the case.</p>
<div class="btn-tech-outline">
[[Present Case to Dr. Sato ->SubmitData]]
</div>
</div>
</div>
<script>
// 9H-1 / DATA-6: same durable-read order as SubmitData.
let _hR = "";
try { _hR = window.Harlowe.API_ACCESS.STATE.variables.studentReasoning || ""; } catch(e) {}
let _sR = "";
try { _sR = sessionStorage.getItem("hns_reasoning") || ""; } catch(e) {}
// D74: same placeholder trap as SubmitData. This one only affects what the Case 3
// summary DISPLAYS, not what is stored.
const _ph = "No reasoning provided.";
const _liveR = (window.studentReasoningText
&& window.studentReasoningText !== _ph) ? window.studentReasoningText : "";
let rText = _liveR || _hR || _sR || "No reasoning recorded.";
document.getElementById("c3-reasoning-display").innerText = rText;
</script>}{
(set: $cost to $cost + 200)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Ultrasound (Safe/Cost-Effective Choice) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
}
{<h3>CORRECT IMAGING CHOICE</h3>
<br>
You order the ultrasound. It is the safest modality given his acute kidney injury.
<br>
<strong>Report:</strong> "Cirrhotic appearing liver with no liver masses seen. No biliary dilation. Large volume ascites. Patent portal vein. No gallstones."
<br>
[[Decide on Management ->Case3Decision]]
}{
(set: $cost to $cost + 1200)
(set: $stability to (max: 0, $stability - 10))
(set: $cred to (max: 0, $cred - 10))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered CT with Contrast (Error - Risk of Contrast-Induced Nephropathy in AKI) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
}
<h3>RISKY CHOICE</h3>
You order the CT. The radiology tech calls you, sounding confused.
<br>
"Doctor, his creatinine is 2.1. Iodinated contrast could worsen his acute kidney injury. Are you sure?"
<br>
You pivot to a non-contrast study, but the radiology tech logs the near-miss safety event.
<br>
<strong>Report:</strong> "Cirrhotic appearing liver. Unable to assess for liver masses on non-contrast study. Large volume ascites. No hydronephrosis. No free air."
<br>
[[Decide on Management ->Case3Decision]]{
(set: $cost to $cost + 2200)
(set: $stability to (max: 0, $stability - 5))
(set: $cred to (max: 0, $cred - 5))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered MRI (Error - Excessive Cost and Delay) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
}
<h3>IMPRACTICAL CHOICE</h3>
You order an MRI.
<br><br>
The MRI tech asks you to call them. "This takes 45 minutes and he has ascites; he probably won't hold his breath well enough for the sequences. Plus, it's 3 AM. He won't be scheduled for several hours but OK, let's get him on the books."
<br>
The MRI won't be done until after your shift is completed. Meanwhile, the patient remains waiting in the ER for you to take your next steps. The delay has allowed the patient's blood pressure to drift down.
<br>
[[Decide on Management ->Case3Decision]]{<h3>DECISION POINT</h3>
<div style="padding: 10px; background: rgba(255, 255, 255, 0.1); border-radius: 5px;">
<strong>RECAP:</strong> The patient has cirrhosis with a new acute kidney injury (AKI), worsening ascites, and abdominal pain with diffuse tenderness.
<br>
</div>
<br>
<div style="background: #222; padding: 15px; border-left: 4px solid #FFD700; border-radius: 4px; margin-bottom: 20px;">
<strong style="color: #FFD700;">🧠 Clinical Reasoning Checkpoint</strong>
<br>
<span style="font-size: 0.9em; color: #ccc;">
<em>Provide your top differential diagnosis for this patient (choose 1), the next clinical step that you would take in management, and <strong>why</strong>.</em>
<br><strong style="color: #FFD700;">Include the why:</strong> name the findings that point to this diagnosis. One or two sentences is enough.
</span>
<br>
<textarea id="case3-reasoning" maxlength="1000" rows="3" style="width: 95%; padding: 10px; border-radius: 5px; border: 1px solid #555; background: #111; color: #e0e0e0; font-family: sans-serif;" placeholder="My top differential is ... because ... Next I would ..."></textarea>
<div id="cc-c3" style="font-size: 0.85em; color: #888; margin-top: 6px;">0 / 20 characters minimum</div>
<button id="commit-btn-c3" style="margin-top: 15px; padding: 10px 25px; background-color: #FFD700; color: #000; font-weight: bold; border: none; border-radius: 4px; cursor: pointer; transition: background 0.3s;">
<i class="fa-solid fa-lock"></i> LOCK IN & REVEAL OPTIONS
</button>
</div>
<div id="reveal-hook-c3" style="display:none;">
(link-repeat: "reveal-options")[
(show: ?optionsHook)
]
</div>
|optionsHook)[
<div id="decision-options" style="animation: fade-in 1s;">
<strong>Select your Action:</strong>
<br>
(link: "He's dehydrated. Bolus him 1L normal saline.")[
(set: $transcript to it + " Case 3 Decision: Incorrectly chose 1L Fluid Bolus (risk of worsening ascites/dilutional hyponatremia).")
(goto: "Case3TrapFluids")
]
(link: "Perform a diagnostic paracentesis.")[
(set: $transcript to it + " Case 3 Decision: Correctly chose Diagnostic Paracentesis to evaluate for SBP.")
(goto: "Case3Paracentesis")
]
(link: "Diurese with furosemide to help the abdomen tenseness.")[
(set: $transcript to it + " Case 3 Decision: Incorrectly chose Diuretics (contraindicated in acute AKI).")
(goto: "Case3TrapDiuretics")
]
</div>
]
<script>
(function(){
// Live-test fix: keep this reasoning checkpoint anchored at the top of the page.
// Harlowe's engine runs passage.find("input, textarea").first().focus() 100ms
// after every passage render, and re-applies that focus when the passage
// transition finishes. On a long passage that scrolls the answer box into view
// and hides everything above it, so the learner lands mid-page. Refuse the
// programmatic focus until the learner actually reaches for the box: a pointer
// press on the box, or any keypress (which is what tabbing into it looks like),
// marks real engagement first, so mouse and keyboard entry both still work.
const scrollBox = document.getElementById("case3-reasoning");
if (scrollBox) {
let engaged = false;
const engage = function() { engaged = true; };
["pointerdown", "mousedown", "touchstart"].forEach(function(ev) {
scrollBox.addEventListener(ev, engage, true);
});
document.addEventListener("keydown", engage, true);
scrollBox.addEventListener("focus", function() {
if (engaged) { return; }
scrollBox.blur();
window.scrollTo(0, 0);
});
}
window.scrollTo(0, 0);
const btn = document.getElementById("commit-btn-c3");
const input = document.getElementById("case3-reasoning");
const flagShort = window.hnsAttachCounter && window.hnsAttachCounter("case3-reasoning", "cc-c3", 20);
// Safety check to ensure elements exist
if(btn && input) {
window.studentReasoningText = "";
btn.addEventListener("click", function() {
// 1. Validation
// Trimmed length and the 20-character floor - see the note on the Case 2 checkpoint.
if(input.value.trim().length < 20) {
if (flagShort) { flagShort(); }
return;
}
// 2. Lock UI
input.disabled = true;
input.style.backgroundColor = "#444";
input.style.color = "#aaa";
input.style.cursor = "not-allowed";
btn.style.display = "none";
// 3. Save Data
window.studentReasoningText = "CASE 3 DECISION: " + input.value;
// 9H-1 / DATA-6: mirror into Harlowe state. window.* globals are wiped by a
// page reload, but Harlowe restores its own variables from sessionStorage - so
// without this the typed reasoning is silently replaced by the "no reasoning"
// fallback and THAT string is written to the Form and sent to Gemini as the
// student's answer.
if (window.Harlowe && window.Harlowe.API_ACCESS) {
try { window.Harlowe.API_ACCESS.STATE.variables.studentReasoning = window.studentReasoningText; } catch(e) {}
}
try { sessionStorage.setItem("hns_reasoning", window.studentReasoningText); } catch(e) {}
// 4. TRIGGER TWINE
// UX-04: target the hook by a stable container id instead of matching on
// link text. The reveal itself is already Harlowe-native - the hook runs
// (show: ?optionsHook) - so only the lookup needed hardening.
const targetLink = document.querySelector('#reveal-hook-c3 tw-link');
if(targetLink) {
targetLink.click();
} else {
// Fallback: If link finding fails, manually show the hook
const hook = document.querySelector('tw-hook[name="optionsHook"]');
if(hook) hook.style.display = "block";
}
});
}
})();
</script>
<style>
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
/* CSS ensures the hook is definitely hidden until Twine reveals it */
tw-hook[name="optionsHook"] { display: none; }
</style>}{
}
<h3>PHYSICAL EXAM</h3>
Mr. Chen looks chronologically older than his stated age. He appears fatigued and uncomfortable.
<br>
You ask him what brought him in.
<br>
<span style="color: #ddd;">"My belly... it's just so tight and sore,"</span> he mumbles, eyes half-closed. <span style="color: #ddd;">"And I haven't peed much since yesterday. Just feel weak."</span>
<br>
<!-- Round-3 review, P7: this was a plain monospace line; Case 2 shows the same
information as a bedside monitor with abnormal values coloured, and the reviewer asked
for the same here. Same markup as Case 2's, so it inherits the wrap rules that keep a
value from ever being clipped, and tools/check_layout.py measures both.
Colours are the author's: BP amber (soft pressure in an AKI), HR/RR/O2 normal, and
nothing red - which is itself the teaching point, since SBP presents quietly.
Temperature: author decision (round 3, option B) - he stays AFEBRILE at 37.8, shown in
the normal colour. The case's teaching rests on it: Dr. Sato's missed-tap line is "He
was afebrile. That is exactly the point", and the ED handoff and transcript both say
afebrile at 37.8. Do not colour or raise it without rewriting those lines.
Wrapped in { } like Case 2's: without it Harlowe turns each line break in this markup
into a <br>, which padded the box and pushed O2 onto a row of its own at 390px. -->
{<div style="background-color: #000; border: 3px solid #333; padding: 5px 10px; font-family: monospace; color: #00ff00; border-radius: 4px;">
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-end; gap: 2px 12px;">
<span>HR: <strong style="color: #00ff00; font-size: 1.3em; white-space: nowrap;">92</strong></span>
<span>BP: <strong style="color: #FFCC00; font-size: 1.3em; white-space: nowrap;">105/65</strong></span>
</div>
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; margin-top: 2px; gap: 2px 12px; font-size: 0.9em; color: #88ff88;">
<span><span style="white-space: nowrap;">T: 37.8°C</span></span>
<span><span style="white-space: nowrap;">RR: 20</span></span>
<span><span style="white-space: nowrap;">O2: 96%</span></span>
</div>
</div>}
<strong>Examine Patient:</strong>
{
(link: "[Palpate Abdomen]")[
(replace: ?abdo)[
<div style="margin-bottom: 8px;"><strong style="color: #00BFFF">Abdomen:</strong> Distended with fluid wave. Diffusely tender. No rebound. Negative Murphy's sign.</div>
]
]
(link: "[Assess Volume]")[
(replace: ?vol)[
<div style="margin-bottom: 8px;"><strong style="color: #00BFFF">Volume:</strong> Mucous membranes dry. Cap refill 3s. JVP not visible.</div>
]
]
(link: "[Neuro Exam]")[
(replace: ?neuro)[
<div style="margin-bottom: 8px;"><strong style="color: #00BFFF">Neuro:</strong> A/O x2 - Disoriented to year. +Asterixis (flapping tremor) observed.</div>
]
]
<hr style="margin: 15px 0; border-color: #444;">
|abdo>[]
|vol>[]
|neuro>[]
<br>
(link: "Exam Completed - Review Admission Labs")[
(goto: "Case3Labs")
]}{
<!-- 9B-1: the $abxStatus stability/cred penalty was applied HERE as well as in
Case3Calc, so vancomycin cost -15/-10 then -15/-15 = -30 stability / -25 cred
for a single decision. Case3Calc is the point of choice and keeps the penalty;
this duplicate is removed. -->
}
<h3>NURSING COMMUNICATION</h3>
You enter the antibiotic order. The nurse, Sam, looks at the screen.
<br>
{
(if: $abxStatus is "Correct")[
"Okay, <strong>ceftriaxone</strong> verified by pharmacy. That covers the gut flora. I'll hang it now."
]
(else-if: $abxStatus is "Nephrotox")[
"Pharmacy flagged the <strong>piperacillin-tazobactam</strong> for renal adjustment. They changed the frequency to every 8 hours based on his creatinine clearance. I assume that's okay?"
<br><br>
You approve the renal dosing. The order is verified, but you notice the pharmacist added a note: <em>"Review indication."</em>
]
(else-if: $abxStatus is "WrongSpectrum")[
"<strong>Vancomycin?</strong>" Sam pauses. "Does he have MRSA? I thought SBP was usually E. coli. Pharmacy is asking for an indication."
<br>
You override the pharmacy block, citing 'Sepsis.' Sam frowns as she releases the meds.
]
}<br>
Nurse Sam turns back to you. "His blood pressure is soft (95/55) and his creatinine is still 2.1. <strong>Do you want to give him any fluids with the antibiotics?</strong>"
<br>
<strong>Select the fluid resuscitation:</strong>
<br>
{
(link: "25% Albumin (1.5g/kg bolus)")[
(set: $fluidStatus to "Correct")
(set: $stability to (min: 100, $stability + 15))
(set: $cred to (min: 100, $cred + 10))
(set: $cost to $cost + 400)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Albumin (Correct - Prevents Hepatorenal Syndrome) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case3Outcome")
]
(link: "Normal Saline (1 Liter Bolus)")[
(set: $fluidStatus to "NS")
(set: $stability to (max: 0, $stability - 10))
(set: $cred to (max: 0, $cred - 5))
(set: $cost to $cost + 50)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Normal Saline (Error - Ineffective Oncotic Pressure) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case3Outcome")
]
(link: "D5W (Maintenance Rate)")[
(set: $fluidStatus to "D5W")
(set: $stability to (max: 0, $stability - 15))
(set: $cred to (max: 0, $cred - 10))
(set: $cost to $cost + 20)
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered D5W (Error - Hyponatremia Risk) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case3Outcome")
]
}{
(if: $abxStatus is "Correct" and $fluidStatus is "Correct")[
(set: $finalDecision to "Optimal Management")
]
(else-if: $abxStatus is "Correct" and $fluidStatus is not "Correct")[
(set: $finalDecision to "Correct Abx / Wrong Fluid")
]
(else-if: $abxStatus is not "Correct" and $fluidStatus is "Correct")[
(set: $finalDecision to "Wrong Abx / Correct Fluid")
]
(else:)[
(set: $finalDecision to "Wrong Abx / Wrong Fluid - Concerning Management of SBP")
]
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Case 3 Clinical Outcome (" + $finalDecision + ") | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
}
<h4>**07:00 AM UPDATE**</h4>
The sun rises. You hand off the patient to the day team.
{
(if: $missedPara is true)[
<div style="border: 1px solid red; padding: 10px; margin-bottom: 10px; background-color: rgba(255,0,0,0.1);">
<strong>ICU TRANSFER:</strong>
<br>
Because of the initial delay in diagnosis, Mr. Chen required transfer to the ICU for vasopressor support. The ICU fellow asks, "Why wasn't he tapped when he first came in?" You struggle to find an answer.
</div>
]
}
<strong>Antibiotic Efficacy:</strong>
{
(if: $abxStatus is "Correct")[
<span style="color: #00FF00">✔ INFECTION CONTROLLED.</span><br>
The ceftriaxone appropriately covered the gram-negative enteric flora. Mr. Chen is afebrile.
]
(else-if: $abxStatus is "Nephrotox")[
<span style="color: #FFCC00">⚠ STEWARDSHIP FAILURE.</span><br>
The piperacillin-tazobactam cleared the bacteria, but this represents **poor antibiotic stewardship**.
<br><br>
<strong>Feedback:</strong> SBP is a monomicrobial infection caused by enteric flora such as (<em>E. coli, Klebsiella</em>). Broad anaerobic and anti-pseudomonal coverage is unnecessary. You treated the patient, but you contributed to multidrug resistance.
]
(else:)[
<span style="color: #FF0000">✖ SEPSIS PERSISTS.</span><br>
Vancomycin provided zero coverage for the gram-negative organisms causing SBP. He remains febrile and tachycardic.
<br><br>
<strong>Feedback:</strong> SBP is caused by enteric gram-negative flora translocating across the bowel wall (<em>E. coli, Klebsiella</em>). Vancomycin covers gram-positive organisms and has no activity against the organisms actually growing in his ascites. He has now had hours of untreated infection on top of his kidney injury.
]
}
<br><br>
<strong>Renal Perfusion:</strong>
{
(if: $fluidStatus is "Correct")[
<span style="color: #00FF00">✔ KIDNEYS PERFUSED.</span><br>
The albumin expanded his effective arterial blood volume. His urine output has improved.
]
(else-if: $fluidStatus is "NS")[
<span style="color: #FFCC00">⚠ VOLUME OVERLOAD.</span><br>
Normal saline is inferior for preventing hepatorenal syndrome in SBP. It mostly leaked into his abdomen, leading to increased ascites, without fixing the renal vasoconstriction.
]
(else:)[
<span style="color: #FF0000">✖ DANGEROUS ELECTROLYTES.</span><br>
D5W is essentially free water. Cirrhosis is a high-ADH state, so that free water is retained: his sodium fell from 128 to 124 and his confusion worsened.
Feedback: You also missed the albumin. Without oncotic support for his effective arterial blood volume, his kidneys remain at risk of hepatorenal syndrome.
]
}
<br>
<!-- D104: was ->ConfidenceCheck. The confidence grid now sits AFTER Case 3's
feedback, so every route into it goes through Case3Summary and SubmitData
first. See the D55/D104 note in Case3AttendingTapFeedback. -->
[[View Case Summary ->Case3Summary]]{
(set: $historyCase2 to $transcript)
(set: $caseName to "Case 3")
(set: $stability to 90)
(set: $cost to 0)
(set: $optimalCost to 1250)
(set: $exam_count to 0)
(set: $turnCount to 0)
(set: $outcome to "Ongoing")
(set: $failReason to "None")
<!-- 9A-2 reputation persistence: reputation now CARRIES across cases. A single
soft floor keeps a bad case visibly damaging without locking the learner out
of a good ending. Floor = 45, no recovery bump. -->
(set: $cred to (max: 45, $cred))
(set: $fluids to false)
(set: $meds to false)
(set: $abx to "None")
(set: $abxStatus to "None")
(set: $fluidStatus to "None")
(set: $missedPara to false)
(set: $case3Retry to false)
}
<strong>Time:</strong> 03:00 AM
<strong>Location:</strong> Resident Workroom
<br>
You step away from the ICU. The adrenaline from the GI bleeding case is starting to wear off, replaced by a heavy, leaden fatigue.
<br>
The hospital is quiet, save for the rhythmic hum of the HVAC and the distant beeping of telemetry monitors. You wander into the breakroom and find a cold, half-empty pot of coffee. It tastes like burnt mud, but you drink it anyway.
<br>
You sit down and close your eyes for a second, allowing yourself a moment of relief.
<br>
<em>BEEP. BEEP. BEEP.</em>
<br>
The sound cuts through the silence like a knife. Your pager vibrates across the metal table. Another admission.
<br><br>
[[Answer the Page ->Case3Start]]{
(set: $cred to (max: 0, $cred - 25))
(set: $stability to (max: 0, $stability - 20))
(set: $case3Retry to true)
<!-- D28: cap the remediation loop. Restart returns to Case3Decision without resetting
$stability/$cred (deliberate - the penalty persists), so repeated wrong choices
drained reputation to zero with no floor and no limit on attempts. Two attempts
is the cap; after that the attending takes over rather than looping. -->
(set: $case3Attempts to (cond: $case3Attempts is a number, $case3Attempts + 1, 1))
(set: $turnCount to $turnCount + 1)
(if: $fluids is true)[
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: CRITICAL FAIL: Missed Paracentesis (Prioritized Fluids) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
]
(else-if: $meds is true)[
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: CRITICAL FAIL: Missed Paracentesis (Prioritized Diuretics) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
]
(else:)[
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: CRITICAL FAIL: Missed Paracentesis Entirely | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + "]")
]
<div style="max-width: 600px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; border: 2px solid #FF4444; background-color: #222; padding: 25px; border-radius: 10px;">
<h3 style="color: #FF4444; text-align: center; text-transform: uppercase; margin: 0; letter-spacing: 2px;">Critical Error: Loss of Autonomy</h3>
<div style="color: #eee; line-height: 1.5; display: flex; flex-direction: column; gap: 15px;">
<p style="margin: 0;">
Your attending arrives as your shift is ending. She logs into the workstation, her eyes scanning the glowing screen. You watch her click through the <strong>"Results Review"</strong> and <strong>"Flowsheets"</strong> tabs rapidly.
</p>
<p style="margin: 0;">
She stops scrolling and turns her chair to face you.
</p><br>
<div style="border-left: 3px solid #FF4444; padding-left: 15px;">
(if: $fluids is true)[
<p style="margin: 0;">
<strong>"I see you bolused him,"</strong> she says, pointing to the intake record. <strong>"But you can't just resuscitate a patient with cirrhosis and a new kidney injury without finding the source. Where is the fluid analysis from his paracentesis?"</strong>
</p>
]
(else-if: $meds is true)[
<strong>"I see you started diuretics,"</strong> she says, shaking her head. <strong>"You are treating this as simple volume overload. But cirrhosis with ascites and a new kidney injury means that fluid is harboring an infection until you have proven otherwise — and he never had to spike a fever for that to be true."</strong>
]
(else:)[
"We have a patient with decompensated cirrhosis, ascites, and an acute kidney injury." she says, her voice tight. "But I don't see a fluid analysis.<strong> Did you do a paracentesis?"</strong>
<br><br>
<strong>"He was afebrile. That is exactly the point — up to a third of patients with spontaneous bacterial peritonitis never mount a fever. Cirrhosis, ascites and an AKI is the indication. You tap all of them."</strong>
]
</div>
<p style="margin: 0;"><br>
"No," you admit. "I thought the priority was managing his symptoms that brought him into the ER."
</p>
<p style="margin: 0; text-align: center; font-style: italic; color: #FF8888; border-top: 1px solid #555; border-bottom: 1px solid #555; padding: 10px 0;">
"You need to call me before you make decisions such as these... Finish your documentation, sign off to the day team, and go home to get some rest."
</p>
</div>
<div style="text-align: center; color: #ccc; font-size: 0.9em;">
{(if: $case3Attempts < 2)[
You must restart the case and manage the workup correctly.
<br><br>
<span style="display: inline-block; border: 1px solid #FF4444; padding: 10px 20px; border-radius: 5px; cursor: pointer; transition: all 0.2s;">
(link: "RESTART CASE 3")[
(set: $missedPara to false)
(set: $abx to "None")
(set: $fluids to false)
(set: $meds to false)
<!-- FLOW-02: return to the decision point, not the top of the case. Replaying
Case3Exam/Case3Labs re-charged the $150 labs and any imaging, inflating the
cost metric on the remediation path. $cost, $stability and $cred are
deliberately NOT reset - the penalty already applied should persist. -->
(goto: "Case3Decision")
]
</span>
]
(else:)[
<div style="border-left: 4px solid #FFCC00; background: rgba(255,204,0,0.08); padding: 12px; text-align: left;">
(set: $paraByAttending to true)
<strong>Dr. Sato:</strong> "Stop. I am going to do this one with you, because the patient
cannot wait for a third attempt." She gloves up and taps him at the bedside.
<br><br>
"Cirrhosis, ascites, and a rising creatinine. That is the whole indication. He does not
need a fever, he does not need to look septic, and he does not need to fail diuretics
first. Next time, the tap comes before the treatment."
</div>
<br>
<!-- A2 / D51: the antibiotic is chosen in Case3Calc, which sits DOWNSTREAM of the
paracentesis - so a learner capped out here never reached it and $abxStatus stayed
"None". 41 of 80 Case 3 rows on 10 Aug (51%) recorded no antibiotic action at all,
and Case3Outcome then read them the same text as a wrong agent. Dr. Sato has just
given them the diagnosis, so asking them to treat it is the natural beat and it
guarantees the decision is always captured. Deltas are identical to Case3Calc: the
tap failure is already penalised twice over, and the antibiotic is a different
competency. Each option routes via Case3AttendingTapFeedback, which links on to
Case3Summary - see the D55/D104 note below for why that is now the safe target. -->
<div style="border-left: 4px solid #FFCC00; background: rgba(255,204,0,0.08); padding: 12px; text-align: left;">
<strong>Dr. Sato:</strong> "The lab called it back while you were writing. 850 nucleated
cells, 90 percent neutrophils — 765 PMNs. That is spontaneous bacterial peritonitis,
on top of the kidney injury he walked in with."
<br><br>
She snaps the gloves off and drops them in the bin. "Which antibiotic would you like to
start?"
</div>
<br>
<!-- D55, as amended by D104. The INVARIANT is unchanged and is the thing to protect:
ConfidenceCheck is the ONLY place $conf_* is ever set, so every route that reaches
the survey must pass through it. Originally it hung off Case3Outcome alone, which a
learner who misses the paracentesis twice never sees - skipping it left all six
$conf_* unset, which Harlowe prints as 0, so EndSurvey's validation ("Please complete
the Clinical Confidence section.") rejected every submit attempt: no survey row, and
no route to GameConclusion, which is only reachable from EndSurvey.
D104 moved the grid to AFTER Case 3's SubmitData, which strengthens this rather than
risking it: all three Case 3 routes - this one, Case3Outcome and Case3MissedParaFail -
already converge on Case3Summary and then on SubmitData, so there is now exactly one
edge into ConfidenceCheck instead of three. Hence these options link to Case3Summary. -->
(link: "Ceftriaxone 2g IV")[
(set: $abxStatus to "Correct")
(set: $cost to $cost + 200)
(set: $stability to (min: 100, $stability + 10))
(set: $cred to (min: 100, $cred + 5))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Ceftriaxone (Correct SBP Treatment, after attending-performed paracentesis) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case3AttendingTapFeedback")
]
(link: "Piperacillin-Tazobactam 3.375g IV")[
(set: $abxStatus to "Nephrotox")
(set: $cost to $cost + 600)
(set: $stability to (max: 0, $stability - 5))
(set: $cred to (max: 0, $cred - 5))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Piperacillin-Tazobactam (Overkill/Nephrotoxicity Risk, after attending-performed paracentesis) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case3AttendingTapFeedback")
]
(link: "Vancomycin 15mg/kg IV")[
(set: $abxStatus to "WrongSpectrum")
(set: $cost to $cost + 400)
(set: $stability to (max: 0, $stability - 15))
(set: $cred to (max: 0, $cred - 10))
(set: $turnCount to $turnCount + 1)
(set: $transcript to $transcript + " [TURN: " + (text:$turnCount) + " | ACTION: Ordered Vancomycin (Incorrect Spectrum - No Gram Neg Coverage, after attending-performed paracentesis) | STABILITY: " + (text:$stability) + " | CRED: " + (text:$cred) + " | COST: " + (text:$cost) + "]")
(goto: "Case3AttendingTapFeedback")
]
]}
</div>
</div>}{<div style="max-width: 600px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; border: 2px solid #FFCC00; background-color: #222; padding: 25px; border-radius: 10px;">
(set: $outcome to "AttendingTakeover")
<h2 style="color: #FFCC00; text-align: center; text-transform: uppercase; margin: 0;">Attending Intervention</h2>
<div style="color: #eee; line-height: 1.5;">
<p>
Your attending, Dr. Sato, steps between you and the patient. She places a hand on the patient's shoulder and looks at his tongue (tremulous) and his skin (diaphoretic).
</p>
<p>
She turns to you, lowering her voice so the family doesn't hear, but her tone is sharp.
</p>
<p>
<strong>"Doctor, look at him,"</strong> she says, gesturing to the patient picking at the air. <strong>"Tremors, autonomic instability, hallucinations, and a history of drinking. This is classic alcohol withdrawal."</strong>
</p>
</p>
<p>
"Stop looking for zebras. You need to treat the withdrawal before he seizes."
</p>
</div>
<div style="text-align: center; color: #ccc; font-size: 0.9em;">
<span style="display: inline-block; border: 1px solid #FFCC00; padding: 10px 20px; border-radius: 5px;">
(link: "Proceed to Treatment")[
(set: $diagnosis to "Alcohol Withdrawal")
(goto: "Case1Treatment")
]
</span>
</div>
</div>}{
(if: $reviewInitialized is not true)[
(set: $reviewInitialized to true)
(set: $qCount to 1)
<!-- PRELOAD-1: the topic build moved to BoardTopics so the Case 3 results
screen can run it first and warm question 1. Still runs here, and is
still exactly once, for any route that reaches board review without
passing that screen. -->
(display: "BoardTopics")
]
(else:)[
(set: $qCount to $qCount + 1)
]
(set: $targetedMax to (min: 4, $weaknessList's length))
(if: $qCount <= $targetedMax)[
(set: $quizTopic to $weaknessList's ($qCount))
(set: $quizMode to "TARGETED REMEDIATION")
(set: $headerColor to "#8B0000")
]
(else:)[
(set: $genIndex to ($qCount - $targetedMax))
(if: $genIndex > $generalTopics's length)[ (set: $genIndex to 1) ]
(set: $quizTopic to $generalTopics's ($genIndex))
(set: $quizMode to "GENERAL REVIEW")
(set: $headerColor to "#003366")
]
<!-- PRELOAD-2: the topic the NEXT question will use, by the same rule applied to
$qCount + 1. The learner spends the explanation screen reading, which is dead time
worth roughly one generation, so that question is fetched during it. Resolved in
Harlowe rather than in JavaScript so there is one implementation of the rule.
Blank past question 5 - there is no sixth question to warm. -->
(set: _nq to $qCount + 1)
(if: _nq > 5)[ (set: $nextTopic to "") ]
(else-if: _nq <= $targetedMax)[ (set: $nextTopic to $weaknessList's (_nq)) ]
(else:)[
(set: _ngi to (_nq - $targetedMax))
(if: _ngi > $generalTopics's length)[ (set: _ngi to 1) ]
(set: $nextTopic to $generalTopics's (_ngi))
]
}
{
<div id="br-hidden-data" style="display:none;">
<span id="br-mode">$reviewWasTargeted</span>
<span id="br-topic">$quizTopic</span>
<span id="br-topic-next">$nextTopic</span>
<span id="br-session-hint">$sessionID</span>
</div>
<div id="quiz-container" style="max-width: 750px; margin: 0 auto; background-color: #222; color: #eee; border-radius: 4px; border: 1px solid #444; box-shadow: 0 4px 15px rgba(0,0,0,0.5);">
<div style="background-color: (print:$headerColor); color: #fff; padding: 15px; border-radius: 4px 4px 0 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #555;">
<div>
<span style="font-weight: bold; letter-spacing: 1px; text-transform: uppercase;">(print: $quizMode)</span>
<br>
<span style="font-size: 0.7em; color: #ccc; opacity: 0.9;">Question (print: $qCount) of 5</span>
</div>
</div>
<div id="quiz-loading" style="padding: 50px; text-align: center;">
<div style="display: inline-block; width: 30px; height: 30px; border: 3px solid #555; border-top-color: #00BFFF; border-radius: 50%; animation: spin 1s linear infinite;"></div>
<br><br>
<span id="loading-text" style="color: #888; font-size: 0.9em;">Generating Question...</span>
</div>
<div id="quiz-content" style="display: none; padding: 25px;">
<p id="q-text" style="font-size: 1.0em; line-height: 1.6; margin-bottom: 25px; color: #eee;">
</p>
<div id="q-options" style="display: flex; flex-direction: column; gap: 12px;">
</div>
<!-- Student review: a reviewer tapped an answer by accident while scrolling and
had to restart the run. Selecting is now separate from committing. -->
<div id="submit-container" style="display: none; text-align: center; margin-top: 18px;">
<button id="submit-answer-btn" disabled style="padding: 12px 30px; font-size: 1em; font-weight: bold; font-family: inherit; border-radius: 4px; border: 1px solid #555; background: #333; color: #777; cursor: not-allowed; transition: all 0.2s;">Select an answer</button>
</div>
<div id="q-explanation" style="display: none; margin-top: 30px; border: 1px solid #00BFFF; background-color: #1a1a1a; border-radius: 4px;">
<div id="feedback-banner" style="padding: 10px 15px; font-weight: bold; border-bottom: 1px solid #444; border-radius: 4px 4px 0 0;">
</div>
<div style="background-color: rgba(0, 191, 255, 0.1); padding: 8px 15px; font-weight: bold; color: #00BFFF; font-size: 0.8em; text-transform: uppercase; border-bottom: 1px solid #00BFFF;">
Evidence-Based Explanation
</div>
<div id="explain-text" style="padding: 15px; font-size: 0.95em; line-height: 1.5; color: #ddd;"></div>
<div style="padding: 10px 15px; background-color: #222; border-top: 1px solid #444; font-size: 0.85em; color: #888;">
<i class="fa-solid fa-book-medical"></i> <strong>Source:</strong>
<span id="citation-display" style="color: #00BFFF; font-style: italic; margin-left: 5px;">
Loading source...
</span>
</div>
</div>
<div id="nav-buttons" style="display:none; margin-top: 30px; padding-top: 20px; border-top: 1px solid #444;">
<div style="display: flex; justify-content: flex-end; align-items: center; gap: 15px;">
(if: $qCount < 5)[
<div class="btn-tech-outline">
(link-goto: "Next Question ➡", "BoardReview")
</div>
]
(else:)[
<div class="btn-tech-outline">
[[End and Submit Score->SubmitBoardData]]
</div>
]
</div>
</div>
<!-- Escape hatch. Deliberately OUTSIDE #nav-buttons, which stays display:none
until a question has been answered - a learner stuck on a question that
never generates would never see it in there. Hidden until the script
decides the learner is genuinely stuck rather than merely wrong: two
CONSECUTIVE technical failures, or five minutes total in this section.
It exists because Cases 1-3 are already submitted by this point but the
survey, the six confidence ratings and the final evaluation are not, so a
lock-out here would cost the study's primary outcome. -->
(if: $qCount < 5)[
<div id="board-escape" style="display:none; margin-top: 20px; padding: 14px; border: 1px solid #FFCC00; background: rgba(255,204,0,0.08); border-radius: 6px; text-align: center;">
<div style="color: #FFCC00; font-size: 0.9em; margin-bottom: 10px;">
<span id="board-escape-reason">Question generation is having trouble.</span>
<!-- Student review, round 2: "You may continue" read as an invitation to
stop. Asked as a question instead, so choosing to leave is a
deliberate act rather than a suggestion. -->
<br>Do you want to continue to your evaluation without finishing the remaining questions?
</div>
<div class="btn-tech-outline">
[[Skip remaining questions and continue->SubmitBoardData]]
</div>
</div>
]
</div>
</div>
<script>
// 1. Initialize Scores if undefined
// D81: window globals do not survive a reload, but the score has to. Without this a
// reload mid-review reset the running total to 0, and the board row was then posted as
// 0/0 - structurally valid and indistinguishable from a genuine zero. Same
// sessionStorage recovery D16 uses for the player id and 9H-1 for the typed reasoning.
// Init clears both keys at story start, so a second run in the same tab starts clean.
let _bsStored = null, _btStored = null;
try {
_bsStored = sessionStorage.getItem("hns_board_correct");
_btStored = sessionStorage.getItem("hns_board_total");
} catch(e) {}
if(typeof window.boardScore === 'undefined') { window.boardScore = parseInt(_bsStored, 10) || 0; }
if(typeof window.totalQuestions === 'undefined') { window.totalQuestions = parseInt(_btStored, 10) || 0; }
// D79: a fresh question step. Both flags are per-step, not per-session, so they are
// cleared on every entry - including the re-entry that "Next Question" performs.
window.brAnswered = false;
window.brFetching = false;
// 2. Capture Current Review Mode (FIXED)
// We read the hidden span instead of using the raw variable string
window.reviewType = document.getElementById("br-mode").innerText.trim();
// *** ENSURE THIS MATCHES YOUR CLOUDFLARE URL ***
const WORKER_URL = "https://hepatology-backend.f-pradhan.workers.dev";
// 3. Capture Topic (FIXED)
const TOPIC = document.getElementById("br-topic").innerText.trim();
// PRELOAD: cache keys are scoped to the session so two runs in one tab cannot share
// warmed questions. The span is written by SubmitData; fall back to a constant rather
// than failing, since a shared key is still correct within a single session.
const _brSessEl = document.getElementById("br-session-hint");
const BR_SESSION = (_brSessEl ? _brSessEl.innerText.trim() : "") || "s";
const NEXT_TOPIC = (function () {
const el = document.getElementById("br-topic-next");
return el ? el.innerText.trim() : "";
})();
// ---- escape-hatch state -------------------------------------------------
// BoardReview is re-entered for every question, so this state lives in
// sessionStorage rather than a closure: it must survive both the passage change
// and a mid-review reload. A wrong ANSWER is not a failure - only a question that
// could not be generated, validated or delivered counts.
const BR_START_KEY = "hns_board_start";
const BR_FAILS_KEY = "hns_board_fails";
// Student review, round 2: back to 5 minutes. A learner who is stuck at eight minutes
// has already been stuck for three, and the hatch is a way out of a broken generation,
// not a stopwatch on thinking time.
const BR_TIME_LIMIT_MS = 5 * 60 * 1000; // 5 minutes total in board review
const BR_FAIL_LIMIT = 2; // consecutive technical failures
function brRead(key) {
try { return parseInt(sessionStorage.getItem(key) || "0", 10) || 0; } catch (e) { return 0; }
}
function brWrite(key, val) {
try { sessionStorage.setItem(key, String(val)); } catch (e) {}
}
function brStarted() {
let v = brRead(BR_START_KEY);
if (!v) { v = Date.now(); brWrite(BR_START_KEY, v); }
return v;
}
function revealEscape(reason) {
const box = document.getElementById("board-escape");
if (!box) { return; }
const why = document.getElementById("board-escape-reason");
if (why && reason) { why.innerText = reason; }
box.style.display = "block";
}
function noteFailure() {
const n = brRead(BR_FAILS_KEY) + 1;
brWrite(BR_FAILS_KEY, n);
if (n >= BR_FAIL_LIMIT) {
revealEscape("Question generation has failed " + n + " times in a row.");
}
return n;
}
function noteSuccess() { brWrite(BR_FAILS_KEY, 0); }
// Time limit: check on entry and then keep checking, so it fires even if the
// learner is sitting on a question rather than navigating.
(function watchClock() {
const elapsed = Date.now() - brStarted();
if (elapsed > BR_TIME_LIMIT_MS) {
revealEscape("You have been in this section for over eight minutes.");
return;
}
setTimeout(watchClock, Math.min(20000, BR_TIME_LIMIT_MS - elapsed + 500));
})();
// PRELOAD: a generated question is cached under the topic it was generated for, so a
// cache entry can never be served for the wrong topic. sessionStorage rather than a
// window global so the benefit survives a reload, and Init clears the keys at story
// start so a replay in the same tab cannot serve a stale question.
function preKey(topic) { return "hns_bq_" + BR_SESSION + "_" + topic; }
function takeCached(topic) {
if (!topic) { return null; }
try {
const raw = sessionStorage.getItem(preKey(topic));
if (!raw) { return null; }
sessionStorage.removeItem(preKey(topic)); // one use only
return JSON.parse(raw);
} catch (e) { return null; }
}
// Fire-and-forget. A failed warm is SILENT and leaves no cache entry, so the worst
// case is exactly the behaviour before preloading existed: a normal fetch on arrival.
function warmQuestion(topic) {
if (!topic) { return; }
try { if (sessionStorage.getItem(preKey(topic))) { return; } } catch (e) { return; }
if (window.brWarmTopic === topic && window.brWarmPromise) { return; }
window.brWarmTopic = topic;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 45000);
window.brWarmPromise = fetch(WORKER_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic: topic }),
signal: ctrl.signal
}).then(function (r) {
clearTimeout(t);
if (!r.ok) { throw new Error("warm HTTP " + r.status); }
return r.text();
}).then(function (txt) {
const q = JSON.parse(txt);
if (q.error) { throw new Error(q.error); }
// Store only a structurally valid item, so a cache hit can never be the thing
// that puts a malformed question in front of a learner.
const optsOk = Array.isArray(q.options) && q.options.length === 5;
const idxOk = Number.isInteger(q.correct_index) && q.correct_index >= 0 && q.correct_index <= 4;
if (!optsOk || !idxOk) { throw new Error("warm produced a malformed item"); }
try { sessionStorage.setItem(preKey(topic), JSON.stringify(q)); } catch (e) {}
}).catch(function (e) {
console.warn("Board preload for '" + topic + "' did not complete: " + e);
}).then(function () {
if (window.brWarmTopic === topic) { window.brWarmPromise = null; }
});
}
async function fetchQuestion() {
// PRELOAD: serve a warmed question before touching the network or the in-flight
// lock below. renderQuiz still validates it and still applies the duplicate-stem
// check, so a cached item goes through exactly the same gates as a live one.
let cached = takeCached(TOPIC);
if (cached) {
renderQuiz(cached);
return;
}
// A warm for this exact topic may still be in flight - a learner who clicks through
// the explanation faster than one generation takes. Join it rather than issuing a
// duplicate request: two generations for one question costs twice and arrives no
// sooner. Joining is still faster than starting fresh, because it began earlier.
if (window.brWarmTopic === TOPIC && window.brWarmPromise) {
const loadTextEarly = document.getElementById("loading-text");
if (loadTextEarly) { loadTextEarly.innerText = "Finishing your next question..."; }
try { await window.brWarmPromise; } catch (e) {}
cached = takeCached(TOPIC);
if (cached) {
renderQuiz(cached);
return;
}
}
// D79 guard 1 of 2: no in-flight lock meant concurrent calls queued up. The
// Regenerate button on the connection/validation error boxes is the trigger a
// slow link produces, and pressing it while a request is outstanding issued
// several - measured at 7 requests and 4 concurrent for a single question, each
// of which then rendered. Cleared in the finally below, never on a path that
// could strand the learner with a permanently locked fetch.
if (window.brFetching) {
console.warn("Board question already being fetched; ignoring the extra request.");
return;
}
window.brFetching = true;
const loadText = document.getElementById("loading-text");
setTimeout(() => { if(loadText) loadText.innerText = "Generating Question..."; }, 2000);
setTimeout(() => { if(loadText) loadText.innerText = "Reviewing Options..."; }, 8000);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 45000);
try {
const response = await fetch(WORKER_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic: TOPIC }),
signal: controller.signal
});
clearTimeout(timeoutId);
const rawText = await response.text();
if (!response.ok) throw new Error("Server Error " + response.status + ": " + rawText);
if (!rawText || rawText.trim() === "") throw new Error("Server returned empty response.");
let quizData;
try {
quizData = JSON.parse(rawText);
} catch (e) {
console.error("JSON Parse Error. Raw Text:", rawText);
throw new Error("Invalid Data Received.");
}
if (quizData.error) throw new Error(quizData.error);
renderQuiz(quizData);
} catch (error) {
console.error(error);
noteFailure();
let userMsg = "Connection Issue.";
if (error.name === 'AbortError') userMsg = "Request timed out (Server busy).";
else if (error.message) userMsg = "Error: " + error.message;
document.getElementById("quiz-loading").innerHTML = `
<div style='color:#FF4444; border:1px solid #FF4444; padding:15px; background:#330000; border-radius:5px;'>
<i class="fa-solid fa-triangle-exclamation"></i> <strong>Connection Failed</strong><br>
<span style="font-size:0.9em">${userMsg}</span>
</div>
<br>
<button onclick="fetchQuestion()" style="padding:8px 16px; cursor:pointer; background:#444; color:white; border:1px solid #666; margin-right:10px;">Retry</button>
<br><br>
[[Next Question ->BoardReview]]
`;
} finally {
// D79: release the in-flight lock on EVERY exit, success or failure. Without
// this the first request latches it and every later Retry/Regenerate press is
// swallowed - the D56 defect again, a control that silently does nothing.
window.brFetching = false;
}
}
function renderQuiz(data) {
// 9F-2 / USMLE-1: validate the item BEFORE rendering. self_check_passed is the
// model asserting it checked its own key, so a structural guard is still needed.
// Fails safe: does not render, and does not count toward totalQuestions/boardScore.
const optsOk = Array.isArray(data.options) && data.options.length === 5;
const idxOk = Number.isInteger(data.correct_index) && data.correct_index >= 0 && data.correct_index <= 4;
if (!optsOk || !idxOk) {
noteFailure();
document.getElementById("quiz-loading").innerHTML =
"<div style='color:#FF4444; border:1px solid #FF4444; padding:15px; background:#330000; border-radius:5px;'>" +
"<strong>Question failed validation</strong><br><span style=\"font-size:0.9em\">The generated item was malformed. Please regenerate.</span></div>" +
"<br><button onclick=\"fetchQuestion()\" style=\"padding:8px 16px; cursor:pointer; background:#444; color:white; border:1px solid #666;\">Regenerate</button>";
return;
}
// 9F-3 / USMLE-4: regenerate on a near-duplicate stem within a session.
window.askedStems = window.askedStems || [];
const stemHash = String(data.vignette || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().slice(0, 120);
if (stemHash && window.askedStems.indexOf(stemHash) !== -1 && window.askedStems.length < 8) {
window.askedStems.push(stemHash);
// D80: this regenerate runs INSIDE the calling fetchQuestion's try block, so
// window.brFetching is still true and the D79 in-flight lock swallowed it -
// the spinner stayed up with no question, no error box and no Retry button.
// Defer to a macrotask so the caller's finally releases the lock first.
setTimeout(function() { fetchQuestion(); }, 0);
return;
}
if (stemHash) window.askedStems.push(stemHash);
// D79 guard 2 of 2: a late or duplicate response used to render into a question
// step that had ALREADY been answered, which re-enables #q-options and lets the
// same $qCount be answered again - window.totalQuestions and window.boardScore
// both incremented while the header still read "Question 1 of 5". That is where
// the board totals of 6 and 7 in the live data came from. The flag is cleared on
// entry to BoardReview, so each question step accepts exactly one answer.
if (window.brAnswered) {
console.warn("Question already answered at this step; ignoring a late render.");
return;
}
noteSuccess(); // a delivered question clears the consecutive-failure count
document.getElementById("quiz-loading").style.display = "none";
document.getElementById("quiz-content").style.display = "block";
// PRELOAD-4 (student review, round 2: "the last two still took 8-9 seconds").
// The warm used to start when the learner ANSWERED, so its head start was only the
// time spent reading the explanation. A fast reader gave it about a second of a
// ~9.5s generation and still waited. Starting it HERE, the moment the question is
// on screen, converts reading the vignette, weighing five options and clicking
// Submit into warm-up as well - several times more head start, for the same one
// request per question. Deferred to a macrotask so it cannot delay first paint of
// the question the learner is waiting on; warmQuestion is idempotent per topic
// (it returns early on an existing cache entry or an in-flight warm for the same
// topic), so the answer-time call below is now a harmless no-op on the common path
// and remains the real trigger only if this one was skipped.
setTimeout(function () { warmQuestion(NEXT_TOPIC); }, 0);
// SEC: model output is inserted as HTML here. The board prompt is built from the
// learner's own weak topics, so a learner can influence what the model writes -
// an <img onerror=...> that survived into innerHTML would run in the page that
// holds their session. Escape the model's text; the markup around it is ours.
const esc = s => String(s == null ? "" : s)
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
.replace(/"/g, """).replace(/'/g, "'");
// Student review, round 2: "the lab values all ran together". The model emits a lab
// panel one value per line, but esc() output goes into innerHTML, where a newline
// is just whitespace - so "Na 131\nK 3.4\nCr 2.1" rendered as one run-on line.
// Escape first, THEN restore the model's own line breaks as markup of ours; doing
// it in this order means a <br> the model tried to send is still inert text.
const escLines = s => esc(s).replace(/\r\n|\r|\n/g, "<br>");
// The lead-in was separated from the vignette by a bare <br><br>, which the model
// could defeat simply by ending the vignette with its own question sentence - and
// then "Q:" ran straight into the stem. As its own block with a real margin and a
// rule above it, the separation holds whatever the model returns.
// Round-3 review, P5: lab results arrive in their own "labs" field and render as a
// panel under the vignette. A structured field does not depend on the model choosing
// to break its prose into lines, which the round-2 fix did. Every name, value and unit goes
// through esc() - these are model output, as open to injection as the vignette.
// A reply without the field (an older Worker) renders exactly as before.
const labRows = Array.isArray(data.labs)
? data.labs.filter(l => l && l.name && l.value !== undefined && l.value !== "")
: [];
const labsHtml = labRows.length
? "<div class='bq-labs' style='margin-top:14px; border:1px solid #333; border-radius:4px; overflow:hidden;'>" +
"<div style='background:#1c1c1c; color:#00BFFF; font-size:0.78em; font-weight:bold; letter-spacing:1px; padding:6px 10px;'>LABORATORY STUDIES</div>" +
labRows.map(l =>
"<div class='bq-lab' style='display:flex; flex-wrap:wrap; justify-content:space-between; gap:2px 14px; padding:5px 10px; border-top:1px solid #2a2a2a;'>" +
"<span style='color:#bbb;'>" + esc(l.name) + "</span>" +
"<span style='color:#fff; font-family:monospace; white-space:nowrap;'>" +
esc(l.value) + (l.unit ? " " + esc(l.unit) : "") + "</span></div>"
).join("") +
"</div>"
: "";
document.getElementById("q-text").innerHTML =
"<div><strong style='color:#00BFFF'>Vignette:</strong> " + escLines(data.vignette) + "</div>" +
labsHtml +
"<div style='margin-top:16px; padding-top:12px; border-top:1px solid #333;'>" +
"<strong style='color:#00BFFF'>Q:</strong> " + escLines(data.question_lead_in) + "</div>";
let cleanTitle = data.reference_title || "AASLD Practice Guidelines";
cleanTitle = cleanTitle.replace(/\.pdf.*/i, "");
cleanTitle = cleanTitle.replace(/_/g, " ").trim();
document.getElementById("citation-display").innerText = cleanTitle;
const optionsContainer = document.getElementById("q-options");
optionsContainer.innerHTML = "";
// Selection state for this question only. Reset on every render, so a regenerated
// question never inherits the previous selection.
let selectedIndex = null;
const submitContainer = document.getElementById("submit-container");
const submitBtn = document.getElementById("submit-answer-btn");
submitBtn.disabled = true;
submitBtn.innerText = "Select an answer";
submitBtn.style.cursor = "not-allowed";
submitBtn.style.background = "#333";
submitBtn.style.color = "#777";
submitBtn.style.borderColor = "#555";
data.options.forEach((opt, index) => {
const btn = document.createElement("div");
btn.innerHTML = `<span style="font-weight:bold; color: #00BFFF; margin-right:10px;">${String.fromCharCode(65+index)}</span> ${esc(opt)}`;
btn.style.cssText = "padding: 15px; border: 1px solid #555; border-radius: 4px; cursor: pointer; background: #333; color: #eee; transition: all 0.2s; font-family: inherit; font-size: 0.95em;";
btn.onmouseover = function() { if(!this.disabled) this.style.backgroundColor = "#444"; }
btn.onmouseout = function() { if(!this.disabled) this.style.backgroundColor = "#333"; }
btn.onclick = () => {
// Selecting only. Nothing is scored, nothing is counted, and the choice can
// be changed until Submit is pressed - the guardrail a reviewer asked for
// after tapping an answer by accident while scrolling.
// D79 still applies: once the step is answered, selection is frozen.
if (window.brAnswered) { return; }
selectedIndex = index;
Array.from(optionsContainer.children).forEach((child, ci) => {
child.style.border = (ci === index) ? "2px solid #00BFFF" : "1px solid #555";
child.style.background = (ci === index) ? "rgba(0, 191, 255, 0.12)" : "#333";
});
submitBtn.disabled = false;
submitBtn.innerText = "Submit Answer";
submitBtn.style.cursor = "pointer";
submitBtn.style.background = "#00BFFF";
submitBtn.style.color = "#000";
submitBtn.style.borderColor = "#00BFFF";
};
optionsContainer.appendChild(btn);
});
submitContainer.style.display = "block";
submitBtn.onclick = () => {
if (selectedIndex === null) { return; }
// D79: mark this step answered before anything else, so a response still
// in flight cannot render a second answerable question underneath it.
if (window.brAnswered) { return; }
window.brAnswered = true;
const index = selectedIndex;
const btn = optionsContainer.children[index];
// INCREMENT TOTAL QUESTIONS ATTEMPTED HERE
window.totalQuestions++;
// D81: mirror to sessionStorage on every answer, so a reload recovers the
// running score instead of silently restarting it at zero.
try { sessionStorage.setItem("hns_board_total", String(window.totalQuestions)); } catch(e) {}
submitContainer.style.display = "none";
Array.from(optionsContainer.children).forEach(child => {
child.style.pointerEvents = "none";
child.disabled = true;
child.style.opacity = "0.6";
});
const banner = document.getElementById("feedback-banner");
if(index === data.correct_index) {
window.boardScore++;
try { sessionStorage.setItem("hns_board_correct", String(window.boardScore)); } catch(e) {}
btn.style.background = "rgba(0, 255, 0, 0.15)";
btn.style.border = "1px solid #00FF00";
btn.style.opacity = "1";
banner.innerHTML = '<span style="color: #00FF00;">✅ CORRECT!</span>';
banner.style.backgroundColor = "rgba(0, 255, 0, 0.1)";
banner.style.borderColor = "#00FF00";
} else {
btn.style.background = "rgba(255, 0, 0, 0.15)";
btn.style.border = "1px solid #FF4444";
optionsContainer.children[data.correct_index].style.background = "rgba(0, 255, 0, 0.15)";
optionsContainer.children[data.correct_index].style.border = "1px solid #00FF00";
optionsContainer.children[data.correct_index].style.opacity = "1";
banner.innerHTML = '<span style="color: #FF4444;">❌ INCORRECT.</span> <span style="font-weight:normal; font-size:0.9em; color:#ccc;">See the explanation below.</span>';
banner.style.backgroundColor = "rgba(255, 0, 0, 0.1)";
banner.style.borderColor = "#FF4444";
}
// PRELOAD-2: the learner now reads the explanation, which measured at
// roughly the length of one generation. Warm the next question during it
// so "Next Question" lands on a rendered item instead of a spinner.
warmQuestion(NEXT_TOPIC);
document.getElementById("q-explanation").style.display = "block";
document.getElementById("explain-text").innerHTML = escLines(data.explanation);
document.getElementById("nav-buttons").style.display = "block";
};
}
// Both Retry buttons are rendered as HTML with onclick="fetchQuestion()", which
// resolves against the GLOBAL scope - but this script runs inside a closure, so the
// function was never reachable from them. Clicking Retry threw
// "ReferenceError: fetchQuestion is not defined" and Harlowe replaced the whole page
// with its crash backdrop ("this page's code has got itself in a mess"), a hard stop
// for the learner. Exposing it deliberately keeps those buttons working.
window.fetchQuestion = fetchQuestion;
fetchQuestion();
</script>
<style>
@keyframes spin { to { transform: rotate(360deg); } }
.btn-tech-outline tw-link, .btn-tech-outline a { border: none !important; text-decoration: none !important; font-weight: bold; letter-spacing: 1px; transition: color 0.2s; font-family: inherit; }
.btn-tech-outline { display: inline-block; background-color: #333; border: 1px solid #555; padding: 12px 24px; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; font-family: inherit; }
.btn-tech-outline tw-link, .btn-tech-outline a { color: #ddd !important; }
.btn-tech-outline:hover { border-color: #00BFFF; background-color: #444; box-shadow: 0 0 8px rgba(0, 191, 255, 0.2); }
.btn-tech-outline:hover tw-link, .btn-tech-outline:hover a { color: #00BFFF !important; }
</style>
}{<div id="board-hidden-data" style="display:none;">
<span id="board-session-id">$sessionID</span>
<!-- D77: this is the span SubmitBoardData actually posts. It used to render $quizMode,
which is the mode of the LAST question - and since $targetedMax caps at the four
topics $weaknessList can hold, question 5 is always GENERAL REVIEW. $reviewWasTargeted
is captured once at entry to BoardReview and answers the question the column is named
for. Changing BoardReview's own #br-mode span was not enough: that one only feeds
window.reviewType, which this passage does not read. -->
<span id="board-review-type">$reviewWasTargeted</span>
<span id="board-player-id">$playerID</span>
</div>
<div style="
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 0;
font-family: sans-serif;
color: #00BFFF;">
<i class="fa-solid fa-cloud-arrow-up fa-fade" style="font-size: 4em; margin-bottom: 25px;"></i>
<h4 style="margin: 0 0 10px 0; letter-spacing: 1px;">SAVING RESULTS...</h4>
<span id="status-text" style="color: #888; font-size: 0.9em;">Syncing with secure database</span>
</div>
<div id="auto-nav" style="display:none; text-align:center;">
[[EndSurvey]]
</div>
<script>
setTimeout(function() {
const FORM_URL = "https://docs.google.com/forms/d/e/1FAIpQLSfjS-eNL1lzxO_zoFtRCYoaxzFKeO9MR_3amvKLDx5H8gui2A/formResponse";
const ENTRY_SESSION = "entry.1983371625";
const ENTRY_CASE = "entry.2131634372";
const ENTRY_BOARD_CORRECT = "entry.707887532";
const ENTRY_BOARD_TOTAL = "entry.1351404368";
const ENTRY_REVIEW_TYPE = "entry.887217582";
// 1. Get Session ID
const sessEl = document.getElementById("board-session-id");
const sessionID = sessEl ? sessEl.innerText.trim() : "error_no_session";
// 2. Get Review Type
const revEl = document.getElementById("board-review-type");
let reviewType = revEl ? revEl.innerText.trim() : "General Review";
if (!reviewType || reviewType === "") reviewType = "General Review";
// 3. Get Player ID
let playerID = "guest_unknown";
// D16: fall back to the stored id if a reload wiped the window global.
let _storedPid2 = "";
try { _storedPid2 = sessionStorage.getItem("hns_playerid") || ""; } catch(e) {}
const _ok = v => typeof v === "string" && v && v !== "loadingid" && v !== "loadingID" && v !== "undefined";
if (_ok(window.playerID)) {
playerID = window.playerID;
} else if (_ok(_storedPid2)) {
playerID = _storedPid2;
}
// 4. Retrieve Global Score Variables
const scoreCorrect = (typeof window.boardScore !== 'undefined') ? window.boardScore : 0;
const scoreTotal = (typeof window.totalQuestions !== 'undefined') ? window.totalQuestions : 0;
function submitBoardData() {
const formData = new FormData();
formData.append(ENTRY_SESSION, sessionID);
formData.append("entry.919823415", playerID);
formData.append(ENTRY_CASE, "Board Review Data");
// These four columns are the per-case numeric measures (stability, reputation,
// cost, reasoning_score). A board-review row has no value for any of them, and
// writing "0" made a structural blank indistinguishable from a genuine score of
// zero - the same failure T2 fixed on the AI path. Any mean taken over the
// column without first filtering on case type was pulled toward zero by one
// row per participant. Send empty so the cells stay numeric-or-blank and drop
// out of analysis on their own.
formData.append("entry.1048306896", "");
formData.append("entry.1221637260", "");
formData.append("entry.1523741928", "");
formData.append("entry.1996926996", "");
formData.append("entry.1945997268", "Board Review Completed");
formData.append("entry.1964375275", "N/A");
formData.append(ENTRY_BOARD_CORRECT, scoreCorrect);
formData.append(ENTRY_BOARD_TOTAL, scoreTotal);
formData.append(ENTRY_REVIEW_TYPE, reviewType);
// #auto-nav is display:none and holds the ONLY way out of this passage, so every
// exit funnels through proceed(): it clicks the hidden link if it is still there,
// and otherwise reveals it. The old code dereferenced #auto-nav without a guard,
// which threw once the passage had already been replaced.
let navigated = false;
function proceed(statusText) {
if (navigated) { return; }
navigated = true;
const nav = document.getElementById("auto-nav");
const status = document.getElementById("status-text");
const nextLink = nav ? nav.querySelector("tw-link, a") : null;
if (nextLink) { nextLink.click(); return; }
if (nav) { nav.style.display = "block"; }
if (status) { status.innerText = statusText; }
}
// D81: SubmitData got a reload duplicate guard in D75; this path never did, and
// Harlowe re-executes this script on a reload. Reproduced: a reload inside the ~1s
// window before proceed() navigates away wrote a SECOND row - and because the score
// lives in window globals a reload wipes, that row read 0/0, which is structurally
// valid and indistinguishable from a genuine zero. A board review happens once per
// session, so the session id alone is a sufficient key; no transcript hash needed.
//
// Armed in .then(), NEVER before the POST. Under mode:"no-cors" the promise resolves
// when a response is received and rejects only on a network failure, so .then() is
// the one moment we know the row reached Google. Arming earlier is exactly what cost
// a live row in D75: a reload during a failing POST suppressed the re-send.
//
// It sits below proceed() deliberately - proceed() is hoisted but reads `navigated`,
// a let declared just above, so calling it any earlier hits the temporal dead zone.
let _boardKey = "";
try {
_boardKey = "hns_board_sent_" + sessionID;
if (sessionStorage.getItem(_boardKey)) {
console.warn("Duplicate board submission suppressed (reload).");
proceed("Data Saved. Click below to continue.");
return;
}
} catch (e) { _boardKey = ""; }
// Watchdog. mode:"no-cors" resolves for both success and rejection, but a HANG
// settles neither branch - and with the link hidden the learner is then stranded
// on "SAVING RESULTS..." with no way to reach the survey or their evaluation.
const navWatchdog = setTimeout(function() {
proceed("Still saving. Click below to continue.");
}, 8000);
// A chaos run lost this row to net::ERR_CONNECTION_CLOSED. proceed() kept the
// learner moving, which was the point, but the board score itself was gone.
// Same retry as the case rows: a rejected fetch got no response, so retrying
// cannot duplicate a row Google already accepted.
(function postBoard(attempt) {
fetch(FORM_URL, { method: "POST", mode: "no-cors", body: formData })
.then(() => {
// D81: the row has reached Google - this is the only safe moment to block
// a re-send. The residual window (a reload after the request leaves but
// before it resolves) now fails toward a duplicate rather than a loss,
// which is the right direction: a duplicate is droppable at analysis,
// a missing row is invisible.
try { if (_boardKey) { sessionStorage.setItem(_boardKey, "1"); } } catch (e) {}
clearTimeout(navWatchdog);
setTimeout(() => { proceed("Data Saved. Click below to continue."); }, 1000);
})
.catch(err => {
console.warn("Board POST attempt " + attempt + " failed: " + err);
if (attempt < 3) {
setTimeout(function() { postBoard(attempt + 1); }, attempt * 1500);
return;
}
clearTimeout(navWatchdog);
console.error("Board POST failed after 3 attempts; this row is lost.");
proceed("Error saving. Click below to continue.");
});
})(1);
}
submitBoardData();
}, 200);
</script>}{<div style="max-width: 600px; margin: 0 auto; padding: 20px; background: #1a1a1a; border: 1px solid #333; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.5);">
<h2 style="color: #00BFFF; text-align: center; margin-bottom: 20px;">
<i class="fa-solid fa-user-graduate"></i> <br> Learner Profile
</h2>
<p style="color: #aaa; text-align: center; font-size: 0.9em; margin-bottom: 30px;">
Before we begin the simulation, please complete the following demographics questions.
<br><em>Your responses are confidential and de-identified. A random session ID links your entries but is not tied to your name.</em>
</p>
<form id="demo-form" style="display: flex; flex-direction: column; gap: 15px;">
<label style="color: #eee;">Gender Identity</label>
<select id="in-gender" style="padding: 10px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
<option value="" disabled selected>Select...</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Non-Binary">Non-Binary</option>
<option value="Prefer not to say">Prefer not to say</option>
</select>
<label style="color: #eee;">Medical School Location (State)</label>
<select id="in-state" style="padding: 10px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
<option value="" disabled selected>Select State...</option>
<option value="AL">Alabama</option><option value="AK">Alaska</option><option value="AZ">Arizona</option>
<option value="AR">Arkansas</option><option value="CA">California</option><option value="CO">Colorado</option>
<option value="CT">Connecticut</option><option value="DE">Delaware</option><option value="FL">Florida</option>
<option value="GA">Georgia</option><option value="HI">Hawaii</option><option value="ID">Idaho</option>
<option value="IL">Illinois</option><option value="IN">Indiana</option><option value="IA">Iowa</option>
<option value="KS">Kansas</option><option value="KY">Kentucky</option><option value="LA">Louisiana</option>
<option value="ME">Maine</option><option value="MD">Maryland</option><option value="MA">Massachusetts</option>
<option value="MI">Michigan</option><option value="MN">Minnesota</option><option value="MS">Mississippi</option>
<option value="MO">Missouri</option><option value="MT">Montana</option><option value="NE">Nebraska</option>
<option value="NV">Nevada</option><option value="NH">New Hampshire</option><option value="NJ">New Jersey</option>
<option value="NM">New Mexico</option><option value="NY">New York</option><option value="NC">North Carolina</option>
<option value="ND">North Dakota</option><option value="OH">Ohio</option><option value="OK">Oklahoma</option>
<option value="OR">Oregon</option><option value="PA">Pennsylvania</option><option value="RI">Rhode Island</option>
<option value="SC">South Carolina</option><option value="SD">South Dakota</option><option value="TN">Tennessee</option>
<option value="TX">Texas</option><option value="UT">Utah</option><option value="VT">Vermont</option>
<option value="VA">Virginia</option><option value="WA">Washington</option><option value="WV">West Virginia</option>
<option value="WI">Wisconsin</option><option value="WY">Wyoming</option>
<option value="International">International / Non-US</option>
</select>
<label style="color: #eee;">Anticipated Grad Year</label>
<select id="in-year" style="padding: 10px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
<option value="" disabled selected>Select Year...</option>
<option value="2026">2026</option>
<option value="2027">2027</option>
<option value="2028">2028</option>
<option value="2029">2029</option>
<option value="2030+">2030 or later</option>
</select>
<label style="color: #eee;">Have you started clinical rotations?</label>
<div style="display: flex; gap: 20px; color: #ccc;">
<label><input type="radio" name="rotations" value="Yes"> Yes</label>
<label><input type="radio" name="rotations" value="No"> No</label>
</div>
<label style="color: #eee;">Intended Specialty (if known, or type unknown)</label>
<input type="text" id="in-specialty" placeholder="e.g. Family Medicine, General Surgery, Hepatology, Undecided..."
style="padding: 10px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
</form>
<div id="error-msg" style="color: #ff4444; margin-top: 15px; display: none; text-align: center;">
Please answer all questions to proceed.
</div>
<button onclick="saveDemographics()"
style="margin-top: 25px; width: 100%; padding: 15px; background: #00BFFF; color: #000; font-weight: bold; border: none; border-radius: 5px; cursor: pointer; font-size: 1.1em; transition: background 0.3s;">
Begin Simulation <i class="fa-solid fa-arrow-right"></i>
</button>
</div>
<div id="start-hook" style="display: none;">
(link: "StartGameHook")[ (goto: "Start") ]
</div>
<script>
// Live-test fix (same defect as the three reasoning checkpoints): Harlowe's engine
// runs passage.find("input, textarea").first().focus() 100ms after every passage
// render and re-applies it when the transition ends. Focusing a field scrolls it
// into view, so this page opened part-way down with the learner landing below the gender, state and training-year questions (here the focused field is the first ROTATIONS radio, where stray arrow keys would also change the answer).
// Refuse the programmatic focus until the learner actually reaches for the field:
// a pointer press on it, or any keypress (which is what tabbing in looks like),
// marks real engagement first, so mouse and keyboard entry both still work.
const scrollBox = document.querySelector("#demo-form input, #demo-form textarea");
if (scrollBox) {
let engaged = false;
const engage = function() { engaged = true; };
["pointerdown", "mousedown", "touchstart"].forEach(function(ev) {
scrollBox.addEventListener(ev, engage, true);
});
document.addEventListener("keydown", engage, true);
scrollBox.addEventListener("focus", function() {
if (engaged) { return; }
scrollBox.blur();
window.scrollTo(0, 0);
});
}
window.scrollTo(0, 0);
window.saveDemographics = function() {
// 1. Grab Values
const gender = document.getElementById("in-gender").value;
const state = document.getElementById("in-state").value;
const year = document.getElementById("in-year").value;
let rotations = "";
const rotYes = document.querySelector('input[name="rotations"][value="Yes"]');
const rotNo = document.querySelector('input[name="rotations"][value="No"]');
if(rotYes && rotYes.checked) rotations = "Yes";
if(rotNo && rotNo.checked) rotations = "No";
const specialty = document.getElementById("in-specialty").value.trim();
// 2. Validate
if(gender === "" || state === "" || year === "" || rotations === "" || specialty === "") {
document.getElementById("error-msg").style.display = "block";
return;
}
// 3. Save to Session Storage
const demoData = {
gender: gender,
state: state,
year: year,
rotations: rotations,
specialty: specialty
};
sessionStorage.setItem("medStudentDemoData", JSON.stringify(demoData));
// 4. TRIGGER NAVIGATION
// UX-04: target the hook by a stable container id rather than matching on
// link text, which broke if the label ever changed. The container is
// display:none, so it is out of the tab order and cannot be reached by a
// learner tabbing through the form (it previously was).
const hook = document.querySelector("#start-hook tw-link");
if (hook) {
hook.click();
} else {
console.error("Navigation hook '#start-hook tw-link' not found.");
document.getElementById("error-msg").innerText =
"Something went wrong starting the simulation. Please reload the page.";
document.getElementById("error-msg").style.display = "block";
}
};
</script>}{
<!-- PRELOAD-1: this block used to live inside BoardReview's first-entry hook, which
meant the question topics did not exist until the learner had already arrived and
was watching a spinner. Every input to it - $diagnosisErrors, $choice, $missedPara,
$fluidStatus, $abxStatus - is final by the end of Case 3, so it can run earlier and
let the first question be generated while the learner reads their Case 3 feedback.
Displayed from BOTH the Case 3 results screen and BoardReview, and guarded by
$topicsPrepared so it runs exactly once however the learner gets here. Kept as one
shared passage rather than copied to the two call sites, so the weakness rules
cannot drift apart. -->
(if: $topicsPrepared is not true)[
(set: $topicsPrepared to true)
(set: $weaknessList to (a:))
(if: $diagnosisErrors > 0)[ (if: not ($weaknessList contains "Hepatic Encephalopathy"))[(set: $weaknessList to $weaknessList + (a: "Hepatic Encephalopathy"))] ]
(if: $choice is "haloperidol")[ (if: not ($weaknessList contains "Alcohol Withdrawal"))[(set: $weaknessList to $weaknessList + (a: "Alcohol Withdrawal"))] ]
(if: $missedPara is true)[ (if: not ($weaknessList contains "Spontaneous Bacterial Peritonitis"))[(set: $weaknessList to $weaknessList + (a: "Spontaneous Bacterial Peritonitis"))] ]
(if: $fluidStatus is "NS" or $fluidStatus is "D5W")[ (if: not ($weaknessList contains "Albumin in Cirrhosis"))[(set: $weaknessList to $weaknessList + (a: "Albumin in Cirrhosis"))] ]
(if: $abxStatus is "Nephrotox" or $abxStatus is "WrongSpectrum")[ (if: not ($weaknessList contains "Spontaneous Bacterial Peritonitis"))[(set: $weaknessList to $weaknessList + (a: "Spontaneous Bacterial Peritonitis"))] ]
<!-- ONE definition of the pool, used by both the build below and the empty-list
fallback. The literal list used to be written out twice, so adding or renaming a
topic in one place and not the other would have left the fallback quietly serving
a different set - a divergence nothing would report, because the fallback only
fires for a learner with four weaknesses. A story variable rather than a _temp:
the fallback sits in a hook nested inside this one, and _temp scoping across hook
boundaries is exactly the trap that produced the clinical-rating bug. -->
<!-- The last entry is new, at author direction: SAAG interpretation. It is phrased the
way the AASLD ascites guidance phrases it, because this string is sent to the
Worker verbatim and becomes the Pinecone retrieval query - the wording decides
which guideline passage the question gets built from, not just what the prompt
calls the topic. There is no topic allowlist in the Worker, so this line is the
entire change. -->
(set: $topicPool to (a:
"Hepatic Encephalopathy",
"Alcohol Withdrawal",
"Variceal Bleeding",
"Albumin in Cirrhosis",
"Spontaneous Bacterial Peritonitis",
"Serum-Ascites Albumin Gradient (SAAG)"
))
(set: $generalTopics to (shuffled: ...$topicPool))
<!-- Student review: the general list was built independently of $weaknessList, so a
topic could appear in BOTH - a learner with an SBP weakness could be asked about
SBP twice in one five-question session, which a reviewer reported as "Q5 is a
repeat of Q2". Drop anything already queued as a targeted topic. Only the
first $targetedMax weaknesses are actually asked, but excluding the whole list is
simpler and cannot under-exclude. If that empties the list, fall back to the full
shuffled set rather than leaving no topic at all. -->
(set: $generalTopics to $generalTopics - $weaknessList)
(if: $generalTopics's length is 0)[
(set: $generalTopics to (shuffled: ...$topicPool))
]
<!-- D77: what gets RECORDED is decided here, once, at entry - not read off the last
question. $quizMode changes per question, and because $targetedMax caps at the
four topics $weaknessList can ever hold, question 5 is always GENERAL REVIEW. So
the submitted value used to say GENERAL for every completed review and TARGETED
only when the learner escaped early: all 34 TARGETED rows in the sheet have a
board total below 5, all 49 completed reviews say GENERAL. It recorded early
escape, not remediation. This variable answers the question the column is named
for - did this learner enter board review with any identified weakness? -->
(set: $reviewWasTargeted to (cond: $weaknessList's length > 0,
"TARGETED REMEDIATION", "GENERAL REVIEW"))
<!-- PRELOAD-1: the topic question 1 will use, resolved here so the prefetch and
BoardReview cannot disagree about what to ask. Mirrors the per-question rule
below for $qCount = 1: the first weakness if there is one, else the first
general topic. -->
(if: $weaknessList's length > 0)[ (set: $firstTopic to $weaknessList's 1st) ]
(else:)[ (set: $firstTopic to $generalTopics's 1st) ]
]
}{
<!-- PRELOAD-3: displayed from each case summary screen. Those passages append their
END SUMMARY turn record on entry and SubmitData never modifies the case state, so
everything the feedback prompt needs is already final here - the warm scores the
exact transcript the Form row will carry. One shared passage rather than three
copies, for the same reason the call itself is shared. -->
<div id="fw-data" style="display:none;">
<span id="fw-case">$caseName</span>
<span id="fw-transcript">$transcript</span>
<span id="fw-stability">$stability</span>
<span id="fw-fail">$failReason</span>
<span id="fw-session">$sessionID</span>
</div>
}<script>
(function () {
try {
if (!window.hnsFeedback) { return; }
var g = function (id) {
var el = document.getElementById(id);
return el ? el.innerText.trim() : "";
};
var caseName = g("fw-case");
var transcript = g("fw-transcript");
if (!caseName || !transcript) { return; }
// Fire and forget. A failure here is silent and leaves no cache entry, so SubmitData
// simply makes the call itself, exactly as before preloading existed.
window.hnsFeedback({
caseName: caseName,
transcript: transcript,
stability: g("fw-stability"),
failReason: g("fw-fail") || "None",
sessionID: g("fw-session")
}).then(function () {
console.log("Dr. Sato feedback preloaded for " + caseName);
}).catch(function (e) {
console.warn("Feedback preload skipped: " + e);
});
} catch (e) {
console.warn("Feedback preload skipped: " + e);
}
})();
</script>{
<!-- D51b(a): the missed-tap path runs Case3MissedParaFail -> here -> Case3Summary and
never touches Case3Outcome, so the antibiotic decision A2 added got no feedback at
all - the learners who most need the teaching point were the only ones not given
it. These three paragraphs mirror Case3Outcome's "Antibiotic Efficacy" block; if
that text is ever changed, change it here too.
The only exit is Case3Summary. Since D104 that is what carries the run on to
SubmitData and then to ConfidenceCheck, which is still the sole place $conf_* is
set (D55) - this passage must not link past it. -->
}
<h3>ANTIBIOTIC CHOICE</h3>
{
(if: $abxStatus is "Correct")[
<span style="color: #00FF00">✔ INFECTION CONTROLLED.</span><br>
The ceftriaxone appropriately covered the gram-negative enteric flora. Mr. Chen is afebrile.
]
(else-if: $abxStatus is "Nephrotox")[
<span style="color: #FFCC00">⚠ STEWARDSHIP FAILURE.</span><br>
The piperacillin-tazobactam cleared the bacteria, but this represents **poor antibiotic stewardship**.
<br><br>
<strong>Feedback:</strong> SBP is a monomicrobial infection caused by enteric flora such as (<em>E. coli, Klebsiella</em>). Broad anaerobic and anti-pseudomonal coverage is unnecessary. You treated the patient, but you contributed to multidrug resistance.
]
(else:)[
<span style="color: #FF0000">✖ SEPSIS PERSISTS.</span><br>
Vancomycin provided zero coverage for the gram-negative organisms causing SBP. He remains febrile and tachycardic.
<br><br>
<strong>Feedback:</strong> SBP is caused by enteric gram-negative flora translocating across the bowel wall (<em>E. coli, Klebsiella</em>). Vancomycin covers gram-positive organisms and has no activity against the organisms actually growing in his ascites. He has now had hours of untreated infection on top of his kidney injury.
]
}
<br>
<div style="text-align: center;">[[Hand off and review the case ->Case3Summary]]</div>