diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index 766aadf..6ee4898 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -135,6 +135,48 @@ public sealed class CcsCaseTests Assert.Empty(CcsCaseValidator.ValidateImport(ValidImport(), new HashSet())); } + [Fact] + public void InteractiveInterviewRequiresAReachableAcyclicGraph() + { + var request = ValidImport(); + request.Cases[0].InitialInterview = ValidInterview(); + Assert.Empty(CcsCaseValidator.ValidateImport(request, new HashSet())); + + request.Cases[0].InitialInterview.Nodes[0].Choices[0].NextNodeId = "missing"; + var errors = CcsCaseValidator.ValidateImport(request, new HashSet()); + Assert.Contains(errors, value => value.Contains("unknown next node", StringComparison.OrdinalIgnoreCase)); + + request = ValidImport(); + request.Cases[0].InitialInterview = ValidInterview(); + request.Cases[0].InitialInterview.Nodes[1].IsTerminal = false; + request.Cases[0].InitialInterview.Nodes[1].Choices = [new() { Id = "loop", Text = "Start over", NextNodeId = "greeting" }]; + errors = CcsCaseValidator.ValidateImport(request, new HashSet()); + Assert.Contains(errors, value => value.Contains("cycles", StringComparison.OrdinalIgnoreCase)); + + request = ValidImport(); + request.Cases[0].InitialInterview = ValidInterview(); + request.Cases[0].InitialInterview.Nodes.Add(new() { Id = "orphan", PatientText = "Unreachable", IsTerminal = true }); + errors = CcsCaseValidator.ValidateImport(request, new HashSet()); + Assert.Contains(errors, value => value.Contains("reachable", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void DialogueChoicesPersistAndCompleteWithoutAdvancingSimulatedTime() + { + var definition = ValidImport().Cases[0]; + definition.InitialInterview = ValidInterview(); + var attempt = new CcsAttempt { InterviewNodeId = "greeting", SimulatedMinute = 7, OpeningCompleted = false }; + + CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow); + + Assert.Equal("done", attempt.InterviewNodeId); + Assert.Equal(["ask_reason"], attempt.InterviewChoiceIds); + Assert.Equal(7, attempt.SimulatedMinute); + Assert.True(attempt.OpeningCompleted); + Assert.Contains(attempt.Actions, value => value.Type == "dialogue_choice" && value.Code == "ask_reason"); + Assert.Throws(() => CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow)); + } + [Fact] public void CcsExpectedScoreUsesSeparateUserAndCaseElo() { @@ -201,4 +243,14 @@ public sealed class CcsCaseTests } ] }; + + private static CcsInitialInterview ValidInterview() => new() + { + StartNodeId = "greeting", + Nodes = + [ + new() { Id = "greeting", PatientText = "Hello, doctor.", Choices = [new() { Id = "ask_reason", Text = "What brings you in?", NextNodeId = "done" }] }, + new() { Id = "done", PatientText = "Thank you.", IsTerminal = true } + ] + }; } diff --git a/api/QuestionsApi/Models/CcsModels.cs b/api/QuestionsApi/Models/CcsModels.cs index 57ac5ec..bb62128 100644 --- a/api/QuestionsApi/Models/CcsModels.cs +++ b/api/QuestionsApi/Models/CcsModels.cs @@ -65,6 +65,7 @@ public class CcsCaseRevision public int Difficulty { get; set; } = 2; public List Tags { get; set; } = []; public CcsInitialChart InitialChart { get; set; } = new(); + public CcsInitialInterview InitialInterview { get; set; } = new(); public List AllowedLocations { get; set; } = []; public List Exams { get; set; } = []; public List OrderResponses { get; set; } = []; @@ -74,6 +75,27 @@ public class CcsCaseRevision public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; } +public class CcsInitialInterview +{ + public string StartNodeId { get; set; } = ""; + public List Nodes { get; set; } = []; +} + +public class CcsDialogueNode +{ + public string Id { get; set; } = ""; + public string PatientText { get; set; } = ""; + public bool IsTerminal { get; set; } + public List Choices { get; set; } = []; +} + +public class CcsDialogueChoice +{ + public string Id { get; set; } = ""; + public string Text { get; set; } = ""; + public string NextNodeId { get; set; } = ""; +} + public class CcsInitialChart { public string PatientSummary { get; set; } = ""; @@ -148,6 +170,8 @@ public class CcsAttempt public int Revision { get; set; } public int SimulatedMinute { get; set; } [BsonIgnoreIfNull] public bool? OpeningCompleted { get; set; } + public string InterviewNodeId { get; set; } = ""; + public List InterviewChoiceIds { get; set; } = []; public string Location { get; set; } = ""; public Dictionary CurrentVitals { get; set; } = []; public List Actions { get; set; } = []; @@ -287,6 +311,7 @@ public class CcsCaseSummary public int RealTimeMinutes { get; set; } public int Difficulty { get; set; } public List Tags { get; set; } = []; + public bool InteractiveInterviewAvailable { get; set; } } public class CcsAttemptResponse @@ -320,11 +345,20 @@ public class CcsAttemptResponse public List PendingResults { get; set; } = []; public List AvailableExams { get; set; } = []; public List AllowedLocations { get; set; } = []; + public CcsInterviewStateResponse? Interview { get; set; } public CcsCaseScore? Score { get; set; } public CcsRatingChange? Rating { get; set; } public List References { get; set; } = []; } +public class CcsInterviewStateResponse +{ + public string CurrentNodeId { get; set; } = ""; + public CcsDialogueNode CurrentNode { get; set; } = new(); + public List VisitedChoiceIds { get; set; } = []; + public bool Completed { get; set; } +} + public class CcsHistoryItem { public string Id { get; set; } = ""; diff --git a/api/QuestionsApi/Services/CcsCaseValidator.cs b/api/QuestionsApi/Services/CcsCaseValidator.cs index 1993015..9262247 100644 --- a/api/QuestionsApi/Services/CcsCaseValidator.cs +++ b/api/QuestionsApi/Services/CcsCaseValidator.cs @@ -4,7 +4,7 @@ namespace QuestionsApi.Services; public static class CcsCaseValidator { - private static readonly HashSet CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"]; + private static readonly HashSet CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "dialogue_choice", "end_case", "exit_case"]; public static List ValidateImport(CcsImportRequest request, IReadOnlySet existingOrderCodes) { @@ -38,6 +38,7 @@ public static class CcsCaseValidator errors.Add(prefix + "initial patient summary and reason are required"); if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + "allowedLocations must include presentingLocation"); + ValidateInterview(item.InitialInterview, prefix, errors); if (item.IsPublished) { var referenceError = QuestionValidator.ValidateReferences(item.References, true); @@ -90,7 +91,7 @@ public static class CcsCaseValidator if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required"; if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative"; if (!CommandTypes.Contains(request.Type)) return "unsupported command type"; - if ((request.Type is "exam" or "order" or "cancel_order" or "location") && string.IsNullOrWhiteSpace(request.Code)) return "code required"; + if ((request.Type is "exam" or "order" or "cancel_order" or "location" or "dialogue_choice") && string.IsNullOrWhiteSpace(request.Code)) return "code required"; if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600"; return null; } @@ -119,4 +120,47 @@ public static class CcsCaseValidator return "history and examinations cannot be mixed with order changes in one batch"; return null; } + + private static void ValidateInterview(CcsInitialInterview interview, string prefix, List errors) + { + if (string.IsNullOrWhiteSpace(interview.StartNodeId) && interview.Nodes.Count == 0) return; + if (string.IsNullOrWhiteSpace(interview.StartNodeId)) errors.Add(prefix + "initialInterview.startNodeId required"); + if (interview.Nodes.Count == 0) { errors.Add(prefix + "initialInterview.nodes required"); return; } + var nodeIds = interview.Nodes.Select(value => value.Id).ToList(); + if (nodeIds.Any(string.IsNullOrWhiteSpace) || nodeIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != nodeIds.Count) + errors.Add(prefix + "interview node ids must be present and unique"); + var nodeMap = interview.Nodes.Where(value => !string.IsNullOrWhiteSpace(value.Id)) + .GroupBy(value => value.Id, StringComparer.OrdinalIgnoreCase).ToDictionary(value => value.Key, value => value.First(), StringComparer.OrdinalIgnoreCase); + if (!nodeMap.ContainsKey(interview.StartNodeId)) errors.Add(prefix + "interview start node does not exist"); + var choiceIds = interview.Nodes.SelectMany(value => value.Choices).Select(value => value.Id).ToList(); + if (choiceIds.Any(string.IsNullOrWhiteSpace) || choiceIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != choiceIds.Count) + errors.Add(prefix + "interview choice ids must be present and globally unique"); + foreach (var node in interview.Nodes) + { + if (string.IsNullOrWhiteSpace(node.PatientText)) errors.Add(prefix + $"interview node {node.Id} requires patientText"); + if (node.IsTerminal && node.Choices.Count > 0) errors.Add(prefix + $"terminal interview node {node.Id} cannot have choices"); + if (!node.IsTerminal && node.Choices.Count == 0) errors.Add(prefix + $"nonterminal interview node {node.Id} requires choices"); + foreach (var choice in node.Choices) + { + if (string.IsNullOrWhiteSpace(choice.Text)) errors.Add(prefix + $"interview choice {choice.Id} requires text"); + if (string.IsNullOrWhiteSpace(choice.NextNodeId) || !nodeMap.ContainsKey(choice.NextNodeId)) + errors.Add(prefix + $"interview choice {choice.Id} references an unknown next node"); + } + } + if (!nodeMap.ContainsKey(interview.StartNodeId)) return; + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var visiting = new HashSet(StringComparer.OrdinalIgnoreCase); + bool Walk(string nodeId) + { + if (visiting.Contains(nodeId)) { errors.Add(prefix + "interview graph cannot contain cycles"); return false; } + if (visited.Contains(nodeId)) return true; + visiting.Add(nodeId); + if (nodeMap.TryGetValue(nodeId, out var node)) + foreach (var choice in node.Choices.Where(value => nodeMap.ContainsKey(value.NextNodeId))) Walk(choice.NextNodeId); + visiting.Remove(nodeId); visited.Add(nodeId); return true; + } + Walk(interview.StartNodeId); + if (visited.Count != nodeMap.Count) errors.Add(prefix + "every interview node must be reachable from startNodeId"); + if (!interview.Nodes.Any(value => value.IsTerminal)) errors.Add(prefix + "interview requires a terminal node"); + } } diff --git a/api/QuestionsApi/Services/CcsStore.cs b/api/QuestionsApi/Services/CcsStore.cs index 3095986..5dd7b31 100644 --- a/api/QuestionsApi/Services/CcsStore.cs +++ b/api/QuestionsApi/Services/CcsStore.cs @@ -117,6 +117,7 @@ public sealed class CcsStore CaseVersion = definition.Version, Mode = CcsAttemptModes.Timed, OpeningCompleted = false, + InterviewNodeId = definition.InitialInterview.StartNodeId, Location = definition.PresentingLocation, CurrentVitals = new Dictionary(definition.InitialChart.Vitals), VitalHistory = @@ -227,6 +228,7 @@ public sealed class CcsStore case "acknowledge_opening": attempt.OpeningCompleted = true; break; + case "dialogue_choice": ApplyDialogueChoice(attempt, definition, command.Code, now); break; case "end_case": EnterFinalOrders(attempt); break; case "exit_case": await CompleteAsync(attempt); break; } @@ -412,6 +414,19 @@ public sealed class CcsStore List vitalHistory = attempt.VitalHistory.Count > 0 ? attempt.VitalHistory : [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary(attempt.CurrentVitals) }]; + CcsInterviewStateResponse? interview = null; + var currentInterviewNode = definition.InitialInterview.Nodes.FirstOrDefault(value => + value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase)); + if (currentInterviewNode is not null) + { + interview = new CcsInterviewStateResponse + { + CurrentNodeId = currentInterviewNode.Id, + CurrentNode = currentInterviewNode, + VisitedChoiceIds = attempt.InterviewChoiceIds, + Completed = attempt.OpeningCompleted ?? false + }; + } return new CcsAttemptResponse { Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId, @@ -434,11 +449,29 @@ public sealed class CcsStore }).ToList(), AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [], AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [], + Interview = interview, Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null, References = completed ? definition.References : [] }; } + public static void ApplyDialogueChoice(CcsAttempt attempt, CcsCaseRevision definition, string choiceId, DateTime now) + { + if (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete."); + var node = definition.InitialInterview.Nodes.FirstOrDefault(value => + value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException("The interactive interview is unavailable."); + var choice = node.Choices.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase)) + ?? throw new ArgumentException("The dialogue choice is not available at the current interview node."); + var next = definition.InitialInterview.Nodes.FirstOrDefault(value => + value.Id.Equals(choice.NextNodeId, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException("The dialogue choice target is unavailable."); + attempt.Actions.Add(Action("dialogue_choice", choice.Id, attempt.SimulatedMinute, now)); + attempt.InterviewChoiceIds.Add(choice.Id); + attempt.InterviewNodeId = next.Id; + if (next.IsTerminal) attempt.OpeningCompleted = true; + } + public async Task AbandonAsync(CcsAttempt attempt) { attempt.State = CcsAttemptStates.Abandoned; @@ -693,7 +726,8 @@ public sealed class CcsStore { ExternalId = value.ExternalId, Title = value.Title, System = value.System, PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes, - Difficulty = value.Difficulty, Tags = value.Tags + Difficulty = value.Difficulty, Tags = value.Tags, + InteractiveInterviewAvailable = !string.IsNullOrWhiteSpace(value.InitialInterview.StartNodeId) && value.InitialInterview.Nodes.Count > 0 }; internal static int Rank(CcsOrderDefinition order, string query) { diff --git a/content/ccs/dka-emergency-001.json b/content/ccs/dka-emergency-001.json index 0e023dd..8a1d7cd 100644 --- a/content/ccs/dka-emergency-001.json +++ b/content/ccs/dka-emergency-001.json @@ -47,6 +47,61 @@ "simulatedStartUtc": "2026-07-15T08:00:00Z", "initialOrderCodes": [] }, + "initialInterview": { + "startNodeId": "greeting", + "nodes": [ + { + "id": "greeting", + "patientText": "Doctor, I feel awful. I can barely keep my eyes open.", + "isTerminal": false, + "choices": [ + { "id": "ask_chief_concern", "text": "Tell me what brought you in today.", "nextNodeId": "chief_concern" }, + { "id": "ask_current_problem", "text": "What has been happening recently?", "nextNodeId": "chief_concern" } + ] + }, + { + "id": "chief_concern", + "patientText": "I have been vomiting for two days, my stomach hurts, and I have become more and more tired.", + "isTerminal": false, + "choices": [ + { "id": "ask_insulin_use", "text": "Have you been taking your insulin?", "nextNodeId": "insulin_history" }, + { "id": "ask_diabetes_treatment", "text": "How have you managed your diabetes during this illness?", "nextNodeId": "insulin_history" } + ] + }, + { + "id": "insulin_history", + "patientText": "I stopped all of my insulin about a day and a half ago because I was not eating.", + "isTerminal": false, + "choices": [ + { "id": "ask_associated_symptoms", "text": "Have you noticed any other symptoms?", "nextNodeId": "associated_symptoms" }, + { "id": "ask_infection_symptoms", "text": "Any fever, cough, urinary symptoms, or diarrhea?", "nextNodeId": "associated_symptoms" } + ] + }, + { + "id": "associated_symptoms", + "patientText": "No cough, burning with urination, or diarrhea. I do not think I have had a fever.", + "isTerminal": false, + "choices": [ + { "id": "ask_medical_history", "text": "Tell me about your medical history and medications.", "nextNodeId": "background" }, + { "id": "ask_allergies", "text": "Do you have kidney problems, take other medicines, or have medication allergies?", "nextNodeId": "background" } + ] + }, + { + "id": "background", + "patientText": "I have type 1 diabetes and normally use long-acting and mealtime insulin. I have no kidney disease and no known drug allergies.", + "isTerminal": false, + "choices": [ + { "id": "finish_interview", "text": "Thank you. I am going to examine you and begin your evaluation.", "nextNodeId": "interview_complete" } + ] + }, + { + "id": "interview_complete", + "patientText": "Okay, doctor.", + "isTerminal": true, + "choices": [] + } + ] + }, "allowedLocations": ["emergency_department", "icu", "ward"], "exams": [ { "code": "general_exam", "name": "General and hydration examination", "durationMinutes": 1, "finding": "Lethargic but arousable; dry mucous membranes, delayed capillary refill, and poor skin turgor." }, diff --git a/docs/CCS_CASE_AUTHORING.md b/docs/CCS_CASE_AUTHORING.md index f35ec76..55bdd30 100644 --- a/docs/CCS_CASE_AUTHORING.md +++ b/docs/CCS_CASE_AUTHORING.md @@ -7,6 +7,7 @@ Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validate ## Case structure - `initialChart` defines the opening history, appearance, vitals, simulated start, and standing orders. Use ordered `historySections` (`code`, `title`, `text`) for the HPI, medical history, medications, allergies, family/social history, and review of systems. The legacy `history` field remains the fallback. +- `initialInterview` optionally defines the graphical office interview. It contains a `startNodeId` and an acyclic set of dialogue nodes. Each node has stable `id`, authored `patientText`, `isTerminal`, and clinician `choices` with globally unique IDs and valid `nextNodeId` references. Every branch must reach a terminal node. - `allowedLocations` controls patient movement. - `exams` defines selectable examination components and their simulated duration. - Catalog orders may define `displayRoute`, `displayFrequency`, and `resultSection`. These are presentation defaults, not user-entered dosing instructions. @@ -23,4 +24,6 @@ Valid result sections are `lab`, `imaging`, `other_test`, and `treatment`; progr New CCS attempts are timed. `realTimeMinutes` is the total wall-clock allowance: the runtime reserves its final two minutes for final orders, so a 20-minute case has 18 minutes of active management plus two minutes of case-end orders. Opening dialogs count against this limit and reconnecting does not pause it. +Graphical interview choices are persisted as attempt actions and support reconnect/resume. They consume real deadline time but do not advance simulated time or contribute directly to scoring. Cases without `initialInterview` remain available in classic CCS and are omitted from CCS Cases 2. + Only the first completed timed attempt for each distinct case contributes to the CCS estimate. Legacy practice attempts remain reviewable but cannot be newly created. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula. diff --git a/scripts/autoload/ccs_service.gd b/scripts/autoload/ccs_service.gd index e664191..f777616 100644 --- a/scripts/autoload/ccs_service.gd +++ b/scripts/autoload/ccs_service.gd @@ -34,6 +34,7 @@ var queue: Array = [] var text_scale := 1.0 var highlights := {} var request_access_token := "" +var launch_origin := "ccs" func _ready() -> void: @@ -77,6 +78,10 @@ func send_command(type: String, code: String = "", minutes: int = 0) -> void: "minutes": minutes }) + +func send_dialogue_choice(choice_id: String) -> void: + send_command("dialogue_choice", choice_id) + func send_batch(commands: Array) -> void: if attempt.is_empty() or commands.is_empty(): return @@ -272,6 +277,7 @@ func _escape_bbcode(value: String) -> String: func _on_auth_changed() -> void: if not AuthState.is_logged_in: + launch_origin = "ccs" highlights.clear() text_scale = 1.0 attempt = {} diff --git a/scripts/ui/ccs_cases_2/doctor_office.gd b/scripts/ui/ccs_cases_2/doctor_office.gd index b954cdd..aa1b890 100644 --- a/scripts/ui/ccs_cases_2/doctor_office.gd +++ b/scripts/ui/ccs_cases_2/doctor_office.gd @@ -1,6 +1,7 @@ extends Control -signal dialogue_choice_selected(index: int, text: String) +signal choice_selected(choice_id: String) +signal interview_continue_requested const CHOICE_NORMAL := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-normal.png") const CHOICE_HOVER := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-hover.png") @@ -22,11 +23,8 @@ var dialogue_tween: Tween func _ready() -> void: _update_clock() _schedule_patient_blink() - show_patient_dialogue("Hello, doctor.") - show_dialogue_choices([ - "What brings you in today?", - "How are you feeling today?", - ]) + show_patient_dialogue("Loading patient interview...") + hide_dialogue_choices() func _process(_delta: float) -> void: @@ -91,12 +89,28 @@ func _resize_dialogue_bubble() -> void: dialogue_bubble.size.y = target_height -func show_dialogue_choices(options: Array[String]) -> void: +func configure_interview(node: Dictionary) -> void: + show_patient_dialogue(str(node.get("patientText", ""))) + if bool(node.get("isTerminal", false)): + show_dialogue_choices([{"id": "__continue", "text": "Continue to Patient Management"}]) + else: + show_dialogue_choices(node.get("choices", [])) + + +func set_choices_pending(pending: bool) -> void: + for child in choice_container.get_children(): + if child is BaseButton: + child.disabled = pending + + +func show_dialogue_choices(options: Array) -> void: hide_dialogue_choices() var stack_height := options.size() * 97.0 + 8.0 choice_container.offset_top = -4.0 - stack_height for index in options.size(): - var option_text := options[index] + var option: Dictionary = options[index] + var choice_id := str(option.get("id", "")) + var option_text := str(option.get("text", "")) var button := TextureButton.new() button.custom_minimum_size = Vector2(420.0, 105.0) button.ignore_texture_size = true @@ -111,7 +125,7 @@ func show_dialogue_choices(options: Array[String]) -> void: button.mouse_exited.connect(_on_choice_hover.bind(button, false)) button.button_down.connect(_on_choice_pressed_visual.bind(button, true)) button.button_up.connect(_on_choice_pressed_visual.bind(button, false)) - button.pressed.connect(_on_dialogue_choice_pressed.bind(index, option_text)) + button.pressed.connect(_on_dialogue_choice_pressed.bind(choice_id)) var label := Label.new() label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) @@ -156,6 +170,8 @@ func _on_choice_pressed_visual(button: TextureButton, pressed: bool) -> void: button.position.y += 2.0 if pressed else -2.0 -func _on_dialogue_choice_pressed(index: int, option_text: String) -> void: - dialogue_choice_selected.emit(index, option_text) - hide_dialogue_choices() +func _on_dialogue_choice_pressed(choice_id: String) -> void: + if choice_id == "__continue": + interview_continue_requested.emit() + else: + choice_selected.emit(choice_id) diff --git a/scripts/ui/ccs_cases_2_screen.gd b/scripts/ui/ccs_cases_2_screen.gd index ab516f2..6ce464a 100644 --- a/scripts/ui/ccs_cases_2_screen.gd +++ b/scripts/ui/ccs_cases_2_screen.gd @@ -1,34 +1,232 @@ extends Control +const Brand := preload("res://scripts/ui/medmind_style.gd") +const Style := preload("res://scripts/ui/ui_style.gd") const DOCTOR_SCENE := preload("res://scenes/ccs_cases_2/doctor_office.tscn") const DESIGN_SIZE := Vector2(1500.0, 800.0) +var main var stage_root: Control var active_scene: Control +var selection_view: VBoxContainer +var case_list: VBoxContainer +var status_label: Label +var retry_button: Button var current_view := "" +var cases_loaded := false +var active_loaded := false +var pending_action := "" func _ready() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) clip_contents = true + _build_selection_view() stage_root = Control.new() stage_root.name = "GraphicalCcsStage" stage_root.size = DESIGN_SIZE + stage_root.visible = false add_child(stage_root) + _connect_service() resized.connect(_fit_stage) _fit_stage() +func _connect_service() -> void: + if not CCSService.cases_changed.is_connected(_on_cases_changed): + CCSService.cases_changed.connect(_on_cases_changed) + if not CCSService.attempt_changed.is_connected(_on_attempt_changed): + CCSService.attempt_changed.connect(_on_attempt_changed) + if not CCSService.request_failed.is_connected(_on_request_failed): + CCSService.request_failed.connect(_on_request_failed) + + func on_show(_payload: Variant = null) -> void: - _show_doctor_office() + CCSService.launch_origin = "ccs_cases_2" + cases_loaded = false + active_loaded = false + pending_action = "loading" + _show_selection("Loading interactive cases...") + CCSService.load_cases() + CCSService.load_active() func on_hide() -> void: _clear_active_scene() -func _show_doctor_office() -> void: - _set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office") +func compatible_cases(cases: Array) -> Array: + return cases.filter(func(item: Variant) -> bool: + return typeof(item) == TYPE_DICTIONARY and bool(item.get("interactiveInterviewAvailable", false))) + + +func _on_cases_changed() -> void: + cases_loaded = true + _render_state() + + +func _on_attempt_changed() -> void: + active_loaded = true + pending_action = "" + _render_state() + + +func _render_state() -> void: + if not visible: + return + if not active_loaded: + return + var attempt: Dictionary = CCSService.attempt + if not attempt.is_empty() and str(attempt.get("state", "")) in ["active", "final_orders"]: + _render_attempt(attempt) + elif cases_loaded and active_loaded: + _render_case_selection() + + +func _render_attempt(attempt: Dictionary) -> void: + if str(attempt.get("state", "")) != "active": + _handoff_to_classic_player() + return + var interview: Dictionary = attempt.get("interview", {}) + if interview.is_empty(): + _handoff_to_classic_player() + return + var node: Dictionary = interview.get("currentNode", {}) + if node.is_empty(): + _handoff_to_classic_player() + return + if bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)): + _handoff_to_classic_player() + return + _show_doctor_office(node) + + +func _build_selection_view() -> void: + selection_view = VBoxContainer.new() + selection_view.name = "CaseSelection" + selection_view.add_theme_constant_override("separation", 16) + add_child(selection_view) + selection_view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var heading := Label.new() + heading.text = "Choose an Interactive CCS Case" + Brand.apply_font(heading) + heading.add_theme_font_size_override("font_size", 28) + selection_view.add_child(heading) + var description := Label.new() + description.text = "Begin a timed patient interview, then continue into clinical management." + description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Brand.apply_font(description) + description.add_theme_font_size_override("font_size", 16) + selection_view.add_child(description) + status_label = Label.new() + status_label.name = "CaseSelectionStatus" + status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Brand.apply_font(status_label) + selection_view.add_child(status_label) + retry_button = Button.new() + retry_button.text = "Retry" + retry_button.visible = false + retry_button.custom_minimum_size = Vector2(120, 42) + retry_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + Style.style_primary_button(retry_button) + retry_button.pressed.connect(_retry_load) + selection_view.add_child(retry_button) + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + selection_view.add_child(scroll) + case_list = VBoxContainer.new() + case_list.name = "InteractiveCaseList" + case_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + case_list.add_theme_constant_override("separation", 10) + scroll.add_child(case_list) + + +func _show_selection(message: String = "") -> void: + _clear_active_scene() + current_view = "case_selection" + selection_view.visible = true + stage_root.visible = false + status_label.text = message + retry_button.visible = false + + +func _render_case_selection() -> void: + _show_selection() + _clear_children(case_list) + var available := compatible_cases(CCSService.cases) + if available.is_empty(): + status_label.text = "No published CCS cases currently include an interactive interview." + return + status_label.text = "%d interactive %s available." % [available.size(), "case is" if available.size() == 1 else "cases are"] + retry_button.visible = false + for raw_case in available: + var case: Dictionary = raw_case + var button := Button.new() + button.name = "Case%s" % str(case.get("externalId", "")).to_pascal_case() + button.text = "%s\n%s · Difficulty %d · %d minutes" % [ + str(case.get("title", "Untitled case")), str(case.get("system", "")), + int(case.get("difficulty", 0)), int(case.get("realTimeMinutes", 0))] + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.custom_minimum_size = Vector2(0, 76) + Style.style_answer_button(button) + button.pressed.connect(_start_case.bind(str(case.get("externalId", "")))) + case_list.add_child(button) + + +func _start_case(case_external_id: String) -> void: + if not pending_action.is_empty() or case_external_id.is_empty(): + return + pending_action = "creating" + status_label.text = "Starting case..." + _set_case_buttons_disabled(true) + CCSService.create_attempt(case_external_id, "timed") + + +func _show_doctor_office(node: Dictionary) -> void: + if active_scene == null: + _set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office") + active_scene.choice_selected.connect(_submit_dialogue_choice) + active_scene.interview_continue_requested.connect(_handoff_to_classic_player) + selection_view.visible = false + stage_root.visible = true + active_scene.configure_interview(node) + active_scene.set_choices_pending(not pending_action.is_empty()) + + +func _submit_dialogue_choice(choice_id: String) -> void: + if not pending_action.is_empty() or choice_id.is_empty(): + return + pending_action = "dialogue" + active_scene.set_choices_pending(true) + CCSService.send_dialogue_choice(choice_id) + + +func _handoff_to_classic_player() -> void: + current_view = "handoff" + if main != null: + main.show_screen("ccs_player") + + +func _on_request_failed(message: String) -> void: + if not visible: + return + var failed_action := pending_action + pending_action = "" + if failed_action == "creating" and message.contains("409"): + status_label.text = "An active case was found. Loading it now..." + CCSService.load_active() + return + if active_scene != null: + active_scene.set_choices_pending(false) + else: + status_label.text = message + retry_button.visible = failed_action == "loading" + _set_case_buttons_disabled(false) + + +func _retry_load() -> void: + on_show() func _set_active_scene(scene: Control, view_name: String) -> void: @@ -45,7 +243,19 @@ func _clear_active_scene() -> void: active_scene.get_parent().remove_child(active_scene) active_scene.queue_free() active_scene = null - current_view = "" + if current_view == "doctor_office": + current_view = "" + + +func _set_case_buttons_disabled(disabled: bool) -> void: + for child in case_list.get_children(): + if child is BaseButton: + child.disabled = disabled + + +func _clear_children(parent: Node) -> void: + for child in parent.get_children(): + child.queue_free() func _fit_stage() -> void: diff --git a/scripts/ui/ccs_hub.gd b/scripts/ui/ccs_hub.gd index ba79569..f12d962 100644 --- a/scripts/ui/ccs_hub.gd +++ b/scripts/ui/ccs_hub.gd @@ -144,6 +144,7 @@ func _connect_services() -> void: func on_show(_payload: Variant = null) -> void: + CCSService.launch_origin = "ccs" pending_action = "" pending_attempt_id = "" status_label.text = "Loading case history..." diff --git a/scripts/ui/ccs_player.gd b/scripts/ui/ccs_player.gd index 7adfc14..0e6990c 100644 --- a/scripts/ui/ccs_player.gd +++ b/scripts/ui/ccs_player.gd @@ -820,7 +820,7 @@ func _confirm_abandon() -> void: func _on_abandoned() -> void: if leaving: leaving = false - main.show_screen("ccs_hub") + main.show_screen("ccs_cases_2" if CCSService.launch_origin == "ccs_cases_2" else "ccs_hub") func _render_chart() -> void: diff --git a/scripts/ui/ccs_review.gd b/scripts/ui/ccs_review.gd index 449ab1c..784ea9a 100644 --- a/scripts/ui/ccs_review.gd +++ b/scripts/ui/ccs_review.gd @@ -36,7 +36,7 @@ func _ready() -> void: report_dialog = preload("res://scripts/ui/ccs_report_dialog.gd").new(); add_child(report_dialog) var row := HBoxContainer.new(); add_child(row) var cases := Button.new(); cases.text = "Another Case"; Style.style_primary_button(cases); cases.pressed.connect(_another_case); row.add_child(cases) - var back := Button.new(); back.text = "Back to CCS Cases"; back.pressed.connect(func(): main.show_screen("ccs_hub")); row.add_child(back) + var back := Button.new(); back.text = "Back to CCS Cases"; back.pressed.connect(_back_to_cases); row.add_child(back) status = Label.new(); Style.set_label(status, 14, Style.RED); row.add_child(status) CCSService.request_failed.connect(_request_failed) if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed) @@ -156,11 +156,18 @@ func _refresh_loaded_attempt() -> void: if str(CCSService.attempt.get("state", "")) == "completed": on_show(CCSService.attempt) func _another_case() -> void: + if CCSService.launch_origin == "ccs_cases_2": + main.show_screen("ccs_cases_2") + return if starting_case: return starting_case = true status.text = "Loading a random timed case..." CCSService.create_attempt("", "timed") + +func _back_to_cases() -> void: + main.show_screen("ccs_cases_2" if CCSService.launch_origin == "ccs_cases_2" else "ccs_hub") + func _request_failed(message: String) -> void: if not visible or not starting_case: return starting_case = false diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 3932622..7cda355 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -345,6 +345,7 @@ func _build_ui() -> void: if FeatureFlags.ccs_cases_2_enabled(): var ccs_cases_2 := preload("res://scripts/ui/ccs_cases_2_screen.gd").new() ccs_cases_2.name = "CcsCases2" + ccs_cases_2.main = self screens_container.add_child(ccs_cases_2) screens["ccs_cases_2"] = ccs_cases_2 @@ -501,7 +502,9 @@ func _on_navigation_pressed(key: String) -> void: "question_bank": show_screen("selection", "question_bank") "dynamic_questions": show_screen("dynamic_questions") "exams": show_screen("simulation_history") - "ccs": show_screen("ccs_hub") + "ccs": + CCSService.launch_origin = "ccs" + show_screen("ccs_hub") "ccs_cases_2": show_screen("ccs_cases_2") "flashcards": show_screen("flashcards_study") "performance": show_screen("profile") diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index 37d51b7..8819212 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -18,7 +18,7 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri elif screen_name == "ccs_cases_2": key = "ccs_cases_2" elif screen_name.begins_with("ccs_"): - key = "ccs" + key = "ccs_cases_2" if CCSService.launch_origin == "ccs_cases_2" else "ccs" elif screen_name.begins_with("flashcards_"): key = "flashcards" elif screen_name == "profile": diff --git a/scripts/ui/study_selector.gd b/scripts/ui/study_selector.gd index 4d2cc27..c42a23c 100644 --- a/scripts/ui/study_selector.gd +++ b/scripts/ui/study_selector.gd @@ -653,6 +653,7 @@ func _select_question_bank() -> void: func _select_ccs() -> void: + CCSService.launch_origin = "ccs" pending_ccs_launch = true ccs_button.disabled = true ccs_status_label.text = "Selecting a timed case..." diff --git a/tests/ccs_cases_2_ui_test.gd b/tests/ccs_cases_2_ui_test.gd index 1a587bd..4f02874 100644 --- a/tests/ccs_cases_2_ui_test.gd +++ b/tests/ccs_cases_2_ui_test.gd @@ -3,6 +3,13 @@ extends SceneTree var failures := 0 +class ScreenHost extends Control: + var shown_screen := "" + + func show_screen(screen_name: String, _payload: Variant = null) -> void: + shown_screen = screen_name + + func _initialize() -> void: call_deferred("_run") @@ -20,25 +27,71 @@ func _run() -> void: await process_frame ProjectSettings.set_setting("step3/deployment_environment", prior_environment) - var host := Control.new() + var service = root.get_node("CCSService") + var prior_attempt: Dictionary = service.attempt.duplicate(true) + var prior_cases: Array = service.cases.duplicate(true) + var prior_origin: String = service.launch_origin + var host := ScreenHost.new() host.size = Vector2(1200.0, 700.0) root.add_child(host) var screen = load("res://scripts/ui/ccs_cases_2_screen.gd").new() + screen.main = host host.add_child(screen) await process_frame - screen.on_show() + + service.cases = [ + {"externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2, "realTimeMinutes": 20, "interactiveInterviewAvailable": true}, + {"externalId": "legacy", "title": "Legacy case", "interactiveInterviewAvailable": false} + ] + service.attempt = {} + screen.cases_loaded = true + screen.active_loaded = true + screen._render_state() await process_frame - _assert(screen.current_view == "doctor_office", "CCS Cases 2 should open directly in the doctor office") - _assert(screen.active_scene != null, "CCS Cases 2 should instantiate the doctor-office scene") + _assert(screen.current_view == "case_selection", "CCS Cases 2 should begin with compatible case selection") + _assert(screen.case_list.get_child_count() == 1, "legacy cases should be excluded from graphical selection") + + service.attempt = _sample_attempt(false) + screen._render_state() + await process_frame + _assert(screen.current_view == "doctor_office", "an active interactive attempt should open the doctor office") + _assert(screen.active_scene.dialogue_text.text == "Hello, doctor.", "the patient bubble should use the server-provided current node") + _assert(screen.active_scene.choice_container.get_child_count() == 1, "the scene should render server-provided dialogue choices") _assert(is_equal_approx(screen.stage_root.scale.x, screen.stage_root.scale.y), "the graphical stage should preserve its aspect ratio") + + service.attempt = _sample_attempt(true) + screen._on_attempt_changed() + await process_frame + _assert(screen.active_scene.dialogue_text.text == "Okay, doctor.", "attempt updates should advance the rendered patient response") + _assert(screen.active_scene.choice_container.get_child_count() == 1, "terminal dialogue should expose the management handoff") + screen.active_scene.interview_continue_requested.emit() + _assert(host.shown_screen == "ccs_player", "terminal dialogue should hand off the same attempt to the CCS player") + screen.on_hide() await process_frame - _assert(screen.active_scene == null and screen.current_view.is_empty(), "hiding CCS Cases 2 should release the prototype scene") + _assert(screen.active_scene == null, "hiding CCS Cases 2 should release the doctor scene") host.queue_free() + service.attempt = prior_attempt + service.cases = prior_cases + service.launch_origin = prior_origin await process_frame quit(1 if failures > 0 else 0) +func _sample_attempt(completed: bool) -> Dictionary: + var node := { + "id": "done" if completed else "greeting", + "patientText": "Okay, doctor." if completed else "Hello, doctor.", + "isTerminal": completed, + "choices": [] if completed else [{"id": "ask_reason", "text": "What brings you in?", "nextNodeId": "done"}] + } + return { + "id": "attempt-1", "state": "active", "revision": 1, + "openingCompleted": completed, + "interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed} + } + + func _assert(condition: bool, message: String) -> void: if condition: return