From a0dae8e478b6eee980431728ae1c6104ac82e251 Mon Sep 17 00:00:00 2001 From: hz Date: Thu, 6 Aug 2026 02:48:44 -0500 Subject: [PATCH] Make graphical CCS dialogue choice first --- api/QuestionsApi.Tests/CcsCaseTests.cs | 10 +++++--- api/QuestionsApi/Models/CcsModels.cs | 1 + api/QuestionsApi/Services/CcsStore.cs | 6 +++-- scripts/ui/ccs_cases_2/doctor_office.gd | 34 +++++++++++++++++++++---- scripts/ui/ccs_cases_2_screen.gd | 16 ++++++++---- tests/ccs_cases_2_ui_test.gd | 15 ++++++++++- 6 files changed, 66 insertions(+), 16 deletions(-) diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index 4518b89..7401865 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -229,11 +229,15 @@ public sealed class CcsCaseTests definition.InitialInterview = TopicInterview(); var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = false }; - Assert.Equal(["chief_concern"], CcsStore.CurrentInterviewNode(attempt, definition)!.Choices.Select(value => value.Id)); + var opening = CcsStore.CurrentInterviewNode(attempt, definition)!; + Assert.True(opening.SuppressPatientText); + Assert.Equal(["chief_concern"], opening.Choices.Select(value => value.Id)); Assert.Throws(() => CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow)); CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow); - Assert.Equal(["required_history", "optional_history"], - CcsStore.CurrentInterviewNode(attempt, definition)!.Choices.Select(value => value.Id)); + var chiefConcern = CcsStore.CurrentInterviewNode(attempt, definition)!; + Assert.False(chiefConcern.SuppressPatientText); + Assert.Equal("I feel tired.", chiefConcern.PatientText); + Assert.Equal(["required_history", "optional_history"], chiefConcern.Choices.Select(value => value.Id)); Assert.Throws(() => CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow)); CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow); diff --git a/api/QuestionsApi/Models/CcsModels.cs b/api/QuestionsApi/Models/CcsModels.cs index c5ecd97..b43702f 100644 --- a/api/QuestionsApi/Models/CcsModels.cs +++ b/api/QuestionsApi/Models/CcsModels.cs @@ -120,6 +120,7 @@ public class CcsDialogueNode { public string Id { get; set; } = ""; public string PatientText { get; set; } = ""; + public bool SuppressPatientText { get; set; } public bool IsTerminal { get; set; } public List Choices { get; set; } = []; } diff --git a/api/QuestionsApi/Services/CcsStore.cs b/api/QuestionsApi/Services/CcsStore.cs index 2f8664f..128192f 100644 --- a/api/QuestionsApi/Services/CcsStore.cs +++ b/api/QuestionsApi/Services/CcsStore.cs @@ -793,7 +793,9 @@ public sealed class CcsStore }); return new CcsDialogueNode { - Id = attempt.InterviewNodeId, PatientText = patientText, Choices = choices + Id = attempt.InterviewNodeId, PatientText = patientText, + SuppressPatientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase), + Choices = choices }; } var interview = EffectiveInterview(definition); @@ -848,7 +850,7 @@ public sealed class CcsStore { new() { - Id = "opening", PatientText = "Hello, doctor.", + Id = "opening", PatientText = "Hello, doctor.", SuppressPatientText = true, Choices = [new() { Id = "ask_reason", Text = "What brings you in today?", NextNodeId = "reason" }] }, new() diff --git a/scripts/ui/ccs_cases_2/doctor_office.gd b/scripts/ui/ccs_cases_2/doctor_office.gd index 1be7f1a..8c4c851 100644 --- a/scripts/ui/ccs_cases_2/doctor_office.gd +++ b/scripts/ui/ccs_cases_2/doctor_office.gd @@ -23,12 +23,13 @@ var dialogue_tween: Tween var clock_seconds := 9.0 * 60.0 * 60.0 var debrief_panel: PanelContainer var experience_mode := "management" +var pending_choices: Array = [] func _ready() -> void: _update_clock() _schedule_patient_blink() - show_patient_dialogue("Loading patient interview...") + hide_patient_dialogue() hide_dialogue_choices() @@ -89,12 +90,14 @@ func show_patient_dialogue(text: String, characters_per_second: float = 35.0) -> var reveal_duration := float(text.length()) / maxf(characters_per_second, 1.0) dialogue_tween = create_tween() dialogue_tween.tween_property(dialogue_text, "visible_ratio", 1.0, reveal_duration) + dialogue_tween.finished.connect(_on_dialogue_reveal_finished) func hide_patient_dialogue() -> void: if dialogue_tween and dialogue_tween.is_valid(): dialogue_tween.kill() dialogue_bubble.visible = false + pending_choices = [] func is_patient_dialogue_visible() -> bool: @@ -113,14 +116,35 @@ func configure_interview(node: Dictionary) -> void: if debrief_panel != null and is_instance_valid(debrief_panel): debrief_panel.queue_free() debrief_panel = null - show_patient_dialogue(str(node.get("patientText", ""))) + var next_choices: Array if bool(node.get("isTerminal", false)): - show_dialogue_choices([{ + next_choices = [{ "id": "__continue", "text": "Finish Interview" if experience_mode == "graphical_interview" else "Continue to Patient Management" - }]) + }] else: - show_dialogue_choices(node.get("choices", [])) + next_choices = node.get("choices", []) + hide_dialogue_choices() + if bool(node.get("suppressPatientText", false)): + hide_patient_dialogue() + show_dialogue_choices(next_choices) + return + pending_choices = next_choices + show_patient_dialogue(str(node.get("patientText", ""))) + + +func complete_dialogue_reveal() -> void: + if dialogue_tween and dialogue_tween.is_valid(): + dialogue_tween.kill() + dialogue_text.visible_ratio = 1.0 + _on_dialogue_reveal_finished() + + +func _on_dialogue_reveal_finished() -> void: + dialogue_tween = null + var choices := pending_choices + pending_choices = [] + show_dialogue_choices(choices) func set_choices_pending(pending: bool) -> void: diff --git a/scripts/ui/ccs_cases_2_screen.gd b/scripts/ui/ccs_cases_2_screen.gd index b4370aa..59f6ead 100644 --- a/scripts/ui/ccs_cases_2_screen.gd +++ b/scripts/ui/ccs_cases_2_screen.gd @@ -92,18 +92,18 @@ func _render_state() -> void: func _render_attempt(attempt: Dictionary) -> void: if str(attempt.get("state", "")) != "active": - _handoff_to_classic_player() + _show_unsupported_attempt("This active case is not available in the graphical interview yet.") return var interview: Dictionary = attempt.get("interview", {}) if interview.is_empty(): - _handoff_to_classic_player() + _show_unsupported_attempt("This active case does not include a graphical dialogue interview.") return var node: Dictionary = interview.get("currentNode", {}) if node.is_empty(): - _handoff_to_classic_player() + _show_unsupported_attempt("The graphical interview could not load its current dialogue.") return if bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)): - _handoff_to_classic_player() + _show_unsupported_attempt("This case has progressed beyond the graphical interview.") return _show_doctor_office(node, attempt) @@ -120,7 +120,7 @@ func _build_selection_view() -> void: 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.text = "Interview the patient by selecting the questions you want to ask." description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Brand.apply_font(description) description.add_theme_font_size_override("font_size", 16) @@ -158,6 +158,12 @@ func _show_selection(message: String = "") -> void: retry_button.visible = false +func _show_unsupported_attempt(message: String) -> void: + _show_selection(message) + _clear_children(case_list) + status_label.text += " Finish or abandon it from CCS Cases before starting another graphical interview." + + func _render_case_selection() -> void: _show_selection() _clear_children(case_list) diff --git a/tests/ccs_cases_2_ui_test.gd b/tests/ccs_cases_2_ui_test.gd index e6c4423..0f5ff4f 100644 --- a/tests/ccs_cases_2_ui_test.gd +++ b/tests/ccs_cases_2_ui_test.gd @@ -59,12 +59,15 @@ func _run() -> void: 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(not screen.active_scene.is_patient_dialogue_visible(), "the opening should wait for a clinician dialogue selection") _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_branching_attempt() screen._render_state() await process_frame + _assert(screen.active_scene.is_patient_dialogue_visible(), "a selected topic should display the patient's response") + _assert(not screen.active_scene.choice_container.visible, "the next choices should wait until the patient finishes speaking") + screen.active_scene.complete_dialogue_reveal() _assert(screen.active_scene.choice_container.columns == 2, "branching interview choices should use two columns") _assert(screen.active_scene.choice_container.get_child_count() == 8, "all available branching topics should render") @@ -72,10 +75,19 @@ func _run() -> void: 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(not screen.active_scene.choice_container.visible, "the terminal action should wait for the closing response") + screen.active_scene.complete_dialogue_reveal() _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") + service.attempt = {"id": "classic-attempt", "state": "active", "openingCompleted": false, "interview": {}} + host.shown_screen = "" + screen._on_attempt_changed() + await process_frame + _assert(screen.current_view == "case_selection", "unsupported active attempts should remain outside the classic player") + _assert(host.shown_screen.is_empty(), "CCS Cases 2 should not trigger classic opening overlays") + service.attempt = _sample_graphical_debrief() screen._on_attempt_changed() await process_frame @@ -98,6 +110,7 @@ func _sample_attempt(completed: bool) -> Dictionary: "id": "done" if completed else "greeting", "patientText": "Okay, doctor." if completed else "Hello, doctor.", "isTerminal": completed, + "suppressPatientText": not completed, "choices": [] if completed else [{"id": "ask_reason", "text": "What brings you in?", "nextNodeId": "done"}] } return {