Author SHA1 Message Date
hz 07516784d2 Add graphical CCS physical examination 2026-08-10 03:27:45 -05:00
hz 579e1e9be4 Refine CCS desktop shortcuts 2026-08-10 01:05:06 -05:00
hz 71f33453d3 Polish CCS computer desktop 2026-08-10 00:45:01 -05:00
hz 33b55753cb Expand CCS patient history 2026-08-09 23:59:28 -05:00
hz d349998d12 Make CCS vitals window draggable 2026-08-09 22:20:20 -05:00
hz 63b9d3c557 Fix CCS vitals layout 2026-08-09 21:35:41 -05:00
hz 065db96211 Add CCS computer vitals app 2026-08-07 22:44:30 -05:00
hz 2b25e25424 Remove CCS viewport letterboxing 2026-08-07 19:17:20 -05:00
hz 91690b2fe9 Scale CCS experience to 16:9 2026-08-07 19:04:16 -05:00
hz e50a97ba91 Add simulated CCS computer 2026-08-07 18:31:00 -05:00
hz 41b3092552 Polish fullscreen CCS dialogue 2026-08-07 17:56:49 -05:00
hz bab73b53ff Pad CCS continuation prompt 2026-08-07 02:33:25 -05:00
hz a4115f4971 Fix CCS scene-wide dialogue clicks 2026-08-06 23:41:42 -05:00
hz 6f81e5af62 Improve CCS dialogue advancement 2026-08-06 22:51:25 -05:00
hz 6e039f00a7 Size CCS speech bubbles to content 2026-08-06 20:39:40 -05:00
hz dfc224660e Constrain CCS patient dialogue 2026-08-06 05:13:21 -05:00
hz 2c7c5dd387 Refine graphical CCS interview flow 2026-08-06 04:58:15 -05:00
hz ee8e8cf787 Improve CCS dialogue layout 2026-08-06 04:41:44 -05:00
hz 4792ff2620 Prevent hidden CCS opening overlays 2026-08-06 04:23:53 -05:00
hz 38d62918d4 Resume stale graphical CCS interviews 2026-08-06 03:34:07 -05:00
hz fa981f68ee Run UAT deploys for smoke test changes 2026-08-06 02:57:44 -05:00
hz 0c330380da Keep UAT CCS smoke on management cases 2026-08-06 02:57:08 -05:00
hz a0dae8e478 Make graphical CCS dialogue choice first 2026-08-06 02:48:44 -05:00
hz 58dd370149 Add graphical duodenal ulcer interview 2026-08-05 23:14:51 -05:00
hz 7709d16018 Show legacy CCS cases in interactive scene 2026-08-05 20:46:55 -05:00
28 changed files with 2647 additions and 104 deletions
+4
View File
@@ -98,6 +98,10 @@ if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then
echo "Dynamic question promotion failed after 3 attempts." >&2
exit 1
fi
kubectl exec -n "${KUBE_NAMESPACE}" "${API_POD}" -- \
dotnet QuestionsApi.dll promote-ccs-cases \
--source /app/content/ccs/duodenal-ulcer-office-001.json \
--apply
fi
kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s
+1 -1
View File
@@ -7,7 +7,7 @@ on:
paths-ignore:
- '**.md'
- 'tools/**'
- 'tests/**'
- 'tests/*.gd'
workflow_dispatch: {}
concurrency:
+156
View File
@@ -91,6 +91,41 @@ public sealed class CcsCaseTests
Assert.True(request!.Cases.Single().ScoringCriteria.Count >= 12);
}
[Fact]
public void DuodenalUlcerInterviewArtifactMatchesTheCaseSchema()
{
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../content/ccs/duodenal-ulcer-office-001.json"));
var request = JsonSerializer.Deserialize<CcsImportRequest>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.NotNull(request);
Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet<string>()));
Assert.Equal(CcsExperienceModes.GraphicalInterview, request.Cases.Single().ExperienceMode);
var topics = request.Cases.Single().InitialInterview.Topics;
Assert.Equal([
"chief_concern", "dizziness_character", "fatigue_course", "abdominal_character",
"relieving_factors", "associated_gi", "exposure_history", "past_medical_history"
], topics.Select(value => value.Id));
Assert.Equal(6, topics.Count(value => value.Required));
Assert.Equal(["fatigue_course"], topics.Single(value => value.Id == "abdominal_character").PrerequisiteTopicIds);
Assert.All(topics.Where(value => value.Id is "relieving_factors" or "associated_gi" or "exposure_history"),
value => Assert.Equal(["abdominal_character"], value.PrerequisiteTopicIds));
Assert.False(topics.Single(value => value.Id == "past_medical_history").Required);
Assert.Contains("cholesterol was high", topics.Single(value => value.Id == "past_medical_history").PatientText,
StringComparison.OrdinalIgnoreCase);
Assert.Equal(2, request.Cases.Single().GraphicalPresentation.InterviewRevision);
Assert.Equal("Hyperlipidemia", CcsStore.MedicalHistory(request.Cases.Single()));
var exams = request.Cases.Single().Exams;
Assert.Equal([
"physical_appearance", "skin", "breasts", "lymph_nodes", "heent_neck", "chest_lungs",
"heart_cardiovascular", "abdomen", "genitalia", "rectal", "extremities_spine", "neuro_psych"
], exams.Select(value => value.Code));
Assert.All(exams, value => Assert.Equal(1, value.DurationMinutes));
Assert.Contains("mild epigastric tenderness", exams.Single(value => value.Code == "abdomen").Finding,
StringComparison.OrdinalIgnoreCase);
Assert.Contains("brown stool", exams.Single(value => value.Code == "rectal").Finding,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PublishedCaseRequiresReferencesAndKnownOrders()
{
@@ -177,6 +212,108 @@ public sealed class CcsCaseTests
Assert.Throws<InvalidOperationException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow));
}
[Fact]
public void LegacyCaseGetsInterviewFromExistingChartNarrative()
{
var definition = ValidImport().Cases[0];
definition.InitialInterview = new();
definition.InitialChart.HistorySections =
[
new() { Code = "hpi", Title = "History of present illness", Text = "The symptoms began this morning." },
new() { Code = "medications", Title = "Medications", Text = "I take no regular medicines." }
];
var interview = CcsStore.EffectiveInterview(definition);
var attempt = new CcsAttempt { InterviewNodeId = interview.StartNodeId, OpeningCompleted = false };
Assert.Equal("opening", interview.StartNodeId);
Assert.Equal(5, interview.Nodes.Count);
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow);
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_history_1", DateTime.UtcNow);
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_history_2", DateTime.UtcNow);
CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow);
Assert.Equal("complete", attempt.InterviewNodeId);
Assert.True(attempt.OpeningCompleted);
}
[Fact]
public void AuthoredInterviewRemainsPreferredOverGeneratedFallback()
{
var definition = ValidImport().Cases[0];
definition.InitialInterview = ValidInterview();
Assert.Same(definition.InitialInterview, CcsStore.EffectiveInterview(definition));
}
[Fact]
public void TopicInterviewBranchesAndUnlocksFinishAfterRequiredTopics()
{
var definition = ValidImport().Cases[0];
definition.InitialInterview = TopicInterview();
var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = false };
var opening = CcsStore.CurrentInterviewNode(attempt, definition)!;
Assert.True(opening.SuppressPatientText);
Assert.Equal(["chief_concern"], opening.Choices.Select(value => value.Id));
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow));
CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow);
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<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow));
CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow);
var finishable = CcsStore.CurrentInterviewNode(attempt, definition)!;
Assert.Contains(finishable.Choices, value => value.Id == "finish_interview");
Assert.DoesNotContain(finishable.Choices, value => value.Id == "chief_concern");
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow));
CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow);
Assert.True(attempt.OpeningCompleted);
Assert.True(CcsStore.CurrentInterviewNode(attempt, definition)!.IsTerminal);
}
[Fact]
public void TopicInterviewRepairsLegacyOpeningCompletionFlag()
{
var definition = ValidImport().Cases[0];
definition.InitialInterview = TopicInterview();
var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = true };
CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow);
Assert.False(attempt.OpeningCompleted);
Assert.Equal("chief_concern", attempt.InterviewNodeId);
}
[Fact]
public void TopicInterviewRejectsPrerequisiteCycles()
{
var request = ValidImport();
request.Cases[0].InitialInterview = TopicInterview();
request.Cases[0].InitialInterview.Topics[0].PrerequisiteTopicIds = ["required_history"];
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
Assert.Contains(errors, value => value.Contains("prerequisites cannot contain cycles", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void CaseContentComparisonIgnoresRevisionMetadata()
{
var first = ValidImport().Cases[0];
var second = ValidImport().Cases[0];
first.Id = "507f1f77bcf86cd799439011";
first.Version = 4;
first.IsCurrent = false;
first.CreatedUtc = DateTime.UtcNow.AddDays(-2);
second.InitialChart.SimulatedStartUtc = first.InitialChart.SimulatedStartUtc;
Assert.Equal(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second));
second.Title = "Changed title";
Assert.NotEqual(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second));
}
[Fact]
public void CcsExpectedScoreUsesSeparateUserAndCaseElo()
{
@@ -253,4 +390,23 @@ public sealed class CcsCaseTests
new() { Id = "done", PatientText = "Thank you.", IsTerminal = true }
]
};
private static CcsInitialInterview TopicInterview() => new()
{
OpeningPatientText = "Good morning.", ClosingPatientText = "Thank you.", FinishChoiceText = "Finish",
Topics =
[
new() { Id = "chief_concern", ClinicianText = "What brings you in?", PatientText = "I feel tired." },
new()
{
Id = "required_history", ClinicianText = "Tell me more.", PatientText = "It started recently.", Required = true,
PrerequisiteTopicIds = ["chief_concern"]
},
new()
{
Id = "optional_history", ClinicianText = "Anything else?", PatientText = "No.",
PrerequisiteTopicIds = ["chief_concern"]
}
]
};
}
+2
View File
@@ -9,6 +9,7 @@ RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj"
COPY api/QuestionsApi/ api/QuestionsApi/
COPY content/dynamic_questions/ content/dynamic_questions/
COPY content/ccs/ content/ccs/
WORKDIR /src/api/QuestionsApi
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION}
@@ -17,6 +18,7 @@ ARG BUILD_REVISION
WORKDIR /app
COPY --from=build /app/publish .
COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions
COPY --from=build /src/content/ccs ./content/ccs
ENV ASPNETCORE_URLS=http://+:5008 \
ASPNETCORE_ENVIRONMENT=Production \
+45
View File
@@ -64,6 +64,8 @@ public class CcsCaseRevision
public int RealTimeMinutes { get; set; } = 20;
public int Difficulty { get; set; } = 2;
public List<string> Tags { get; set; } = [];
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
public CcsInitialChart InitialChart { get; set; } = new();
public CcsInitialInterview InitialInterview { get; set; } = new();
public List<string> AllowedLocations { get; set; } = [];
@@ -79,12 +81,47 @@ public class CcsInitialInterview
{
public string StartNodeId { get; set; } = "";
public List<CcsDialogueNode> Nodes { get; set; } = [];
public string OpeningPatientText { get; set; } = "";
public List<CcsInterviewTopic> Topics { get; set; } = [];
public string FinishChoiceText { get; set; } = "Finish the interview";
public string ClosingPatientText { get; set; } = "Thank you, doctor.";
public CcsInterviewDebrief Debrief { get; set; } = new();
}
public class CcsInterviewTopic
{
public string Id { get; set; } = "";
public string ClinicianText { get; set; } = "";
public string PatientText { get; set; } = "";
public bool Required { get; set; }
public List<string> PrerequisiteTopicIds { get; set; } = [];
}
public class CcsInterviewDebrief
{
public string LikelyDiagnosis { get; set; } = "";
public string Summary { get; set; } = "";
public List<string> TeachingPoints { get; set; } = [];
}
public class CcsGraphicalPresentation
{
public string SceneKey { get; set; } = "";
public string PatientAssetKey { get; set; } = "";
public int InterviewRevision { get; set; }
}
public static class CcsExperienceModes
{
public const string Management = "management";
public const string GraphicalInterview = "graphical_interview";
}
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<CcsDialogueChoice> Choices { get; set; } = [];
}
@@ -270,6 +307,7 @@ public class CcsImportResponse
{
public int OrdersUpserted { get; set; }
public int CasesCreated { get; set; }
public int CasesSkipped { get; set; }
public List<string> Errors { get; set; } = [];
}
@@ -312,6 +350,9 @@ public class CcsCaseSummary
public int Difficulty { get; set; }
public List<string> Tags { get; set; } = [];
public bool InteractiveInterviewAvailable { get; set; }
public bool GraphicalPresentationAvailable { get; set; }
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
}
public class CcsAttemptResponse
@@ -332,11 +373,14 @@ public class CcsAttemptResponse
public DateTime CreatedUtc { get; set; }
public int RealTimeMinutes { get; set; }
public int ElapsedSeconds { get; set; }
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
public string PatientSummary { get; set; } = "";
public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = "";
public string History { get; set; } = "";
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
public string MedicalHistory { get; set; } = "";
public Dictionary<string, string> Vitals { get; set; } = [];
public List<CcsChartEntry> Chart { get; set; } = [];
public List<string> ActiveOrderCodes { get; set; } = [];
@@ -346,6 +390,7 @@ public class CcsAttemptResponse
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
public List<string> AllowedLocations { get; set; } = [];
public CcsInterviewStateResponse? Interview { get; set; }
public CcsInterviewDebrief? InterviewDebrief { get; set; }
public CcsCaseScore? Score { get; set; }
public CcsRatingChange? Rating { get; set; }
public List<QuestionReference> References { get; set; } = [];
+6
View File
@@ -29,6 +29,12 @@ if (args.FirstOrDefault() == "promote-dynamic-questions")
return;
}
if (args.FirstOrDefault() == "promote-ccs-cases")
{
Environment.ExitCode = await CcsPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
if (args.FirstOrDefault() == "export-dynamic-review")
{
Environment.ExitCode = await DynamicReviewExportCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
@@ -34,6 +34,12 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(item.PresentingLocation)) errors.Add(prefix + "presentingLocation required");
if (item.RealTimeMinutes is not (10 or 20)) errors.Add(prefix + "realTimeMinutes must be 10 or 20");
if (item.Difficulty is < 1 or > 5) errors.Add(prefix + "difficulty must be between 1 and 5");
if (item.ExperienceMode is not (CcsExperienceModes.Management or CcsExperienceModes.GraphicalInterview))
errors.Add(prefix + "experienceMode is invalid");
if (item.ExperienceMode == CcsExperienceModes.GraphicalInterview
&& (string.IsNullOrWhiteSpace(item.GraphicalPresentation.SceneKey)
|| string.IsNullOrWhiteSpace(item.GraphicalPresentation.PatientAssetKey)))
errors.Add(prefix + "graphical interviews require sceneKey and patientAssetKey");
if (string.IsNullOrWhiteSpace(item.InitialChart.PatientSummary) || string.IsNullOrWhiteSpace(item.InitialChart.ReasonForEncounter))
errors.Add(prefix + "initial patient summary and reason are required");
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
@@ -73,6 +79,10 @@ public static class CcsCaseValidator
if (criterion.ActionType == "order" && !orderCodes.Contains(target)) errors.Add(prefix + $"criterion {criterion.Id} references unknown order {target}");
if (criterion.ActionType == "exam" && !examCodes.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown exam {target}");
if (criterion.ActionType == "location" && !item.AllowedLocations.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown location {target}");
if (criterion.ActionType == "dialogue_choice"
&& !item.InitialInterview.Topics.Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase))
&& !item.InitialInterview.Nodes.SelectMany(value => value.Choices).Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase)))
errors.Add(prefix + $"criterion {criterion.Id} references unknown dialogue choice {target}");
}
}
}
@@ -123,6 +133,55 @@ public static class CcsCaseValidator
private static void ValidateInterview(CcsInitialInterview interview, string prefix, List<string> errors)
{
if (interview.Topics.Count > 0)
{
if (interview.Nodes.Count > 0 || !string.IsNullOrWhiteSpace(interview.StartNodeId))
errors.Add(prefix + "initialInterview cannot combine topics with nodes");
if (string.IsNullOrWhiteSpace(interview.OpeningPatientText))
errors.Add(prefix + "topic interview requires openingPatientText");
if (string.IsNullOrWhiteSpace(interview.FinishChoiceText) || string.IsNullOrWhiteSpace(interview.ClosingPatientText))
errors.Add(prefix + "topic interview requires finishChoiceText and closingPatientText");
var topicIds = interview.Topics.Select(value => value.Id).ToList();
if (topicIds.Any(string.IsNullOrWhiteSpace)
|| topicIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != topicIds.Count)
errors.Add(prefix + "interview topic ids must be present and unique");
if (topicIds.Contains("finish_interview", StringComparer.OrdinalIgnoreCase))
errors.Add(prefix + "finish_interview is a reserved interview topic id");
if (!interview.Topics.Any(value => value.Required))
errors.Add(prefix + "topic interview requires at least one required topic");
foreach (var topic in interview.Topics)
{
if (string.IsNullOrWhiteSpace(topic.ClinicianText))
errors.Add(prefix + $"interview topic {topic.Id} requires clinicianText");
if (string.IsNullOrWhiteSpace(topic.PatientText))
errors.Add(prefix + $"interview topic {topic.Id} requires patientText");
foreach (var prerequisite in topic.PrerequisiteTopicIds)
if (!topicIds.Contains(prerequisite, StringComparer.OrdinalIgnoreCase)
|| prerequisite.Equals(topic.Id, StringComparison.OrdinalIgnoreCase))
errors.Add(prefix + $"interview topic {topic.Id} has an invalid prerequisite {prerequisite}");
}
var topicMap = interview.Topics.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
var resolved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var resolving = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool ResolveTopic(string topicId)
{
if (resolving.Contains(topicId))
{
errors.Add(prefix + "interview topic prerequisites cannot contain cycles");
return false;
}
if (resolved.Contains(topicId)) return true;
resolving.Add(topicId);
if (topicMap.TryGetValue(topicId, out var topic))
foreach (var prerequisite in topic.PrerequisiteTopicIds.Where(topicMap.ContainsKey))
ResolveTopic(prerequisite);
resolving.Remove(topicId);
resolved.Add(topicId);
return true;
}
foreach (var topic in interview.Topics) ResolveTopic(topic.Id);
return;
}
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; }
@@ -0,0 +1,92 @@
using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
using QuestionsApi.Models;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class CcsPromotionCommand
{
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase);
var sourceIndex = Array.FindIndex(args, value => value.Equals("--source", StringComparison.OrdinalIgnoreCase));
var source = sourceIndex >= 0 && sourceIndex + 1 < args.Length
? args[sourceIndex + 1]
: Path.Combine(AppContext.BaseDirectory, "content", "ccs");
var files = File.Exists(source)
? new[] { source }
: Directory.Exists(source)
? Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories).ToArray()
: [];
if (files.Length == 0)
{
await output.WriteLineAsync($"CCS content not found: {source}");
return 2;
}
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true };
var request = new CcsImportRequest();
foreach (var file in files)
{
var parsed = JsonSerializer.Deserialize<CcsImportRequest>(await File.ReadAllTextAsync(file), json)
?? throw new InvalidDataException($"Could not parse CCS content: {file}");
request.Orders.AddRange(parsed.Orders);
request.Cases.AddRange(parsed.Cases);
}
var duplicateOrders = request.Orders.GroupBy(value => value.Code, StringComparer.OrdinalIgnoreCase)
.Where(value => value.Count() > 1).Select(value => value.Key).ToList();
if (duplicateOrders.Count > 0)
throw new InvalidDataException($"Duplicate CCS order codes: {string.Join(", ", duplicateOrders)}");
var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables().Build();
var mongo = new MongoDatabaseProvider(configuration);
var orders = mongo.Database.GetCollection<CcsOrderDefinition>("CcsOrderCatalog");
var cases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
var existingCodes = (await orders.Find(Builders<CcsOrderDefinition>.Filter.Empty)
.Project(value => value.Code).ToListAsync()).ToHashSet(StringComparer.OrdinalIgnoreCase);
var errors = CcsCaseValidator.ValidateImport(request, existingCodes);
if (errors.Count > 0)
{
foreach (var error in errors) await output.WriteLineAsync(error);
return 1;
}
await output.WriteLineAsync($"Validated {request.Cases.Count} CCS case(s) and {request.Orders.Count} order(s).");
if (!apply)
{
await output.WriteLineAsync("Dry run only. Pass --apply to publish.");
return 0;
}
foreach (var order in request.Orders)
{
order.Code = order.Code.Trim().ToLowerInvariant();
order.UpdatedUtc = DateTime.UtcNow;
await orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
}
var created = 0;
var unchanged = 0;
foreach (var incoming in request.Cases)
{
var current = await cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
if (current is not null && CcsStore.CaseContentDocument(current).Equals(CcsStore.CaseContentDocument(incoming)))
{
unchanged++;
continue;
}
if (current is not null)
await cases.UpdateOneAsync(value => value.Id == current.Id,
Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
incoming.Id = null;
incoming.Version = (current?.Version ?? 0) + 1;
incoming.IsCurrent = true;
incoming.CreatedUtc = DateTime.UtcNow;
await cases.InsertOneAsync(incoming);
created++;
}
await output.WriteLineAsync($"Promotion complete: {created} created, {unchanged} unchanged.");
return 0;
}
}
+183 -14
View File
@@ -50,10 +50,16 @@ public sealed class CcsStore
await _orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
}
var created = 0;
var skipped = 0;
foreach (var incoming in request.Cases)
{
var current = await _cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
if (current is not null && CaseContentDocument(current).Equals(CaseContentDocument(incoming)))
{
skipped++;
continue;
}
if (current is not null)
await _cases.UpdateOneAsync(value => value.Id == current.Id, Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
incoming.Id = null;
@@ -63,7 +69,17 @@ public sealed class CcsStore
await _cases.InsertOneAsync(incoming);
created++;
}
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created };
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created, CasesSkipped = skipped };
}
internal static BsonDocument CaseContentDocument(CcsCaseRevision value)
{
var document = value.ToBsonDocument();
document.Remove("_id");
document.Remove(nameof(CcsCaseRevision.Version));
document.Remove(nameof(CcsCaseRevision.IsCurrent));
document.Remove(nameof(CcsCaseRevision.CreatedUtc));
return document;
}
public async Task<List<CcsCaseSummary>> ListCasesAsync() =>
@@ -108,6 +124,8 @@ public sealed class CcsStore
}
if (definition is null) throw new KeyNotFoundException("CCS case not found.");
var now = DateTime.UtcNow;
var interview = EffectiveInterview(definition);
var interviewStartNodeId = definition.InitialInterview.Topics.Count > 0 ? "opening" : interview.StartNodeId;
var attempt = new CcsAttempt
{
UserId = userId,
@@ -117,7 +135,7 @@ public sealed class CcsStore
CaseVersion = definition.Version,
Mode = CcsAttemptModes.Timed,
OpeningCompleted = false,
InterviewNodeId = definition.InitialInterview.StartNodeId,
InterviewNodeId = interviewStartNodeId,
Location = definition.PresentingLocation,
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
VitalHistory =
@@ -125,9 +143,11 @@ public sealed class CcsStore
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
],
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = true,
DeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? null : now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
HardDeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? null : now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = definition.ExperienceMode != CcsExperienceModes.GraphicalInterview,
Chart =
[
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
@@ -415,8 +435,10 @@ public sealed class CcsStore
? attempt.VitalHistory
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
CcsInterviewStateResponse? interview = null;
var currentInterviewNode = definition.InitialInterview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
var currentInterviewNode = CurrentInterviewNode(attempt, definition);
var openingCompleted = attempt.OpeningCompleted ?? true;
if (definition.InitialInterview.Topics.Count > 0 && currentInterviewNode?.IsTerminal != true)
openingCompleted = false;
if (currentInterviewNode is not null)
{
interview = new CcsInterviewStateResponse
@@ -424,7 +446,7 @@ public sealed class CcsStore
CurrentNodeId = currentInterviewNode.Id,
CurrentNode = currentInterviewNode,
VisitedChoiceIds = attempt.InterviewChoiceIds,
Completed = attempt.OpeningCompleted ?? false
Completed = openingCompleted
};
}
return new CcsAttemptResponse
@@ -433,14 +455,19 @@ public sealed class CcsStore
CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}",
Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision,
SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc,
OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location,
OpeningCompleted = openingCompleted, Location = attempt.Location,
DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc,
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
ExperienceMode = definition.ExperienceMode, GraphicalPresentation = definition.GraphicalPresentation,
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
HistorySections = definition.InitialChart.HistorySections,
Appearance = definition.InitialChart.Appearance,
History = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
? "" : definition.InitialChart.History,
HistorySections = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
? [] : definition.InitialChart.HistorySections,
MedicalHistory = MedicalHistory(definition),
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
@@ -450,6 +477,8 @@ public sealed class CcsStore
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Interview = interview,
InterviewDebrief = completed && definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? definition.InitialInterview.Debrief : null,
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
References = completed ? definition.References : []
};
@@ -457,13 +486,25 @@ public sealed class CcsStore
public static void ApplyDialogueChoice(CcsAttempt attempt, CcsCaseRevision definition, string choiceId, DateTime now)
{
if (definition.InitialInterview.Topics.Count > 0)
{
if (attempt.OpeningCompleted == true
&& attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("The initial interview is already complete.");
// Attempts that passed through the legacy classic opening can have
// this flag set before any graphical topic was completed.
attempt.OpeningCompleted = false;
ApplyTopicChoice(attempt, definition.InitialInterview, choiceId, now);
return;
}
if (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete.");
var node = definition.InitialInterview.Nodes.FirstOrDefault(value =>
var interview = EffectiveInterview(definition);
var node = interview.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 =>
var next = interview.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));
@@ -727,8 +768,136 @@ 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,
InteractiveInterviewAvailable = !string.IsNullOrWhiteSpace(value.InitialInterview.StartNodeId) && value.InitialInterview.Nodes.Count > 0
InteractiveInterviewAvailable = value.InitialInterview.Topics.Count > 0 || EffectiveInterview(value).Nodes.Count > 0,
GraphicalPresentationAvailable = !string.IsNullOrWhiteSpace(value.GraphicalPresentation.SceneKey)
&& !string.IsNullOrWhiteSpace(value.GraphicalPresentation.PatientAssetKey),
ExperienceMode = value.ExperienceMode, GraphicalPresentation = value.GraphicalPresentation
};
internal static CcsDialogueNode? CurrentInterviewNode(CcsAttempt attempt, CcsCaseRevision definition)
{
var topicInterview = definition.InitialInterview;
if (topicInterview.Topics.Count > 0)
{
if (attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
return new CcsDialogueNode
{
Id = "complete", PatientText = topicInterview.ClosingPatientText, IsTerminal = true
};
var patientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase)
? topicInterview.OpeningPatientText
: topicInterview.Topics.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))?.PatientText;
if (string.IsNullOrWhiteSpace(patientText)) return null;
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
var choices = topicInterview.Topics.Where(value => !visited.Contains(value.Id)
&& value.PrerequisiteTopicIds.All(visited.Contains))
.Select(value => new CcsDialogueChoice
{
Id = value.Id, Text = value.ClinicianText, NextNodeId = value.Id
}).ToList();
if (topicInterview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
choices.Add(new CcsDialogueChoice
{
Id = "finish_interview", Text = topicInterview.FinishChoiceText, NextNodeId = "complete"
});
return new CcsDialogueNode
{
Id = attempt.InterviewNodeId, PatientText = patientText,
SuppressPatientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase),
Choices = choices
};
}
var interview = EffectiveInterview(definition);
return interview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
}
private static void ApplyTopicChoice(CcsAttempt attempt, CcsInitialInterview interview, string choiceId, DateTime now)
{
if (choiceId.Equals("finish_interview", StringComparison.OrdinalIgnoreCase))
{
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!interview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
throw new ArgumentException("Complete the core interview topics before finishing.");
attempt.Actions.Add(Action("dialogue_choice", "finish_interview", attempt.SimulatedMinute, now));
attempt.InterviewChoiceIds.Add("finish_interview");
attempt.InterviewNodeId = "complete";
attempt.OpeningCompleted = true;
return;
}
var topic = interview.Topics.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
if (attempt.InterviewChoiceIds.Contains(topic.Id, StringComparer.OrdinalIgnoreCase))
throw new ArgumentException("That interview topic has already been covered.");
if (!topic.PrerequisiteTopicIds.All(value => attempt.InterviewChoiceIds.Contains(value, StringComparer.OrdinalIgnoreCase)))
throw new ArgumentException("That interview topic is not available yet.");
attempt.Actions.Add(Action("dialogue_choice", topic.Id, attempt.SimulatedMinute, now));
attempt.InterviewChoiceIds.Add(topic.Id);
attempt.InterviewNodeId = topic.Id;
}
internal static CcsInitialInterview EffectiveInterview(CcsCaseRevision definition)
{
if (!string.IsNullOrWhiteSpace(definition.InitialInterview.StartNodeId)
&& definition.InitialInterview.Nodes.Count > 0)
return definition.InitialInterview;
var narrative = definition.InitialChart.HistorySections
.Where(value => !string.IsNullOrWhiteSpace(value.Text))
.Select((value, index) => new
{
Id = $"history_{index + 1}",
Prompt = string.IsNullOrWhiteSpace(value.Title)
? "Can you tell me more about your history?"
: $"Can you tell me about your {value.Title.Trim().ToLowerInvariant()}?",
value.Text
}).ToList();
if (narrative.Count == 0 && !string.IsNullOrWhiteSpace(definition.InitialChart.History))
narrative.Add(new { Id = "history_1", Prompt = "Can you tell me more about your history?", Text = definition.InitialChart.History });
var nodes = new List<CcsDialogueNode>
{
new()
{
Id = "opening", PatientText = "Hello, doctor.", SuppressPatientText = true,
Choices = [new() { Id = "ask_reason", Text = "What brings you in today?", NextNodeId = "reason" }]
},
new()
{
Id = "reason", PatientText = definition.InitialChart.ReasonForEncounter,
Choices = [new()
{
Id = narrative.Count > 0 ? "ask_history_1" : "finish_interview",
Text = narrative.Count > 0 ? narrative[0].Prompt : "Is there anything else you would like me to know?",
NextNodeId = narrative.Count > 0 ? narrative[0].Id : "complete"
}]
}
};
for (var index = 0; index < narrative.Count; index++)
{
var next = index + 1 < narrative.Count ? narrative[index + 1] : null;
nodes.Add(new CcsDialogueNode
{
Id = narrative[index].Id, PatientText = narrative[index].Text,
Choices = [new()
{
Id = next is null ? "finish_interview" : $"ask_history_{index + 2}",
Text = next?.Prompt ?? "Is there anything else you would like me to know?",
NextNodeId = next?.Id ?? "complete"
}]
});
}
nodes.Add(new CcsDialogueNode
{
Id = "complete", PatientText = "That is everything I can think of, doctor.", IsTerminal = true
});
return new CcsInitialInterview { StartNodeId = "opening", Nodes = nodes };
}
internal static string MedicalHistory(CcsCaseRevision definition) =>
definition.InitialChart.HistorySections.FirstOrDefault(value =>
value.Code.Equals("past_medical_history", StringComparison.OrdinalIgnoreCase))?.Text ?? "";
internal static int Rank(CcsOrderDefinition order, string query)
{
var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant());
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
</svg>

After

Width:  |  Height:  |  Size: 379 B

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cj8q5fugoyfhq"
path="res://.godot/imported/medical-history.svg-8cd75462cd9d8822133509e0f23e2799.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/icons/medical-history.svg"
dest_files=["res://.godot/imported/medical-history.svg-8cd75462cd9d8822133509e0f23e2799.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z"/>
<path d="M3.22 12H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27"/>
</svg>

After

Width:  |  Height:  |  Size: 394 B

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b8juqb8k774q4"
path="res://.godot/imported/vitals.svg-aeab46849d322f67e09ecb738933ea93.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/icons/vitals.svg"
dest_files=["res://.godot/imported/vitals.svg-aeab46849d322f67e09ecb738933ea93.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://crss7xdb4asxw"
path="res://.godot/imported/computer-base.png-c22fe210cc788b2a30551b9078564447.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/images/computer/computer-base.png"
dest_files=["res://.godot/imported/computer-base.png-c22fe210cc788b2a30551b9078564447.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="14" x="2" y="3" rx="2"/>
<line x1="8" x2="16" y1="21" y2="21"/>
<line x1="12" x2="12" y1="17" y2="21"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://diqn3ntrortw"
path="res://.godot/imported/monitor.svg-1101ac90716471f71a4ffd9814ccca4c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/monitor.svg"
dest_files=["res://.godot/imported/monitor.svg-1101ac90716471f71a4ffd9814ccca4c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+166
View File
@@ -0,0 +1,166 @@
{
"orders": [],
"cases": [
{
"externalId": "ccs-gastro-duodenal-ulcer-office-001",
"isPublished": true,
"title": "Office Visit — 44-year-old man",
"system": "Gastrointestinal System",
"presentingLocation": "office",
"realTimeMinutes": 10,
"difficulty": 1,
"tags": ["history taking", "dyspepsia", "duodenal ulcer", "Helicobacter pylori"],
"experienceMode": "graphical_interview",
"graphicalPresentation": {
"sceneKey": "doctor_office",
"patientAssetKey": "patient_44_white_male",
"interviewRevision": 2
},
"initialChart": {
"patientSummary": "44-year-old man attending an office visit",
"reasonForEncounter": "The reason for the visit has not yet been elicited.",
"appearance": "He is seated comfortably, attentive, and in no acute distress.",
"history": "Several weeks of intermittent lightheadedness and fatigue accompany three weeks of sleep-disrupting, burning epigastric discomfort rated 4/10. Food and antacids provide temporary relief. He reports belching, fullness, and occasional black stool without frank blood. He has hyperlipidemia and no recent sick contacts, travel, or antibiotic use.",
"historySections": [
{ "code": "chief_concern", "title": "Chief concern", "text": "Intermittent lightheadedness and fatigue for several weeks." },
{ "code": "abdominal_symptoms", "title": "Abdominal symptoms", "text": "Burning, non-radiating epigastric discomfort has been present for three weeks, is rated 4/10, is unaffected by position or movement, and improves temporarily after food or antacids." },
{ "code": "associated_symptoms", "title": "Associated symptoms", "text": "Belching, fullness, sleep disruption, and occasional black stool without frank blood." },
{ "code": "past_medical_history", "title": "Past medical history", "text": "Hyperlipidemia" },
{ "code": "exposure_history", "title": "Exposure history", "text": "No recent sick contacts, travel, or antibiotic use." }
],
"vitals": {
"temperature": "36.8 C (98.2 F)",
"bloodPressure": "122/76 mm Hg",
"pulse": "76/min",
"respirations": "14/min",
"oxygenSaturation": "99% on room air"
},
"simulatedStartUtc": "2026-08-05T09:00:00Z",
"initialOrderCodes": []
},
"initialInterview": {
"openingPatientText": "Good morning, doctor.",
"finishChoiceText": "Thank you. I have enough history for now.",
"closingPatientText": "Thank you, doctor. I hope we can figure out what has been going on.",
"topics": [
{
"id": "chief_concern",
"clinicianText": "Good morning. What brings you in today?",
"patientText": "For the past few weeks, I have felt worn out, and every so often I get lightheaded—especially when I stand up quickly.",
"required": false
},
{
"id": "dizziness_character",
"clinicianText": "Tell me more about the dizziness. Does the room spin, and have you fainted?",
"patientText": "The room does not spin. It is more like I might black out for a second when I get up, but I have never actually fainted. I have had no weakness, numbness, trouble speaking, chest pain, or racing heartbeat.",
"required": true,
"prerequisiteTopicIds": ["chief_concern"]
},
{
"id": "fatigue_course",
"clinicianText": "How has the fatigue affected you, and how have you been sleeping and eating?",
"patientText": "My energy is down by the afternoon. My appetite is about the same, but lately a burning discomfort high in my abdomen sometimes wakes me at night.",
"required": true,
"prerequisiteTopicIds": ["chief_concern"]
},
{
"id": "abdominal_character",
"clinicianText": "Tell me more about the abdominal discomfort—where is it, what does it feel like, and how long has it been happening?",
"patientText": "It started about three weeks ago. It is a burning pain in the upper middle part of my abdomen. It does not spread anywhere, I would rate it about a four out of ten, and changing position or moving does not make it better or worse.",
"required": true,
"prerequisiteTopicIds": ["fatigue_course"]
},
{
"id": "relieving_factors",
"clinicianText": "Does anything make the discomfort better or worse?",
"patientText": "Eating usually settles it for a while, and antacids help too. Position and movement do not seem to change it.",
"required": true,
"prerequisiteTopicIds": ["abdominal_character"]
},
{
"id": "associated_gi",
"clinicianText": "Have you noticed belching, fullness, vomiting, or any blood or change in the color of your stool?",
"patientText": "I have been belching more and sometimes feel unusually full. I have not vomited or seen red blood, but my stool has looked black on occasion.",
"required": true,
"prerequisiteTopicIds": ["abdominal_character"]
},
{
"id": "exposure_history",
"clinicianText": "Have you had any sick contacts, recent travel, or antibiotic use?",
"patientText": "No. I have not been around anyone sick, traveled recently, or taken antibiotics.",
"required": true,
"prerequisiteTopicIds": ["abdominal_character"]
},
{
"id": "past_medical_history",
"clinicianText": "Do you have any past medical conditions?",
"patientText": "A few years ago, my doctor told me my cholesterol was high. Other than that, I have not been diagnosed with any major medical problems.",
"required": false,
"prerequisiteTopicIds": ["chief_concern"]
}
],
"debrief": {
"likelyDiagnosis": "Peptic ulcer disease with possible intermittent upper gastrointestinal bleeding",
"summary": "The history identifies orthostatic lightheadedness and fatigue accompanying burning epigastric pain relieved by food and antacids, with occasional black stool concerning for intermittent upper gastrointestinal bleeding.",
"teachingPoints": [
"Clarify whether dizziness is vertigo, presyncope, or disequilibrium and screen for neurologic and cardiac warning symptoms.",
"Characterize epigastric pain by timing, quality, severity, radiation, triggers, and relieving factors.",
"Ask specifically about melena or hematemesis because occult or intermittent bleeding can present with fatigue and lightheadedness."
]
}
},
"allowedLocations": ["office"],
"exams": [
{ "code": "physical_appearance", "name": "Physical Appearance", "durationMinutes": 1, "finding": "The patient is alert, cooperative, and in no acute distress. He appears well nourished." },
{ "code": "skin", "name": "Skin", "durationMinutes": 1, "finding": "Skin is warm and dry without rash, jaundice, bruising, or visible pallor." },
{ "code": "breasts", "name": "Breasts", "durationMinutes": 1, "finding": "With the patient's consent and a chaperone present, examination shows no masses, tenderness, skin changes, or nipple discharge." },
{ "code": "lymph_nodes", "name": "Lymph Nodes", "durationMinutes": 1, "finding": "No cervical, supraclavicular, axillary, or inguinal lymphadenopathy is present." },
{ "code": "heent_neck", "name": "HEENT / Neck", "durationMinutes": 1, "finding": "Conjunctivae and oral mucosa are normal. Oropharynx is clear, neck is supple, and there is no thyromegaly or jugular venous distention." },
{ "code": "chest_lungs", "name": "Chest / Lungs", "durationMinutes": 1, "finding": "Breathing is unlabored. Lungs are clear to auscultation bilaterally without wheezes, crackles, or rhonchi." },
{ "code": "heart_cardiovascular", "name": "Heart / Cardiovascular", "durationMinutes": 1, "finding": "Heart has a regular rate and rhythm with normal S1 and S2 and no murmurs, rubs, or gallops. Peripheral pulses are symmetric." },
{ "code": "abdomen", "name": "Abdomen", "durationMinutes": 1, "finding": "The abdomen is soft and nondistended with mild epigastric tenderness. There is no guarding, rebound tenderness, palpable mass, or hepatosplenomegaly. Bowel sounds are normal." },
{ "code": "genitalia", "name": "Genitalia", "durationMinutes": 1, "finding": "With the patient's consent and a chaperone present, external genital examination is normal without lesions, masses, tenderness, or hernia." },
{ "code": "rectal", "name": "Rectal", "durationMinutes": 1, "finding": "With the patient's consent and a chaperone present, rectal tone is normal and no mass or tenderness is present. Brown stool without gross blood is noted." },
{ "code": "extremities_spine", "name": "Extremities / Spine", "durationMinutes": 1, "finding": "Extremities have full range of motion without edema, clubbing, cyanosis, or joint swelling. The spine is nontender." },
{ "code": "neuro_psych", "name": "Neuro / Psych", "durationMinutes": 1, "finding": "He is alert and fully oriented with normal speech, strength, sensation, coordination, gait, mood, and affect." }
],
"orderResponses": [],
"events": [],
"scoringCriteria": [
{ "id": "elicit_chief_concern", "title": "Elicit the chief concern", "actionType": "dialogue_choice", "targetCode": "chief_concern", "points": 10, "rationale": "An open-ended opening allows the patient to identify lightheadedness and fatigue in his own words." },
{ "id": "characterize_dizziness", "title": "Characterize the dizziness", "actionType": "dialogue_choice", "targetCode": "dizziness_character", "points": 15, "rationale": "Distinguishing presyncope from vertigo and screening for neurologic and cardiac warning symptoms focuses the differential." },
{ "id": "explore_fatigue", "title": "Explore fatigue, sleep, and intake", "actionType": "dialogue_choice", "targetCode": "fatigue_course", "points": 10, "rationale": "The course and associated symptoms help characterize the nonspecific fatigue." },
{ "id": "characterize_abdominal_pain", "title": "Characterize the abdominal discomfort", "actionType": "dialogue_choice", "targetCode": "abdominal_character", "points": 15, "rationale": "Location, quality, duration, severity, and radiation help characterize the source of pain." },
{ "id": "identify_relief", "title": "Identify relieving and aggravating factors", "actionType": "dialogue_choice", "targetCode": "relieving_factors", "points": 10, "rationale": "Temporary relief with food or antacids supports an acid-related pattern." },
{ "id": "screen_gi_bleeding", "title": "Screen for gastrointestinal bleeding", "actionType": "dialogue_choice", "targetCode": "associated_gi", "points": 15, "rationale": "Occasional black stool may represent melena and requires recognition." },
{ "id": "review_exposures", "title": "Review relevant exposures", "actionType": "dialogue_choice", "targetCode": "exposure_history", "points": 5, "rationale": "Recent illness, travel, and antibiotic exposure help refine the gastrointestinal differential." },
{ "id": "assess_appearance", "title": "Assess physical appearance", "actionType": "exam", "targetCode": "physical_appearance", "points": 5, "rationale": "General appearance helps assess acuity and physiologic compromise." },
{ "id": "examine_skin", "title": "Examine the skin", "actionType": "exam", "targetCode": "skin", "points": 5, "rationale": "Skin examination screens for pallor, jaundice, bruising, and other systemic clues." },
{ "id": "examine_lungs", "title": "Examine the chest and lungs", "actionType": "exam", "targetCode": "chest_lungs", "points": 5, "rationale": "A cardiopulmonary examination helps evaluate associated fatigue and lightheadedness." },
{ "id": "examine_heart", "title": "Examine the cardiovascular system", "actionType": "exam", "targetCode": "heart_cardiovascular", "points": 5, "rationale": "Cardiovascular examination helps assess presyncope and hemodynamic status." },
{ "id": "examine_abdomen", "title": "Examine the abdomen", "actionType": "exam", "targetCode": "abdomen", "points": 15, "rationale": "Focused abdominal examination identifies epigastric tenderness and screens for peritoneal signs or masses." },
{ "id": "perform_rectal_exam", "title": "Perform a rectal examination", "actionType": "exam", "targetCode": "rectal", "points": 10, "rationale": "Rectal examination can assess for current gross gastrointestinal bleeding when the history includes black stool." },
{ "id": "perform_neurologic_exam", "title": "Perform a neurologic examination", "actionType": "exam", "targetCode": "neuro_psych", "points": 5, "rationale": "A focused neurologic examination helps evaluate lightheadedness and exclude focal deficits." }
],
"references": [
{
"title": "Peptic Ulcer Disease",
"publisher": "American College of Gastroenterology",
"url": "https://gi.org/topics/peptic-ulcer-disease/",
"supports": "Typical ulcer symptoms, bleeding warning symptoms, NSAID history, H. pylori evaluation, and endoscopic diagnosis",
"accessedOn": "2026-08-05",
"reviewer": "Question bank editorial review"
},
{
"title": "ACG Guideline on Treatment of Helicobacter pylori: New Recommendations",
"publisher": "American College of Gastroenterology",
"url": "https://gi.org/journals-publications/ebgi/schoenfeld_sep2024/",
"supports": "H. pylori treatment principles and confirmation of eradication",
"accessedOn": "2026-08-05",
"reviewer": "Question bank editorial review"
}
]
}
],
"validateOnly": false
}
+4
View File
@@ -4,6 +4,10 @@ CCS content is imported through `POST /api/ccs/admin/import` by a `SUPER` user.
Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validateOnly` to `true` first, then submit the same payload with `false`. Updating an existing `externalId` creates a new immutable revision; active and completed attempts remain attached to the revision they started with.
Topic-based graphical interviews use `initialInterview.openingPatientText` plus a `topics` array instead of `startNodeId` and `nodes`. Each topic supplies a stable id, clinician prompt, patient response, and optional `required` and `prerequisiteTopicIds` fields. Required topics gate `finish_interview`; visited topics are removed from the available choices. Set `experienceMode` to `graphical_interview` and provide a supported `graphicalPresentation` to complete in CCS Cases 2 with an authored debrief instead of handing off to classic patient management.
UAT deployment promotes `content/ccs/duodenal-ulcer-office-001.json` through the idempotent `promote-ccs-cases` command. An unchanged case is skipped; changed content creates the next immutable revision.
## 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.
+344 -21
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=8 format=3]
[gd_scene load_steps=13 format=3]
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/office-backdrop.png" id="1_backdrop"]
[ext_resource type="Script" path="res://scripts/ui/ccs_cases_2/doctor_office.gd" id="2_script"]
@@ -6,6 +6,11 @@
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/patient-44-white-male.png" id="4_patient"]
[ext_resource type="Shader" path="res://shaders/ccs_cases_2/patient_idle.gdshader" id="5_patient_fit"]
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/chat-bubble.png" id="6_chat_bubble"]
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/computer/computer-base.png" id="7_computer"]
[ext_resource type="Texture2D" path="res://assets/icons/lucide/monitor.svg" id="8_monitor"]
[ext_resource type="Texture2D" path="res://assets/icons/lucide/x.svg" id="9_close"]
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/icons/vitals.svg" id="10_vitals"]
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/icons/medical-history.svg" id="11_history"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"]
shader = ExtResource("5_patient_fit")
@@ -103,15 +108,17 @@ antialiased = true
[node name="DialogueBubble" type="Control" parent="."]
z_index = 30
clip_contents = true
layout_mode = 1
anchors_preset = 0
anchor_left = 0.51
anchor_top = 0.055
anchor_right = 0.51
anchor_bottom = 0.055
offset_right = 420.0
offset_bottom = 140.0
mouse_filter = 2
anchor_left = 0.53
anchor_top = 0.04
anchor_right = 0.53
anchor_bottom = 0.04
offset_right = 650.0
offset_bottom = 220.0
mouse_default_cursor_shape = 2
focus_mode = 2
[node name="Background" type="TextureRect" parent="DialogueBubble"]
layout_mode = 1
@@ -126,33 +133,349 @@ texture = ExtResource("6_chat_bubble")
expand_mode = 1
stretch_mode = 0
[node name="Text" type="Label" parent="DialogueBubble"]
[node name="Text" type="RichTextLabel" parent="DialogueBubble"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 76.0
offset_left = 96.0
offset_top = 31.0
offset_right = -48.0
offset_bottom = -43.0
offset_right = -64.0
offset_bottom = -64.0
grow_horizontal = 2
grow_vertical = 2
theme_override_colors/font_color = Color(0.055, 0.105, 0.145, 1)
theme_override_font_sizes/font_size = 22
theme_override_font_sizes/font_size = 20
text = "Hello, doctor."
fit_content = false
scroll_active = false
autowrap_mode = 2
visible_characters_behavior = 1
vertical_alignment = 1
[node name="DialogueChoices" type="VBoxContainer" parent="."]
[node name="ContinueHint" type="Label" parent="DialogueBubble"]
layout_mode = 1
anchors_preset = 3
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -330.0
offset_top = -55.0
offset_right = -90.0
offset_bottom = -27.0
grow_horizontal = 0
grow_vertical = 0
mouse_filter = 2
theme_override_colors/font_color = Color(0.12, 0.22, 0.28, 0.82)
theme_override_font_sizes/font_size = 15
text = "Click to continue"
horizontal_alignment = 2
vertical_alignment = 1
[node name="DialogueChoiceArea" type="Control" parent="."]
z_index = 29
layout_mode = 1
anchors_preset = 0
anchor_left = 0.5
anchors_preset = 12
anchor_top = 1.0
anchor_right = 0.5
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -210.0
offset_top = -206.0
offset_right = 210.0
offset_left = 18.0
offset_top = -194.0
offset_right = -18.0
offset_bottom = -4.0
theme_override_constants/separation = -8
[node name="ChoicePager" type="HBoxContainer" parent="DialogueChoiceArea"]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
offset_bottom = 42.0
grow_horizontal = 2
theme_override_constants/separation = 12
alignment = 1
[node name="Previous" type="Button" parent="DialogueChoiceArea/ChoicePager"]
custom_minimum_size = Vector2(108, 38)
layout_mode = 2
text = "Previous"
[node name="Page" type="Label" parent="DialogueChoiceArea/ChoicePager"]
custom_minimum_size = Vector2(100, 38)
layout_mode = 2
theme_override_colors/font_color = Color(0.94, 0.95, 0.91, 1)
theme_override_colors/font_shadow_color = Color(0.02, 0.035, 0.05, 0.9)
theme_override_constants/shadow_offset_x = 1
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 17
text = "Page 1 of 2"
horizontal_alignment = 1
vertical_alignment = 1
[node name="Next" type="Button" parent="DialogueChoiceArea/ChoicePager"]
custom_minimum_size = Vector2(108, 38)
layout_mode = 2
text = "Next"
[node name="DialogueChoices" type="HBoxContainer" parent="DialogueChoiceArea"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 46.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 10
alignment = 1
[node name="ComputerLauncher" type="Button" parent="."]
z_index = 40
layout_mode = 0
offset_left = 24.0
offset_top = 24.0
offset_right = 188.0
offset_bottom = 78.0
mouse_default_cursor_shape = 2
tooltip_text = "Open simulated computer"
icon = ExtResource("8_monitor")
icon_max_width = 25
expand_icon = true
text = "Computer"
[node name="ComputerOverlay" type="Control" parent="."]
visible = false
z_index = 100
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 0
[node name="Dimmer" type="ColorRect" parent="ComputerOverlay"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 0
color = Color(0.015, 0.025, 0.04, 0.86)
[node name="Screen" type="TextureRect" parent="ComputerOverlay"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
texture = ExtResource("7_computer")
expand_mode = 1
stretch_mode = 0
[node name="VitalsLauncher" type="Button" parent="ComputerOverlay"]
layout_mode = 0
offset_left = 30.0
offset_top = 32.0
offset_right = 142.0
offset_bottom = 138.0
mouse_default_cursor_shape = 2
icon = ExtResource("10_vitals")
icon_max_width = 58
expand_icon = true
icon_alignment = 1
vertical_icon_alignment = 0
[node name="Label" type="Label" parent="ComputerOverlay/VitalsLauncher"]
layout_mode = 1
anchors_preset = 12
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -34.0
grow_horizontal = 2
grow_vertical = 0
mouse_filter = 2
theme_override_colors/font_color = Color(0.96, 0.98, 1, 1)
theme_override_colors/font_outline_color = Color(0.01, 0.04, 0.07, 0.95)
theme_override_constants/outline_size = 4
theme_override_font_sizes/font_size = 17
text = "Vitals"
horizontal_alignment = 1
vertical_alignment = 1
[node name="MedicalHistoryLauncher" type="Button" parent="ComputerOverlay"]
layout_mode = 0
offset_left = 30.0
offset_top = 154.0
offset_right = 166.0
offset_bottom = 260.0
mouse_default_cursor_shape = 2
icon = ExtResource("11_history")
icon_max_width = 58
expand_icon = true
icon_alignment = 1
vertical_icon_alignment = 0
[node name="Label" type="Label" parent="ComputerOverlay/MedicalHistoryLauncher"]
layout_mode = 1
anchors_preset = 12
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -34.0
grow_horizontal = 2
grow_vertical = 0
mouse_filter = 2
theme_override_colors/font_color = Color(0.96, 0.98, 1, 1)
theme_override_colors/font_outline_color = Color(0.01, 0.04, 0.07, 0.95)
theme_override_constants/outline_size = 4
theme_override_font_sizes/font_size = 16
text = "Medical History"
horizontal_alignment = 1
vertical_alignment = 1
[node name="VitalsWindow" type="PanelContainer" parent="ComputerOverlay"]
visible = false
z_index = 2
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -360.0
offset_top = -270.0
offset_right = 360.0
offset_bottom = 270.0
grow_horizontal = 2
grow_vertical = 2
[node name="Margin" type="MarginContainer" parent="ComputerOverlay/VitalsWindow"]
layout_mode = 2
theme_override_constants/margin_left = 32
theme_override_constants/margin_top = 24
theme_override_constants/margin_right = 32
theme_override_constants/margin_bottom = 28
[node name="Content" type="VBoxContainer" parent="ComputerOverlay/VitalsWindow/Margin"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="Header" type="HBoxContainer" parent="ComputerOverlay/VitalsWindow/Margin/Content"]
layout_mode = 2
mouse_default_cursor_shape = 13
[node name="Title" type="Label" parent="ComputerOverlay/VitalsWindow/Margin/Content/Header"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 30
text = "Vitals"
vertical_alignment = 1
[node name="Close" type="Button" parent="ComputerOverlay/VitalsWindow/Margin/Content/Header"]
custom_minimum_size = Vector2(48, 48)
layout_mode = 2
mouse_default_cursor_shape = 2
tooltip_text = "Close vital signs"
icon = ExtResource("9_close")
icon_max_width = 24
expand_icon = true
[node name="Divider" type="HSeparator" parent="ComputerOverlay/VitalsWindow/Margin/Content"]
layout_mode = 2
[node name="Rows" type="GridContainer" parent="ComputerOverlay/VitalsWindow/Margin/Content"]
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/h_separation = 40
theme_override_constants/v_separation = 12
columns = 2
[node name="Empty" type="Label" parent="ComputerOverlay/VitalsWindow/Margin/Content"]
visible = false
layout_mode = 2
size_flags_vertical = 3
theme_override_font_sizes/font_size = 20
text = "No nurse vital signs are available."
horizontal_alignment = 1
vertical_alignment = 1
[node name="MedicalHistoryWindow" type="PanelContainer" parent="ComputerOverlay"]
visible = false
z_index = 2
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -330.0
offset_top = -210.0
offset_right = 330.0
offset_bottom = 210.0
grow_horizontal = 2
grow_vertical = 2
[node name="Margin" type="MarginContainer" parent="ComputerOverlay/MedicalHistoryWindow"]
layout_mode = 2
theme_override_constants/margin_left = 32
theme_override_constants/margin_top = 24
theme_override_constants/margin_right = 32
theme_override_constants/margin_bottom = 28
[node name="Content" type="VBoxContainer" parent="ComputerOverlay/MedicalHistoryWindow/Margin"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="Header" type="HBoxContainer" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
layout_mode = 2
mouse_default_cursor_shape = 13
[node name="Title" type="Label" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 30
text = "Medical History"
vertical_alignment = 1
[node name="Close" type="Button" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header"]
custom_minimum_size = Vector2(48, 48)
layout_mode = 2
mouse_default_cursor_shape = 2
tooltip_text = "Close medical history"
icon = ExtResource("9_close")
icon_max_width = 24
expand_icon = true
[node name="Divider" type="HSeparator" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
layout_mode = 2
[node name="SectionTitle" type="Label" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
layout_mode = 2
theme_override_colors/font_color = Color(0.18, 0.25, 0.31, 1)
theme_override_font_sizes/font_size = 20
text = "Past Medical History"
[node name="Text" type="Label" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
layout_mode = 2
theme_override_colors/font_color = Color(0.04, 0.09, 0.13, 1)
theme_override_font_sizes/font_size = 22
text = "No past medical history is available."
[node name="Close" type="Button" parent="ComputerOverlay"]
layout_mode = 1
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -88.0
offset_top = 20.0
offset_right = -24.0
offset_bottom = 84.0
grow_horizontal = 0
mouse_default_cursor_shape = 2
tooltip_text = "Close computer"
icon = ExtResource("9_close")
icon_max_width = 30
expand_icon = true
+843 -36
View File
@@ -2,46 +2,139 @@ extends Control
signal choice_selected(choice_id: String)
signal interview_continue_requested
signal physical_exam_selected(exam_code: String)
signal physical_exam_finish_requested
signal debrief_return_requested
signal debrief_repeat_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")
const CHOICE_PRESSED := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-pressed.png")
const CHOICE_CROP := Rect2(80.0, 200.0, 1610.0, 485.0)
const CHOICES_PER_PAGE := 4
const DIALOGUE_PAGE_MIN_CHARS := 32
const DIALOGUE_PAGE_MAX_CHARS := 82
const DIALOGUE_BUBBLE_MIN_HEIGHT := 130.0
const DIALOGUE_BUBBLE_MAX_HEIGHT := 240.0
const DIALOGUE_BUBBLE_VERTICAL_CHROME := 95.0
@onready var hour_hand: Node2D = $ClockMount/HourHand
@onready var minute_hand: Node2D = $ClockMount/MinuteHand
@onready var second_hand: Node2D = $ClockMount/SecondHand
@onready var patient: TextureRect = $Patient
@onready var dialogue_bubble: Control = $DialogueBubble
@onready var dialogue_text: Label = $DialogueBubble/Text
@onready var choice_container: VBoxContainer = $DialogueChoices
@onready var dialogue_text: RichTextLabel = $DialogueBubble/Text
@onready var dialogue_continue_hint: Label = $DialogueBubble/ContinueHint
@onready var choice_area: Control = $DialogueChoiceArea
@onready var choice_container: HBoxContainer = $DialogueChoiceArea/DialogueChoices
@onready var choice_pager: HBoxContainer = $DialogueChoiceArea/ChoicePager
@onready var previous_page_button: Button = $DialogueChoiceArea/ChoicePager/Previous
@onready var page_label: Label = $DialogueChoiceArea/ChoicePager/Page
@onready var next_page_button: Button = $DialogueChoiceArea/ChoicePager/Next
@onready var computer_launcher: Button = $ComputerLauncher
@onready var computer_overlay: Control = $ComputerOverlay
@onready var computer_close_button: Button = $ComputerOverlay/Close
@onready var vitals_launcher: Button = $ComputerOverlay/VitalsLauncher
@onready var vitals_window: PanelContainer = $ComputerOverlay/VitalsWindow
@onready var vitals_close_button: Button = $ComputerOverlay/VitalsWindow/Margin/Content/Header/Close
@onready var vitals_header: HBoxContainer = $ComputerOverlay/VitalsWindow/Margin/Content/Header
@onready var vitals_rows: GridContainer = $ComputerOverlay/VitalsWindow/Margin/Content/Rows
@onready var vitals_empty: Label = $ComputerOverlay/VitalsWindow/Margin/Content/Empty
@onready var medical_history_launcher: Button = $ComputerOverlay/MedicalHistoryLauncher
@onready var medical_history_window: PanelContainer = $ComputerOverlay/MedicalHistoryWindow
@onready var medical_history_header: HBoxContainer = $ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header
@onready var medical_history_close_button: Button = $ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header/Close
@onready var medical_history_text: Label = $ComputerOverlay/MedicalHistoryWindow/Margin/Content/Text
var blink_tween: Tween
var dialogue_tween: Tween
var clock_seconds := 9.0 * 60.0 * 60.0
var debrief_panel: PanelContainer
var physical_exam_panel: PanelContainer
var physical_exam_grid: GridContainer
var physical_exam_finding_title: Label
var physical_exam_finding_text: Label
var physical_exam_status: Label
var physical_exam_finish_button: Button
var physical_exam_buttons := {}
var experience_mode := "management"
var pending_choices: Array = []
var available_choices: Array = []
var choice_page := 0
var dialogue_pages: Array[String] = []
var dialogue_page_index := 0
var dialogue_characters_per_second := 35.0
var dialogue_reveal_complete := false
var dialogue_layout_generation := 0
var dialogue_input_enabled := true
var computer_available := true
var computer_drag_window: Control
var computer_drag_offset := Vector2.ZERO
var computer_window_z := 2
func _ready() -> void:
previous_page_button.pressed.connect(_change_choice_page.bind(-1))
next_page_button.pressed.connect(_change_choice_page.bind(1))
computer_launcher.pressed.connect(open_computer)
computer_close_button.pressed.connect(close_computer)
vitals_launcher.pressed.connect(open_vitals)
vitals_close_button.pressed.connect(close_vitals)
medical_history_launcher.pressed.connect(open_medical_history)
medical_history_close_button.pressed.connect(close_medical_history)
vitals_header.gui_input.connect(_on_computer_window_header_input.bind(vitals_window, vitals_close_button))
medical_history_header.gui_input.connect(_on_computer_window_header_input.bind(
medical_history_window, medical_history_close_button))
vitals_window.gui_input.connect(_on_computer_window_input.bind(vitals_window))
medical_history_window.gui_input.connect(_on_computer_window_input.bind(medical_history_window))
resized.connect(_resize_computer_windows)
_style_vitals_window()
_style_computer_window(medical_history_window)
_style_desktop_shortcut(vitals_launcher)
_style_desktop_shortcut(medical_history_launcher)
_resize_computer_windows()
_update_clock()
_schedule_patient_blink()
show_patient_dialogue("Loading patient interview...")
hide_patient_dialogue()
hide_dialogue_choices()
func _process(_delta: float) -> void:
_update_clock()
pass
func _update_clock() -> void:
var time := Time.get_time_dict_from_system()
var unix_time := Time.get_unix_time_from_system()
var second := float(time.second) + fmod(unix_time, 1.0)
var minute := float(time.minute) + second / 60.0
var hour := float(time.hour % 12) + minute / 60.0
var hour_value := floori(clock_seconds / 3600.0)
var minute_value := floori(fmod(clock_seconds, 3600.0) / 60.0)
var second_value := fmod(clock_seconds, 60.0)
var second := float(second_value)
var minute := float(minute_value) + second / 60.0
var hour := float(hour_value % 12) + minute / 60.0
hour_hand.rotation = TAU * hour / 12.0
minute_hand.rotation = TAU * minute / 60.0
second_hand.rotation = TAU * second / 60.0
func configure_presentation(
presentation: Dictionary, simulated_start: String, simulated_minute: int,
vitals: Dictionary = {}, medical_history: String = "") -> void:
if str(presentation.get("patientAssetKey", "")) != "patient_44_white_male":
push_warning("Unsupported CCS patient asset key; using the office default.")
var date_time := Time.get_datetime_dict_from_datetime_string(simulated_start, false)
if not date_time.is_empty():
clock_seconds = float(int(date_time.get("hour", 9)) * 3600 + int(date_time.get("minute", 0)) * 60)
clock_seconds += float(simulated_minute * 60)
_update_clock()
_render_vitals(vitals)
medical_history_text.text = medical_history.strip_edges() \
if not medical_history.strip_edges().is_empty() \
else "No past medical history is available."
func configure_experience_mode(value: String) -> void:
experience_mode = value
func _schedule_patient_blink() -> void:
blink_tween = create_tween()
blink_tween.tween_interval(randf_range(2.8, 5.2))
@@ -62,57 +155,483 @@ func _set_patient_blink(amount: float) -> void:
func show_patient_dialogue(text: String, characters_per_second: float = 35.0) -> void:
if dialogue_tween and dialogue_tween.is_valid():
dialogue_tween.kill()
dialogue_text.text = text
dialogue_pages = _split_dialogue_pages(text)
dialogue_page_index = 0
dialogue_characters_per_second = characters_per_second
_show_dialogue_page()
func _show_dialogue_page() -> void:
if dialogue_tween and dialogue_tween.is_valid():
dialogue_tween.kill()
if dialogue_pages.is_empty():
complete_response_transition()
return
dialogue_text.text = dialogue_pages[dialogue_page_index]
dialogue_text.visible_ratio = 0.0
dialogue_reveal_complete = false
dialogue_continue_hint.text = "Click to continue"
dialogue_continue_hint.visible = true
dialogue_bubble.visible = true
_resize_dialogue_bubble()
var reveal_duration := float(text.length()) / maxf(characters_per_second, 1.0)
dialogue_bubble.grab_focus()
dialogue_layout_generation += 1
call_deferred("_prepare_dialogue_page", dialogue_layout_generation)
func _prepare_dialogue_page(generation: int) -> void:
if generation != dialogue_layout_generation or not dialogue_bubble.visible \
or dialogue_reveal_complete:
return
var measured_height := dialogue_text.get_content_height() + DIALOGUE_BUBBLE_VERTICAL_CHROME
dialogue_bubble.size.y = clampf(
measured_height, DIALOGUE_BUBBLE_MIN_HEIGHT, DIALOGUE_BUBBLE_MAX_HEIGHT)
var reveal_duration := float(dialogue_text.text.length()) / maxf(dialogue_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_layout_generation += 1
dialogue_bubble.visible = false
dialogue_continue_hint.visible = false
dialogue_pages = []
dialogue_page_index = 0
dialogue_reveal_complete = false
pending_choices = []
func is_patient_dialogue_visible() -> bool:
return dialogue_bubble.visible
func _resize_dialogue_bubble() -> void:
# Label wrapping is calculated from the fixed text width immediately after
# assignment, so explicit newlines and automatic wraps both affect height.
var line_count := maxi(dialogue_text.get_line_count(), 1)
var target_height := clampf(110.0 + line_count * 30.0, 140.0, 320.0)
dialogue_bubble.size.y = target_height
func configure_interview(node: Dictionary) -> void:
show_patient_dialogue(str(node.get("patientText", "")))
computer_available = true
computer_launcher.visible = not computer_overlay.visible
if debrief_panel != null and is_instance_valid(debrief_panel):
debrief_panel.queue_free()
debrief_panel = null
var next_choices: Array
if bool(node.get("isTerminal", false)):
show_dialogue_choices([{"id": "__continue", "text": "Continue to Patient Management"}])
next_choices = [{
"id": "__continue",
"text": "Begin Physical Examination" 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_reveal_complete:
return
if dialogue_tween and dialogue_tween.is_valid():
dialogue_tween.kill()
dialogue_layout_generation += 1
dialogue_text.visible_ratio = 1.0
_on_dialogue_reveal_finished()
func _on_dialogue_reveal_finished() -> void:
dialogue_tween = null
dialogue_reveal_complete = true
dialogue_continue_hint.text = "Click to continue"
dialogue_continue_hint.visible = true
func advance_dialogue() -> void:
if not dialogue_bubble.visible:
return
if not dialogue_reveal_complete:
complete_dialogue_reveal()
return
if dialogue_page_index < dialogue_pages.size() - 1:
dialogue_page_index += 1
_show_dialogue_page()
return
complete_response_transition()
func complete_response_transition() -> void:
var choices := pending_choices
pending_choices = []
dialogue_bubble.visible = false
dialogue_continue_hint.visible = false
dialogue_pages = []
dialogue_page_index = 0
dialogue_reveal_complete = false
show_dialogue_choices(choices)
func _input(event: InputEvent) -> void:
if computer_overlay.visible:
return
if _event_targets_computer_launcher(event):
return
if not dialogue_input_enabled or not dialogue_bubble.visible:
return
var should_advance := false
if event is InputEventMouseButton:
should_advance = event.button_index == MOUSE_BUTTON_LEFT and event.pressed \
and get_global_rect().has_point(event.position)
elif event is InputEventScreenTouch:
should_advance = event.pressed and get_global_rect().has_point(event.position)
elif event is InputEventKey:
should_advance = event.pressed and not event.echo \
and event.keycode in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE]
if not should_advance:
return
advance_dialogue()
get_viewport().set_input_as_handled()
func set_dialogue_input_enabled(enabled: bool) -> void:
dialogue_input_enabled = enabled
func open_computer() -> void:
if not computer_available or computer_overlay.visible:
return
computer_overlay.visible = true
computer_launcher.visible = false
if dialogue_tween and dialogue_tween.is_valid():
dialogue_tween.pause()
computer_close_button.grab_focus()
func close_computer() -> bool:
if not computer_overlay.visible:
return false
close_vitals()
close_medical_history()
computer_overlay.visible = false
computer_launcher.visible = computer_available
if dialogue_tween and dialogue_tween.is_valid():
dialogue_tween.play()
return true
func is_computer_open() -> bool:
return computer_overlay.visible
func close_transient_overlay() -> bool:
var top_window := _topmost_computer_window()
if top_window == vitals_window:
return close_vitals()
if top_window == medical_history_window:
return close_medical_history()
return close_computer()
func open_vitals() -> void:
if not computer_overlay.visible:
return
vitals_window.visible = true
_bring_computer_window_to_front(vitals_window)
vitals_close_button.grab_focus()
func close_vitals() -> bool:
if not vitals_window.visible:
return false
if computer_drag_window == vitals_window:
computer_drag_window = null
vitals_window.visible = false
return true
func is_vitals_open() -> bool:
return vitals_window.visible
func open_medical_history() -> void:
if not computer_overlay.visible:
return
medical_history_window.visible = true
_bring_computer_window_to_front(medical_history_window)
medical_history_close_button.grab_focus()
func close_medical_history() -> bool:
if not medical_history_window.visible:
return false
if computer_drag_window == medical_history_window:
computer_drag_window = null
medical_history_window.visible = false
return true
func is_medical_history_open() -> bool:
return medical_history_window.visible
func _render_vitals(vitals: Dictionary) -> void:
for child in vitals_rows.get_children():
vitals_rows.remove_child(child)
child.queue_free()
var rows := _build_vital_rows(vitals)
for row in rows:
var name_label := Label.new()
name_label.custom_minimum_size.x = 230.0
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_label.add_theme_font_size_override("font_size", 19)
name_label.add_theme_color_override("font_color", Color(0.18, 0.25, 0.31))
name_label.text = "%s:" % str(row.get("label", "Vital sign"))
vitals_rows.add_child(name_label)
var value_label := Label.new()
value_label.custom_minimum_size.x = 230.0
value_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
value_label.add_theme_font_size_override("font_size", 19)
value_label.add_theme_color_override("font_color", Color(0.04, 0.09, 0.13))
value_label.text = str(row.get("value", ""))
value_label.autowrap_mode = TextServer.AUTOWRAP_OFF
value_label.clip_text = true
vitals_rows.add_child(value_label)
vitals_rows.visible = not rows.is_empty()
vitals_empty.visible = rows.is_empty()
_resize_computer_windows()
func _build_vital_rows(vitals: Dictionary) -> Array[Dictionary]:
var rows: Array[Dictionary] = []
var displayed := {}
_append_vital(rows, displayed, vitals, "temperature", "Temperature")
_append_vital(rows, displayed, vitals, "pulse", "Pulse")
_append_vital(rows, displayed, vitals, "respirations", "Respiratory rate")
var blood_pressure := str(vitals.get("bloodPressure", "")).strip_edges()
if not blood_pressure.is_empty():
displayed["bloodPressure"] = true
var pattern := RegEx.create_from_string("^\\s*([^/]+)\\s*/\\s*([0-9.]+)(.*)$")
var matched := pattern.search(blood_pressure)
if matched:
var suffix := matched.get_string(3).strip_edges()
var unit_suffix := " %s" % suffix if not suffix.is_empty() else ""
rows.append({"label": "Blood pressure, systolic", "value": matched.get_string(1).strip_edges() + unit_suffix})
rows.append({"label": "Blood pressure, diastolic", "value": matched.get_string(2).strip_edges() + unit_suffix})
else:
rows.append({"label": "Blood pressure", "value": blood_pressure})
_append_vital(rows, displayed, vitals, "oxygenSaturation", "Oxygen saturation")
_append_vital(rows, displayed, vitals, "height", "Height")
_append_vital(rows, displayed, vitals, "weight", "Weight")
_append_vital(rows, displayed, vitals, "bmi", "Body Mass Index")
var remaining_keys: Array = vitals.keys()
remaining_keys.sort()
for raw_key in remaining_keys:
var key := str(raw_key)
if not displayed.has(key):
_append_vital(rows, displayed, vitals, key, _friendly_vital_name(key))
return rows
func _append_vital(
rows: Array[Dictionary], displayed: Dictionary, vitals: Dictionary,
key: String, label: String) -> void:
displayed[key] = true
var value := str(vitals.get(key, "")).strip_edges()
if not value.is_empty():
rows.append({"label": label, "value": value})
func _friendly_vital_name(key: String) -> String:
var result := ""
for index in key.length():
var character := key[index]
if index > 0 and character == character.to_upper() and character != character.to_lower():
result += " "
result += character
return result.capitalize()
func _style_vitals_window() -> void:
_style_computer_window(vitals_window)
func _style_computer_window(window: PanelContainer) -> void:
var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0.97, 0.98, 0.99, 0.98)
panel_style.border_color = Color(0.12, 0.24, 0.34, 0.9)
panel_style.set_border_width_all(3)
panel_style.set_corner_radius_all(14)
panel_style.shadow_color = Color(0.0, 0.0, 0.0, 0.35)
panel_style.shadow_size = 18
window.add_theme_stylebox_override("panel", panel_style)
func _style_desktop_shortcut(button: Button) -> void:
var normal := StyleBoxEmpty.new()
var hover := StyleBoxFlat.new()
hover.bg_color = Color(0.82, 0.91, 0.97, 0.18)
hover.border_color = Color(0.9, 0.96, 1.0, 0.5)
hover.set_border_width_all(1)
hover.set_corner_radius_all(8)
var pressed := hover.duplicate() as StyleBoxFlat
pressed.bg_color = Color(0.72, 0.86, 0.96, 0.28)
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", pressed)
button.add_theme_stylebox_override("focus", hover)
func _resize_computer_windows() -> void:
if vitals_window == null or medical_history_window == null:
return
var vital_row_count := vitals_rows.get_child_count() / 2
var vitals_content_height := clampf(180.0 + float(vital_row_count) * 36.0, 300.0, 540.0)
var vitals_size := Vector2(
minf(720.0, maxf(320.0, size.x - 48.0)),
minf(vitals_content_height, maxf(300.0, size.y - 48.0)))
_set_centered_window_rect(vitals_window, vitals_size, Vector2(-55.0, -25.0))
var history_size := Vector2(
minf(560.0, maxf(320.0, size.x - 48.0)),
minf(300.0, maxf(260.0, size.y - 48.0)))
_set_centered_window_rect(medical_history_window, history_size, Vector2(55.0, 25.0))
func _set_centered_window_rect(window: Control, window_size: Vector2, offset: Vector2) -> void:
window.offset_left = -window_size.x * 0.5 + offset.x
window.offset_top = -window_size.y * 0.5 + offset.y
window.offset_right = window_size.x * 0.5 + offset.x
window.offset_bottom = window_size.y * 0.5 + offset.y
_set_computer_window_position(window, window.position)
func _on_computer_window_header_input(
event: InputEvent, window: Control, close_button: Button) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
if close_button.get_global_rect().has_point(event.global_position):
return
computer_drag_window = window
computer_drag_offset = event.global_position - window.global_position
_bring_computer_window_to_front(window)
else:
computer_drag_window = null
window.accept_event()
elif event is InputEventMouseMotion and computer_drag_window == window:
var target_global: Vector2 = event.global_position - computer_drag_offset
_set_computer_window_position(window, target_global - global_position)
window.accept_event()
func _on_computer_window_input(event: InputEvent, window: Control) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
_bring_computer_window_to_front(window)
func _set_vitals_window_position(target: Vector2) -> void:
_set_computer_window_position(vitals_window, target)
func _set_medical_history_window_position(target: Vector2) -> void:
_set_computer_window_position(medical_history_window, target)
func _set_computer_window_position(window: Control, target: Vector2) -> void:
var maximum := Vector2(
maxf(0.0, size.x - window.size.x),
maxf(0.0, size.y - window.size.y))
window.position = Vector2(
clampf(target.x, 0.0, maximum.x),
clampf(target.y, 0.0, maximum.y))
func _bring_computer_window_to_front(window: Control) -> void:
computer_window_z += 1
window.z_index = computer_window_z
func _topmost_computer_window() -> Control:
var top_window: Control
for window in [vitals_window, medical_history_window]:
if window.visible and (top_window == null or window.z_index > top_window.z_index):
top_window = window
return top_window
func _event_targets_computer_launcher(event: InputEvent) -> bool:
if not computer_launcher.visible:
return false
if event is InputEventMouseButton:
return event.button_index == MOUSE_BUTTON_LEFT \
and computer_launcher.get_global_rect().has_point(event.position)
if event is InputEventScreenTouch:
return computer_launcher.get_global_rect().has_point(event.position)
return false
func _split_dialogue_pages(text: String) -> Array[String]:
var normalized := text.strip_edges()
if normalized.is_empty():
return []
var pages: Array[String] = []
var current := ""
for word in normalized.split(" ", false):
var candidate := word if current.is_empty() else "%s %s" % [current, word]
var has_natural_break := current.length() >= DIALOGUE_PAGE_MIN_CHARS \
and _ends_dialogue_phrase(word)
if candidate.length() > DIALOGUE_PAGE_MAX_CHARS and not current.is_empty():
pages.append(current)
current = word
elif has_natural_break:
pages.append(candidate)
current = ""
else:
current = candidate
if not current.is_empty():
pages.append(current)
return pages
func _ends_dialogue_phrase(word: String) -> bool:
return word.ends_with(",") or word.ends_with(";") or word.ends_with(":") \
or word.ends_with(".") or word.ends_with("?") or word.ends_with("!")
func set_choices_pending(pending: bool) -> void:
for child in choice_container.get_children():
if child is BaseButton:
child.disabled = pending
previous_page_button.disabled = pending or choice_page <= 0
next_page_button.disabled = pending or choice_page >= _choice_page_count() - 1
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: Dictionary = options[index]
available_choices = options.duplicate(true)
choice_page = 0
_render_choice_page()
func _render_choice_page() -> void:
_clear_choice_buttons()
var page_count := _choice_page_count()
if page_count == 0:
choice_area.visible = false
return
choice_page = clampi(choice_page, 0, page_count - 1)
var start_index := choice_page * CHOICES_PER_PAGE
var end_index := mini(start_index + CHOICES_PER_PAGE, available_choices.size())
var visible_choice_count := end_index - start_index
for index in range(start_index, end_index):
var option: Dictionary = available_choices[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.set_meta("choice_id", choice_id)
button.custom_minimum_size = Vector2(
0.0 if visible_choice_count == CHOICES_PER_PAGE else 350.0, 136.0)
button.size_flags_horizontal = (
Control.SIZE_EXPAND_FILL if visible_choice_count == CHOICES_PER_PAGE
else Control.SIZE_SHRINK_CENTER)
button.ignore_texture_size = true
button.stretch_mode = TextureButton.STRETCH_SCALE
button.texture_normal = _crop_choice_texture(CHOICE_NORMAL)
@@ -129,25 +648,57 @@ func show_dialogue_choices(options: Array) -> void:
var label := Label.new()
label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
label.offset_left = 48.0
label.offset_top = 14.0
label.offset_right = -48.0
label.offset_bottom = -14.0
label.offset_left = 28.0
label.offset_top = 12.0
label.offset_right = -28.0
label.offset_bottom = -12.0
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_color_override("font_color", Color(0.055, 0.105, 0.145))
label.add_theme_font_size_override("font_size", 19)
label.add_theme_font_size_override("font_size", _choice_font_size(option_text))
label.text = option_text
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.clip_text = true
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
button.add_child(label)
choice_container.add_child(button)
choice_container.visible = not options.is_empty()
choice_pager.visible = page_count > 1
page_label.text = "Page %d of %d" % [choice_page + 1, page_count]
previous_page_button.disabled = choice_page <= 0
next_page_button.disabled = choice_page >= page_count - 1
choice_area.visible = true
choice_container.visible = true
func hide_dialogue_choices() -> void:
for child in choice_container.get_children():
child.queue_free()
_clear_choice_buttons()
available_choices = []
choice_page = 0
choice_container.visible = false
choice_pager.visible = false
choice_area.visible = false
func _clear_choice_buttons() -> void:
for child in choice_container.get_children():
choice_container.remove_child(child)
child.queue_free()
func _choice_page_count() -> int:
return ceili(float(available_choices.size()) / float(CHOICES_PER_PAGE))
func _change_choice_page(direction: int) -> void:
choice_page = clampi(choice_page + direction, 0, maxi(_choice_page_count() - 1, 0))
_render_choice_page()
func _choice_font_size(text: String) -> int:
if text.length() > 105:
return 15
if text.length() > 75:
return 16
return 17
func _crop_choice_texture(source: Texture2D) -> AtlasTexture:
@@ -175,3 +726,259 @@ func _on_dialogue_choice_pressed(choice_id: String) -> void:
interview_continue_requested.emit()
else:
choice_selected.emit(choice_id)
func show_physical_exam(exams: Array, chart: Array, pending_code: String = "") -> void:
hide_patient_dialogue()
hide_dialogue_choices()
computer_available = true
computer_launcher.visible = not computer_overlay.visible
_ensure_physical_exam_panel()
physical_exam_panel.visible = true
for child in physical_exam_grid.get_children():
physical_exam_grid.remove_child(child)
child.queue_free()
physical_exam_buttons.clear()
var completed := {}
for raw_entry in chart:
var entry: Dictionary = raw_entry
completed[str(entry.get("code", ""))] = entry
var exam_codes := {}
for raw_exam in exams:
var exam: Dictionary = raw_exam
var code := str(exam.get("code", ""))
if code.is_empty():
continue
exam_codes[code] = true
var button := Button.new()
var is_complete := completed.has(code)
button.custom_minimum_size = Vector2(0.0, 48.0)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.text = "%s%s" % ["[Done] " if is_complete else "", str(exam.get("name", code.capitalize()))]
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.disabled = is_complete or not pending_code.is_empty()
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.pressed.connect(_on_physical_exam_pressed.bind(code))
_style_physical_exam_button(button, is_complete)
physical_exam_grid.add_child(button)
physical_exam_buttons[code] = button
var latest_finding: Dictionary = {}
for index in range(chart.size() - 1, -1, -1):
var entry: Dictionary = chart[index]
if exam_codes.has(str(entry.get("code", ""))):
latest_finding = entry
break
if latest_finding.is_empty():
physical_exam_finding_title.text = "Examination Findings"
physical_exam_finding_text.text = "Select an examination component to perform it and review the finding."
else:
physical_exam_finding_title.text = str(latest_finding.get("title", "Examination Finding"))
physical_exam_finding_text.text = str(latest_finding.get("text", "No finding was reported."))
set_physical_exam_pending(pending_code)
func set_physical_exam_pending(exam_code: String) -> void:
var pending := not exam_code.is_empty()
for code in physical_exam_buttons:
var button: Button = physical_exam_buttons[code]
button.disabled = pending or button.text.begins_with("[Done]")
physical_exam_finish_button.disabled = pending
physical_exam_status.text = "Performing %s..." % _physical_exam_name(exam_code) if pending else ""
func is_physical_exam_visible() -> bool:
return physical_exam_panel != null and physical_exam_panel.visible
func show_physical_exam_error(message: String) -> void:
set_physical_exam_pending("")
physical_exam_status.text = message
func _ensure_physical_exam_panel() -> void:
if physical_exam_panel != null and is_instance_valid(physical_exam_panel):
return
physical_exam_panel = PanelContainer.new()
physical_exam_panel.name = "PhysicalExamination"
physical_exam_panel.z_index = 50
add_child(physical_exam_panel)
physical_exam_panel.set_anchors_preset(Control.PRESET_FULL_RECT)
physical_exam_panel.offset_left = 44.0
physical_exam_panel.offset_top = 28.0
physical_exam_panel.offset_right = -44.0
physical_exam_panel.offset_bottom = -28.0
var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0.96, 0.97, 0.98, 0.97)
panel_style.border_color = Color(0.12, 0.22, 0.28, 0.9)
panel_style.set_border_width_all(2)
panel_style.set_corner_radius_all(14)
physical_exam_panel.add_theme_stylebox_override("panel", panel_style)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 26)
margin.add_theme_constant_override("margin_top", 22)
margin.add_theme_constant_override("margin_right", 26)
margin.add_theme_constant_override("margin_bottom", 22)
physical_exam_panel.add_child(margin)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 10)
margin.add_child(content)
var heading := Label.new()
heading.text = "Physical Examination"
heading.add_theme_font_size_override("font_size", 30)
heading.add_theme_color_override("font_color", Color(0.055, 0.105, 0.145))
content.add_child(heading)
var instructions := Label.new()
instructions.text = "Select one component at a time. Completed examinations are marked below."
instructions.add_theme_font_size_override("font_size", 17)
instructions.add_theme_color_override("font_color", Color(0.18, 0.25, 0.31))
content.add_child(instructions)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
content.add_child(scroll)
physical_exam_grid = GridContainer.new()
physical_exam_grid.name = "ExamComponents"
physical_exam_grid.columns = 2
physical_exam_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
physical_exam_grid.add_theme_constant_override("h_separation", 12)
physical_exam_grid.add_theme_constant_override("v_separation", 8)
scroll.add_child(physical_exam_grid)
var divider := HSeparator.new()
content.add_child(divider)
physical_exam_finding_title = Label.new()
physical_exam_finding_title.add_theme_font_size_override("font_size", 21)
physical_exam_finding_title.add_theme_color_override("font_color", Color(0.08, 0.19, 0.27))
content.add_child(physical_exam_finding_title)
physical_exam_finding_text = Label.new()
physical_exam_finding_text.custom_minimum_size.y = 64.0
physical_exam_finding_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
physical_exam_finding_text.add_theme_font_size_override("font_size", 17)
physical_exam_finding_text.add_theme_color_override("font_color", Color(0.06, 0.1, 0.14))
content.add_child(physical_exam_finding_text)
var actions := HBoxContainer.new()
actions.alignment = BoxContainer.ALIGNMENT_END
actions.add_theme_constant_override("separation", 12)
content.add_child(actions)
physical_exam_status = Label.new()
physical_exam_status.size_flags_horizontal = Control.SIZE_EXPAND_FILL
physical_exam_status.add_theme_font_size_override("font_size", 16)
physical_exam_status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
actions.add_child(physical_exam_status)
physical_exam_finish_button = Button.new()
physical_exam_finish_button.text = "Finish Case"
physical_exam_finish_button.custom_minimum_size = Vector2(170.0, 48.0)
physical_exam_finish_button.pressed.connect(func(): physical_exam_finish_requested.emit())
actions.add_child(physical_exam_finish_button)
func _style_physical_exam_button(button: Button, complete: bool) -> void:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.88, 0.94, 0.97, 0.96) if complete else Color(1.0, 1.0, 1.0, 0.96)
style.border_color = Color(0.19, 0.52, 0.43) if complete else Color(0.45, 0.54, 0.61)
style.set_border_width_all(1)
style.set_corner_radius_all(8)
style.set_content_margin_all(12.0)
var hover := style.duplicate() as StyleBoxFlat
hover.border_color = Color(0.16, 0.45, 0.68)
button.add_theme_stylebox_override("normal", style)
button.add_theme_stylebox_override("disabled", style)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", hover)
button.add_theme_font_size_override("font_size", 17)
button.add_theme_color_override("font_color", Color(0.06, 0.12, 0.17))
button.add_theme_color_override("font_disabled_color", Color(0.12, 0.31, 0.26) if complete else Color(0.42, 0.47, 0.51))
func _physical_exam_name(code: String) -> String:
if physical_exam_buttons.has(code):
return str(physical_exam_buttons[code].text).trim_prefix("[Done] ")
return "examination"
func _on_physical_exam_pressed(code: String) -> void:
physical_exam_selected.emit(code)
func show_debrief(debrief: Dictionary, score: Dictionary) -> void:
if physical_exam_panel != null and is_instance_valid(physical_exam_panel):
physical_exam_panel.visible = false
computer_available = false
close_computer()
computer_launcher.visible = false
hide_patient_dialogue()
hide_dialogue_choices()
if debrief_panel != null and is_instance_valid(debrief_panel):
debrief_panel.queue_free()
debrief_panel = PanelContainer.new()
debrief_panel.name = "InterviewDebrief"
debrief_panel.z_index = 50
debrief_panel.size = Vector2(870.0, 600.0)
debrief_panel.position = (size - debrief_panel.size) * 0.5
var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0.96, 0.95, 0.91, 0.97)
panel_style.border_color = Color(0.12, 0.22, 0.28, 0.8)
panel_style.set_border_width_all(2)
panel_style.set_corner_radius_all(12)
panel_style.set_content_margin_all(28.0)
debrief_panel.add_theme_stylebox_override("panel", panel_style)
add_child(debrief_panel)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 16)
debrief_panel.add_child(content)
var heading := Label.new()
heading.text = "Interview Debrief"
heading.add_theme_font_size_override("font_size", 30)
heading.add_theme_color_override("font_color", Color(0.055, 0.105, 0.145))
content.add_child(heading)
var diagnosis := Label.new()
diagnosis.text = str(debrief.get("likelyDiagnosis", "Interview complete"))
diagnosis.add_theme_font_size_override("font_size", 23)
diagnosis.add_theme_color_override("font_color", Color(0.12, 0.36, 0.27))
diagnosis.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content.add_child(diagnosis)
var summary := Label.new()
summary.text = str(debrief.get("summary", ""))
summary.add_theme_font_size_override("font_size", 18)
summary.add_theme_color_override("font_color", Color(0.08, 0.12, 0.15))
summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content.add_child(summary)
var score_label := Label.new()
score_label.text = "History coverage: %d%%" % int(round(float(score.get("percent", 0.0))))
score_label.add_theme_font_size_override("font_size", 20)
content.add_child(score_label)
var covered: Array[String] = []
var missed: Array[String] = []
for raw_criterion in score.get("criteria", []):
var criterion: Dictionary = raw_criterion
var title := str(criterion.get("title", "History topic"))
if str(criterion.get("status", "")) == "credited":
covered.append(title)
elif str(criterion.get("status", "")) == "missed":
missed.append(title)
var coverage := Label.new()
coverage.text = "Covered: %s\nMissed optional topics: %s" % [
", ".join(covered), "None" if missed.is_empty() else ", ".join(missed)]
coverage.add_theme_font_size_override("font_size", 16)
coverage.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content.add_child(coverage)
var points := Label.new()
var teaching_points: Array = debrief.get("teachingPoints", [])
points.text = "Key points\n- " + "\n- ".join(teaching_points) if not teaching_points.is_empty() else ""
points.add_theme_font_size_override("font_size", 17)
points.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
points.size_flags_vertical = Control.SIZE_EXPAND_FILL
content.add_child(points)
var actions := HBoxContainer.new()
actions.alignment = BoxContainer.ALIGNMENT_END
actions.add_theme_constant_override("separation", 12)
content.add_child(actions)
var return_button := Button.new()
return_button.text = "Return to Cases"
return_button.custom_minimum_size = Vector2(190.0, 48.0)
return_button.pressed.connect(func(): debrief_return_requested.emit())
actions.add_child(return_button)
var repeat_button := Button.new()
repeat_button.text = "Repeat Interview"
repeat_button.custom_minimum_size = Vector2(190.0, 48.0)
repeat_button.pressed.connect(func(): debrief_repeat_requested.emit())
actions.add_child(repeat_button)
+176 -21
View File
@@ -3,7 +3,9 @@ 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)
const DESIGN_SIZE := Vector2(1600.0, 900.0)
const FOCUSED_INTERVIEW_CASE_ID := "ccs-gastro-duodenal-ulcer-office-001"
const FOCUSED_INTERVIEW_REVISION := 2
var main
var stage_root: Control
@@ -16,6 +18,8 @@ var current_view := ""
var cases_loaded := false
var active_loaded := false
var pending_action := ""
var pending_exam_code := ""
var restart_case_external_id := ""
func _ready() -> void:
@@ -39,10 +43,13 @@ func _connect_service() -> void:
CCSService.attempt_changed.connect(_on_attempt_changed)
if not CCSService.request_failed.is_connected(_on_request_failed):
CCSService.request_failed.connect(_on_request_failed)
if not CCSService.attempt_abandoned.is_connected(_on_attempt_abandoned):
CCSService.attempt_abandoned.connect(_on_attempt_abandoned)
func on_show(_payload: Variant = null) -> void:
CCSService.launch_origin = "ccs_cases_2"
restart_case_external_id = ""
cases_loaded = false
active_loaded = false
pending_action = "loading"
@@ -52,12 +59,27 @@ func on_show(_payload: Variant = null) -> void:
func on_hide() -> void:
restart_case_external_id = ""
_clear_active_scene()
func set_pause_menu_open(open: bool) -> void:
if active_scene != null and active_scene.has_method("set_dialogue_input_enabled"):
active_scene.set_dialogue_input_enabled(not open)
func close_transient_overlay() -> bool:
if active_scene != null and active_scene.has_method("close_transient_overlay"):
return bool(active_scene.close_transient_overlay())
return false
func compatible_cases(cases: Array) -> Array:
return cases.filter(func(item: Variant) -> bool:
return typeof(item) == TYPE_DICTIONARY and bool(item.get("interactiveInterviewAvailable", false)))
return typeof(item) == TYPE_DICTIONARY \
and bool(item.get("interactiveInterviewAvailable", false)) \
and bool(item.get("graphicalPresentationAvailable", false)) \
and str(item.get("graphicalPresentation", {}).get("sceneKey", "")) == "doctor_office")
func _on_cases_changed() -> void:
@@ -68,6 +90,7 @@ func _on_cases_changed() -> void:
func _on_attempt_changed() -> void:
active_loaded = true
pending_action = ""
pending_exam_code = ""
_render_state()
@@ -77,6 +100,10 @@ func _render_state() -> void:
if not active_loaded:
return
var attempt: Dictionary = CCSService.attempt
if not attempt.is_empty() and str(attempt.get("state", "")) == "completed" \
and str(attempt.get("experienceMode", "")) == "graphical_interview":
_render_debrief(attempt)
return
if not attempt.is_empty() and str(attempt.get("state", "")) in ["active", "final_orders"]:
_render_attempt(attempt)
elif cases_loaded and active_loaded:
@@ -85,20 +112,27 @@ 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()
if _is_retired_interview_revision(attempt, node):
_restart_with_current_interview(attempt)
return
_show_doctor_office(node)
if str(attempt.get("experienceMode", "management")) != "graphical_interview" \
and bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)):
_show_unsupported_attempt("This case has progressed beyond the graphical interview.")
return
if current_view == "physical_examination" and bool(node.get("isTerminal", false)):
_show_physical_examination(attempt)
return
_show_doctor_office(node, attempt)
func _build_selection_view() -> void:
@@ -113,7 +147,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)
@@ -151,12 +185,18 @@ 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)
var available := compatible_cases(CCSService.cases)
if available.is_empty():
status_label.text = "No published CCS cases currently include an interactive interview."
status_label.text = "No published CCS cases currently have a supported graphical interview."
return
status_label.text = "%d interactive %s available." % [available.size(), "case is" if available.size() == 1 else "cases are"]
retry_button.visible = false
@@ -183,13 +223,24 @@ func _start_case(case_external_id: String) -> void:
CCSService.create_attempt(case_external_id, "timed")
func _show_doctor_office(node: Dictionary) -> void:
func _show_doctor_office(node: Dictionary, attempt: 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)
active_scene.interview_continue_requested.connect(_begin_physical_examination)
active_scene.physical_exam_selected.connect(_submit_physical_exam)
active_scene.physical_exam_finish_requested.connect(_finish_physical_examination)
active_scene.debrief_return_requested.connect(_return_to_cases)
active_scene.debrief_repeat_requested.connect(_repeat_interview)
selection_view.visible = false
stage_root.visible = true
active_scene.configure_experience_mode(str(attempt.get("experienceMode", "management")))
active_scene.configure_presentation(
attempt.get("graphicalPresentation", {}),
str(attempt.get("simulatedStartUtc", "")),
int(attempt.get("simulatedMinute", 0)),
attempt.get("vitals", {}),
str(attempt.get("medicalHistory", "")))
active_scene.configure_interview(node)
active_scene.set_choices_pending(not pending_action.is_empty())
@@ -202,10 +253,105 @@ func _submit_dialogue_choice(choice_id: String) -> void:
CCSService.send_dialogue_choice(choice_id)
func _handoff_to_classic_player() -> void:
current_view = "handoff"
if main != null:
main.show_screen("ccs_player")
func _begin_physical_examination() -> void:
if not pending_action.is_empty():
return
_show_physical_examination(CCSService.attempt)
func _show_physical_examination(attempt: Dictionary) -> void:
if active_scene == null:
_set_active_scene(DOCTOR_SCENE.instantiate(), "physical_examination")
active_scene.physical_exam_selected.connect(_submit_physical_exam)
active_scene.physical_exam_finish_requested.connect(_finish_physical_examination)
active_scene.debrief_return_requested.connect(_return_to_cases)
active_scene.debrief_repeat_requested.connect(_repeat_interview)
selection_view.visible = false
stage_root.visible = true
active_scene.configure_experience_mode("graphical_interview")
active_scene.configure_presentation(
attempt.get("graphicalPresentation", {}),
str(attempt.get("simulatedStartUtc", "")),
int(attempt.get("simulatedMinute", 0)),
attempt.get("vitals", {}),
str(attempt.get("medicalHistory", "")))
active_scene.show_physical_exam(
attempt.get("availableExams", []), attempt.get("chart", []), pending_exam_code)
current_view = "physical_examination"
func _submit_physical_exam(exam_code: String) -> void:
if not pending_action.is_empty() or exam_code.is_empty():
return
pending_action = "physical_exam"
pending_exam_code = exam_code
active_scene.set_physical_exam_pending(exam_code)
CCSService.send_command("exam", exam_code)
func _finish_physical_examination() -> void:
if not pending_action.is_empty():
return
pending_action = "finishing_exam"
active_scene.set_physical_exam_pending("finishing_case")
CCSService.send_command("exit_case")
func _render_debrief(attempt: Dictionary) -> void:
if active_scene == null:
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office")
active_scene.debrief_return_requested.connect(_return_to_cases)
active_scene.debrief_repeat_requested.connect(_repeat_interview)
selection_view.visible = false
stage_root.visible = true
active_scene.configure_presentation(
attempt.get("graphicalPresentation", {}),
str(attempt.get("simulatedStartUtc", "")),
int(attempt.get("simulatedMinute", 0)),
attempt.get("vitals", {}),
str(attempt.get("medicalHistory", "")))
active_scene.show_debrief(attempt.get("interviewDebrief", {}), attempt.get("score", {}))
current_view = "interview_debrief"
func _return_to_cases() -> void:
CCSService.attempt = {}
pending_action = ""
_render_case_selection()
func _repeat_interview() -> void:
var case_external_id := str(CCSService.attempt.get("caseExternalId", ""))
if case_external_id.is_empty() or not pending_action.is_empty():
return
pending_action = "creating"
CCSService.create_attempt(case_external_id, "timed")
func _is_retired_interview_revision(attempt: Dictionary, _node: Dictionary) -> bool:
if str(attempt.get("caseExternalId", "")) != FOCUSED_INTERVIEW_CASE_ID:
return false
var presentation: Dictionary = attempt.get("graphicalPresentation", {})
return int(presentation.get("interviewRevision", 0)) < FOCUSED_INTERVIEW_REVISION
func _restart_with_current_interview(attempt: Dictionary) -> void:
if pending_action == "refreshing_interview":
return
restart_case_external_id = str(attempt.get("caseExternalId", ""))
pending_action = "refreshing_interview"
_show_selection("Updating this interview to its latest patient history dialogue...")
CCSService.abandon()
func _on_attempt_abandoned() -> void:
if restart_case_external_id.is_empty():
return
var case_external_id := restart_case_external_id
restart_case_external_id = ""
pending_action = "creating"
status_label.text = "Starting the updated interview..."
CCSService.create_attempt(case_external_id, "timed")
func _on_request_failed(message: String) -> void:
@@ -217,8 +363,17 @@ func _on_request_failed(message: String) -> void:
status_label.text = "An active case was found. Loading it now..."
CCSService.load_active()
return
if failed_action == "refreshing_interview":
restart_case_external_id = ""
status_label.text = "The updated interview could not be started. %s" % message
retry_button.visible = true
return
if active_scene != null:
active_scene.set_choices_pending(false)
if current_view == "physical_examination":
pending_exam_code = ""
active_scene.show_physical_exam_error(message)
else:
active_scene.set_choices_pending(false)
else:
status_label.text = message
retry_button.visible = failed_action == "loading"
@@ -243,7 +398,7 @@ func _clear_active_scene() -> void:
active_scene.get_parent().remove_child(active_scene)
active_scene.queue_free()
active_scene = null
if current_view == "doctor_office":
if current_view in ["doctor_office", "physical_examination"]:
current_view = ""
@@ -264,6 +419,6 @@ func _fit_stage() -> void:
var available := size
if available.x <= 0.0 or available.y <= 0.0:
return
var fit_scale := minf(available.x / DESIGN_SIZE.x, available.y / DESIGN_SIZE.y)
stage_root.scale = Vector2.ONE * fit_scale
stage_root.position = (available - DESIGN_SIZE * fit_scale) * 0.5
stage_root.scale = Vector2.ONE
stage_root.position = Vector2.ZERO
stage_root.size = available
+7
View File
@@ -435,6 +435,7 @@ func on_show(payload: Variant = null) -> void:
func on_hide() -> void:
timer.stop()
opening_active = false
_dismiss_dialogs()
@@ -458,6 +459,12 @@ func _ccs_dialogs() -> Array[Window]:
func _render() -> void:
# This screen stays subscribed to the shared CCS attempt while hidden.
# Never allow its Window-based dialogs to appear over CCS Cases 2.
if not visible:
opening_active = false
_dismiss_dialogs()
return
var attempt: Dictionary = CCSService.attempt
if attempt.is_empty():
return
+32 -6
View File
@@ -126,11 +126,11 @@ func _apply_screen_margins(screen_name: String) -> void:
if screens_margin == null or screen_name.is_empty():
return
var is_fullscreen_study := screen_name == "player"
var margin := 0 if screen_name == "login" else (12 if is_fullscreen_study else (20 if compact_shell else 44))
var margin := 0 if screen_name in ["login", "ccs_cases_2"] else (12 if is_fullscreen_study else (20 if compact_shell else 44))
screens_margin.add_theme_constant_override("margin_left", margin)
screens_margin.add_theme_constant_override("margin_right", margin)
screens_margin.add_theme_constant_override("margin_top", 0 if screen_name == "login" else (12 if is_fullscreen_study else (20 if compact_shell else 34)))
screens_margin.add_theme_constant_override("margin_bottom", 0 if screen_name == "login" else (12 if is_fullscreen_study else 28))
screens_margin.add_theme_constant_override("margin_top", 0 if screen_name in ["login", "ccs_cases_2"] else (12 if is_fullscreen_study else (20 if compact_shell else 34)))
screens_margin.add_theme_constant_override("margin_bottom", 0 if screen_name in ["login", "ccs_cases_2"] else (12 if is_fullscreen_study else 28))
func _build_ui() -> void:
@@ -582,7 +582,7 @@ func _on_drawer_backdrop_input(event: InputEvent) -> void:
func _screen_uses_shell(screen_name: String) -> bool:
return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player"]
return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player", "ccs_cases_2"]
func _update_shell_visibility(screen_name: String) -> void:
@@ -699,15 +699,29 @@ func _input(event: InputEvent) -> void:
if not AuthState.is_logged_in or current_screen_name == "login":
return
if event.is_action_pressed("ui_cancel"):
if _close_current_transient_overlay():
get_viewport().set_input_as_handled()
return
if drawer_open:
_close_drawer()
get_viewport().set_input_as_handled()
return
if current_screen_name in ["player", "ccs_player", "flashcards_study"]:
if _screen_uses_pause_menu(current_screen_name):
_toggle_app_menu()
get_viewport().set_input_as_handled()
func _close_current_transient_overlay() -> bool:
var screen: Control = screens.get(current_screen_name)
if screen != null and screen.has_method("close_transient_overlay"):
return bool(screen.close_transient_overlay())
return false
func _screen_uses_pause_menu(screen_name: String) -> bool:
return screen_name in ["player", "ccs_player", "ccs_cases_2", "flashcards_study"]
func _refresh_header_controls() -> void:
if feedback_button != null:
feedback_button.visible = AuthState.is_logged_in and size.x >= 560.0
@@ -875,13 +889,15 @@ func _toggle_app_menu() -> void:
_close_app_menu()
else:
UISoundService.play_sound("open")
_set_current_screen_pause_menu_open(true)
app_menu.visible = true
app_menu.size = app_menu.custom_minimum_size
app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5
menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name in ["ccs_player", "flashcards_study"]
menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name in ["ccs_player", "ccs_cases_2", "flashcards_study"]
if menu_exit_button.visible:
menu_exit_button.text = (
"Abandon CCS Case" if current_screen_name == "ccs_player"
else "Exit CCS Interview" if current_screen_name == "ccs_cases_2"
else "Exit Study Session" if current_screen_name == "flashcards_study"
else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation"
else "Exit Run")
@@ -892,9 +908,16 @@ func _toggle_app_menu() -> void:
func _close_app_menu() -> void:
if app_menu != null:
app_menu.visible = false
_set_current_screen_pause_menu_open(false)
BlockSessionService.resume_current_question_timer()
func _set_current_screen_pause_menu_open(open: bool) -> void:
var screen = screens.get(current_screen_name)
if screen != null and screen.has_method("set_pause_menu_open"):
screen.set_pause_menu_open(open)
func _navigate_from_pause(target_screen: String) -> void:
var return_screen := current_screen_name
show_screen(target_screen, {"return_screen": return_screen})
@@ -911,6 +934,9 @@ func exit_current_run() -> void:
if current_screen_name == "flashcards_study":
show_screen("flashcards_hub")
return
if current_screen_name == "ccs_cases_2":
show_screen("dashboard")
return
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
BlockSessionService.finish_block()
elif BlockSessionService.session_kind == "simulation":
+331 -4
View File
@@ -23,6 +23,16 @@ func _run() -> void:
var nav_button := main_screen.sidebar.find_child("NavCcsCases2", true, false) as Button
_assert(nav_button != null and nav_button.visible, "UAT should expose a CCS Cases 2 navigation item")
_assert(main_screen.screens.has("ccs_cases_2"), "UAT should register the graphical CCS screen")
_assert(not main_screen._screen_uses_shell("ccs_cases_2"), "CCS Cases 2 should hide the application shell")
main_screen._apply_screen_margins("ccs_cases_2")
_assert(main_screen.screens_margin.get_theme_constant("margin_left") == 0, "fullscreen CCS should remove horizontal screen margins")
_assert(main_screen.screens_margin.get_theme_constant("margin_top") == 0, "fullscreen CCS should remove vertical screen margins")
main_screen.current_screen_name = "ccs_cases_2"
_assert(main_screen._screen_uses_pause_menu("ccs_cases_2"), "CCS Cases 2 should route Escape to the pause menu")
main_screen._toggle_app_menu()
_assert(main_screen.app_menu.visible, "the pause menu should open over CCS Cases 2")
_assert(main_screen.menu_exit_button.visible and main_screen.menu_exit_button.text == "Exit CCS Interview", "the CCS pause menu should expose a clear exit action")
main_screen._close_app_menu()
main_screen.queue_free()
await process_frame
ProjectSettings.set_setting("step3/deployment_environment", prior_environment)
@@ -40,7 +50,11 @@ func _run() -> void:
await process_frame
service.cases = [
{"externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2, "realTimeMinutes": 20, "interactiveInterviewAvailable": true},
{
"externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2,
"realTimeMinutes": 20, "interactiveInterviewAvailable": true, "graphicalPresentationAvailable": true,
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"}
},
{"externalId": "legacy", "title": "Legacy case", "interactiveInterviewAvailable": false}
]
service.attempt = {}
@@ -50,22 +64,207 @@ func _run() -> void:
await process_frame
_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")
var retired_attempt := {
"caseExternalId": "ccs-gastro-duodenal-ulcer-office-001",
"graphicalPresentation": {"interviewRevision": 1}
}
_assert(screen._is_retired_interview_revision(retired_attempt, {
"id": "fatigue_course", "patientText": "I feel tired.", "choices": []
}), "an older authored interview revision should trigger a restart")
retired_attempt.graphicalPresentation.interviewRevision = 2
_assert(not screen._is_retired_interview_revision(retired_attempt, {}),
"the current authored interview revision should remain active")
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(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")
_assert(screen.active_scene.computer_launcher.visible, "the doctor office should expose the simulated computer launcher")
_assert(screen.active_scene.computer_launcher.icon.resource_path.ends_with("monitor.svg"), "the computer launcher should use the monitor icon")
screen.active_scene.computer_launcher.pressed.emit()
_assert(screen.active_scene.is_computer_open(), "the computer launcher should open the simulated desktop")
_assert(not screen.active_scene.computer_launcher.visible, "the launcher should hide while the computer is open")
_assert(screen.active_scene.computer_overlay.get_node("Screen").texture.resource_path.ends_with("computer-base.png"), "the overlay should render the supplied computer base")
_assert(screen.active_scene.computer_overlay.get_node("Screen").stretch_mode == TextureRect.STRETCH_SCALE, "the computer should fill the viewport without side bars")
_assert(screen.active_scene.vitals_launcher.visible, "the computer desktop should expose a Vitals app")
_assert(screen.active_scene.medical_history_launcher.visible, "the computer desktop should expose a Medical History app")
_assert(screen.active_scene.vitals_launcher.text.is_empty() and screen.active_scene.medical_history_launcher.text.is_empty(), "desktop shortcuts should place labels below centered icons")
_assert(screen.active_scene.vitals_launcher.get_node("Label").text == "Vitals" and screen.active_scene.medical_history_launcher.get_node("Label").text == "Medical History", "desktop shortcut labels should remain readable")
_assert(screen.active_scene.vitals_launcher.get_theme_stylebox("normal") is StyleBoxEmpty and screen.active_scene.medical_history_launcher.get_theme_stylebox("normal") is StyleBoxEmpty, "desktop shortcuts should not render permanent button tiles")
_assert(screen.active_scene.medical_history_launcher.position.y > screen.active_scene.vitals_launcher.get_rect().end.y, "desktop shortcuts should form a vertical icon column")
_assert(screen.active_scene.vitals_launcher.icon.get_width() >= 58 and screen.active_scene.medical_history_launcher.icon.get_width() >= 58, "desktop shortcuts should use high-resolution vector imports")
screen.active_scene.vitals_launcher.pressed.emit()
_assert(screen.active_scene.is_vitals_open(), "the Vitals app should open a nurse vital-sign window")
_assert(screen.active_scene.vitals_header.get_node("Title").text == "Vitals", "the vital window should use the concise Vitals heading")
_assert(_vital_values(screen.active_scene) == ["36.8 C (98.2 F)", "76/min", "14/min", "122 mm Hg", "76 mm Hg", "99% on room air"], "the Vitals app should render the active attempt data in clinical order")
_assert(_vital_values_fit(screen.active_scene), "vital values should retain a readable column without character-by-character wrapping")
screen.active_scene.medical_history_launcher.pressed.emit()
_assert(screen.active_scene.is_vitals_open() and screen.active_scene.is_medical_history_open(), "both chart windows should be able to remain open")
_assert(screen.active_scene.medical_history_text.text == "Hyperlipidemia", "Medical History should render the case-authored past medical history")
_assert(screen.active_scene.medical_history_window.size.y <= 310.0, "Medical History should size closely to its short content (size %s, minimum %s)" % [screen.active_scene.medical_history_window.size, screen.active_scene.medical_history_window.get_combined_minimum_size()])
_assert(screen.active_scene.vitals_window.size.y <= 410.0, "Vitals should size to its rendered measurement rows")
_assert(screen.active_scene.medical_history_window.z_index > screen.active_scene.vitals_window.z_index, "the most recently opened chart window should come to the front")
var original_history_position: Vector2 = screen.active_scene.medical_history_window.position
screen.active_scene._set_medical_history_window_position(Vector2(90.0, 70.0))
var moved_history_position: Vector2 = screen.active_scene.medical_history_window.position
_assert(not moved_history_position.is_equal_approx(original_history_position) \
and moved_history_position.x >= 0.0 and moved_history_position.y >= 0.0,
"Medical History should use the shared draggable-window behavior")
_assert(screen.close_transient_overlay(), "the first Escape should close the topmost Medical History app")
_assert(not screen.active_scene.is_medical_history_open() and screen.active_scene.is_vitals_open(), "closing Medical History should leave Vitals open")
screen.active_scene._set_vitals_window_position(Vector2(75.0, 55.0))
_assert(screen.active_scene.vitals_window.position.is_equal_approx(Vector2(75.0, 55.0)), "the Vitals window should support repositioning by its header")
screen.active_scene._set_vitals_window_position(Vector2(-500.0, 5000.0))
var max_vitals_position: Vector2 = screen.active_scene.size - screen.active_scene.vitals_window.size
_assert(screen.active_scene.vitals_window.position.is_equal_approx(Vector2(0.0, max_vitals_position.y)), "dragging should keep the Vitals window inside the computer viewport")
_assert(screen.close_transient_overlay(), "the second Escape should close the Vitals app")
_assert(not screen.active_scene.is_vitals_open() and screen.active_scene.is_computer_open(), "closing Vitals should return to the computer desktop")
_assert(screen.close_transient_overlay(), "the third Escape should close the computer")
_assert(not screen.active_scene.is_computer_open(), "closing the transient overlay should restore the doctor office")
_assert(not screen.close_transient_overlay(), "the fourth Escape should be available to open the pause menu")
screen.active_scene.open_computer()
screen.active_scene.open_vitals()
screen.active_scene.open_medical_history()
screen.active_scene.computer_close_button.pressed.emit()
_assert(not screen.active_scene.is_computer_open() and not screen.active_scene.is_vitals_open() \
and not screen.active_scene.is_medical_history_open(),
"the computer close button should close every child chart window")
_assert(is_equal_approx(screen.DESIGN_SIZE.x / screen.DESIGN_SIZE.y, 16.0 / 9.0), "the graphical CCS stage should use a true 16:9 design canvas")
_assert(screen.stage_root.size.is_equal_approx(screen.size), "the graphical stage should fill the available viewport")
_assert(screen.stage_root.position.is_equal_approx(Vector2.ZERO), "the graphical stage should not introduce letterbox margins")
var stale_graphical_attempt := _sample_attempt(false)
stale_graphical_attempt.experienceMode = "graphical_interview"
stale_graphical_attempt.openingCompleted = true
service.attempt = stale_graphical_attempt
screen._render_state()
await process_frame
_assert(screen.current_view == "doctor_office", "legacy completion flags should not block a graphical interview")
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")
_assert(screen.active_scene.dialogue_pages.size() > 1, "long patient responses should split into readable dialogue pages")
_assert(_label_content_fits(screen.active_scene.dialogue_text), "the first patient response page should fit inside its speech bubble")
_assert(is_equal_approx(screen.active_scene.dialogue_text.offset_left, 96.0), "patient dialogue should retain the increased left inset")
_assert(_bubble_matches_content(screen.active_scene), "the speech bubble should size itself to the wrapped response page")
_assert(screen.active_scene.dialogue_continue_hint.visible, "the continuation prompt should remain visible while text is typing")
_assert(screen.active_scene.dialogue_continue_hint.text == "Click to continue", "every dialogue page should use supported continuation wording")
_assert(screen.active_scene.dialogue_text.visible_characters_behavior == TextServer.VC_CHARS_AFTER_SHAPING, "typewriter text should wrap before glyphs are revealed")
var wrapped_line_count: int = screen.active_scene.dialogue_text.get_line_count()
screen.active_scene.open_computer()
var computer_blocked_page: int = screen.active_scene.dialogue_page_index
var computer_click := _left_click(screen.active_scene.get_global_rect().get_center())
screen.active_scene._input(computer_click)
_assert(screen.active_scene.dialogue_page_index == computer_blocked_page and not screen.active_scene.dialogue_reveal_complete, "the open computer should block scene-wide dialogue clicks")
screen.active_scene.computer_close_button.pressed.emit()
_assert(not screen.active_scene.is_computer_open(), "the computer close button should return to the interview")
var outside_click := _left_click(screen.active_scene.get_global_rect().end + Vector2(50.0, 50.0))
screen.active_scene._input(outside_click)
_assert(not screen.active_scene.dialogue_reveal_complete, "clicks outside the doctor scene should not affect dialogue")
var scene_click := _left_click(screen.active_scene.get_global_rect().get_center())
screen.set_pause_menu_open(true)
screen.active_scene._input(scene_click)
_assert(not screen.active_scene.dialogue_reveal_complete, "dialogue should ignore scene clicks while the Escape menu is open")
screen.set_pause_menu_open(false)
screen.active_scene._input(scene_click)
_assert(screen.active_scene.dialogue_page_index == 0, "clicking during typing should complete rather than skip the current page")
_assert(screen.active_scene.dialogue_text.get_line_count() == wrapped_line_count, "revealing typewriter text should not shift words between lines")
_assert(screen.active_scene.dialogue_continue_hint.visible, "completed dialogue pages should invite the learner to continue")
await create_timer(1.35).timeout
_assert(screen.active_scene.is_patient_dialogue_visible(), "patient dialogue should remain until the learner advances it")
_assert(not screen.active_scene.choice_container.visible, "questions must not appear on a timer")
while screen.active_scene.dialogue_page_index < screen.active_scene.dialogue_pages.size() - 1:
screen.active_scene._input(scene_click)
await process_frame
_assert(_label_content_fits(screen.active_scene.dialogue_text), "every patient response page should fit inside its speech bubble")
screen.active_scene.complete_dialogue_reveal()
screen.active_scene._input(scene_click)
await process_frame
_assert(not screen.active_scene.is_patient_dialogue_visible(), "questions should replace patient dialogue after the final learner click")
_assert(screen.active_scene.choice_container.get_child_count() == 4, "the first question page should contain four choices")
screen.active_scene._input(scene_click)
_assert(screen.active_scene.choice_container.get_child_count() == 4, "scene clicks should not affect explicit question controls")
_assert(screen.active_scene.choice_pager.visible, "eight choices should display paging controls")
_assert(screen.active_scene.choice_page == 0, "new question sets should begin on the first page")
_assert(_visible_choice_ids(screen.active_scene) == ["topic_0", "topic_1", "topic_2", "topic_3"], "the first page should preserve choice order and IDs")
_assert(_choice_labels_fit(screen.active_scene), "first-page question text should fit within every button")
screen.active_scene._change_choice_page(1)
await process_frame
_assert(screen.active_scene.choice_page == 1, "Next should advance to the second question page")
_assert(screen.active_scene.choice_container.get_child_count() == 4, "the second question page should contain the remaining choices")
_assert(_visible_choice_ids(screen.active_scene) == ["topic_4", "topic_5", "topic_6", "topic_7"], "the second page should preserve choice order and IDs")
_assert(_choice_labels_fit(screen.active_scene), "second-page question text should fit within every button")
screen.active_scene._change_choice_page(-1)
_assert(screen.active_scene.choice_page == 0, "Previous should return to the first question page")
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(not screen.active_scene.choice_container.visible, "the terminal action should wait for the closing response")
screen.active_scene.complete_dialogue_reveal()
screen.active_scene.advance_dialogue()
_assert(screen.active_scene.choice_container.get_child_count() == 1, "terminal dialogue should expose the management handoff")
_assert(not screen.active_scene.choice_pager.visible, "a single terminal action should not show paging controls")
var terminal_choice := screen.active_scene.choice_container.get_child(0) as Control
_assert(terminal_choice.size.x <= 360.0, "a single terminal choice should remain a compact centered card")
_assert((terminal_choice.get_child(0) as Label).text == "Begin Physical Examination",
"terminal dialogue should lead into the graphical physical examination")
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")
await process_frame
_assert(screen.current_view == "physical_examination", "the examination should remain inside CCS Cases 2")
_assert(screen.active_scene.is_physical_exam_visible(), "the physical examination panel should be visible")
_assert(screen.active_scene.physical_exam_grid.columns == 2, "exam components should use two responsive columns")
_assert(screen.active_scene.physical_exam_grid.get_child_count() == 12, "all twelve examination components should be available")
_assert(not screen.active_scene.physical_exam_finish_button.disabled, "Finish Case should remain available before every exam is complete")
screen.active_scene.set_physical_exam_pending("abdomen")
_assert(screen.active_scene.physical_exam_finish_button.disabled, "case completion should pause while an exam command is pending")
_assert(screen.active_scene.physical_exam_buttons.values().all(func(button): return button.disabled),
"exam controls should disable while one component is submitted")
var examined_attempt := _sample_attempt(true)
examined_attempt.chart = [{"code": "abdomen", "title": "Abdomen", "text": "Mild epigastric tenderness."}]
service.attempt = examined_attempt
screen._on_attempt_changed()
await process_frame
_assert(screen.current_view == "physical_examination", "attempt refreshes should preserve the physical examination phase")
_assert(screen.active_scene.physical_exam_buttons.abdomen.text.begins_with("[Done]"), "completed exams should be marked")
_assert(screen.active_scene.physical_exam_buttons.abdomen.disabled, "completed exams should not be repeated")
_assert(screen.active_scene.physical_exam_finding_text.text == "Mild epigastric tenderness.", "the returned chart finding should be displayed")
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
_assert(screen.current_view == "interview_debrief", "a completed graphical interview should remain in CCS Cases 2")
_assert(screen.active_scene.find_child("InterviewDebrief", true, false) != null, "the graphical case should render its debrief")
_assert(not screen.active_scene.computer_launcher.visible, "the computer launcher should stay hidden during the interview debrief")
var sizing_scene = load("res://scenes/ccs_cases_2/doctor_office.tscn").instantiate()
host.add_child(sizing_scene)
await process_frame
sizing_scene.show_patient_dialogue("Short response.")
await process_frame
var short_height: float = sizing_scene.dialogue_bubble.size.y
_assert(is_equal_approx(short_height, sizing_scene.DIALOGUE_BUBBLE_MIN_HEIGHT), "short dialogue should use the minimum bubble height")
_assert(sizing_scene.dialogue_bubble.get_global_rect().encloses(sizing_scene.dialogue_continue_hint.get_global_rect()), "the continuation hint should fit inside a minimum-height bubble")
_assert(sizing_scene.dialogue_continue_hint.position.y - sizing_scene.dialogue_text.get_rect().end.y >= 8.0, "the continuation hint should have visible padding above it")
sizing_scene.show_patient_dialogue("Line one explains the symptom.\nLine two adds timing.\nLine three adds context.\nLine four concludes.")
await process_frame
_assert(sizing_scene.dialogue_bubble.size.y > short_height, "multi-line dialogue should increase the bubble height")
_assert(sizing_scene.dialogue_bubble.size.y <= sizing_scene.DIALOGUE_BUBBLE_MAX_HEIGHT, "dialogue bubble growth should remain bounded")
_assert(_bubble_matches_content(sizing_scene), "the expanded bubble should contain all wrapped text")
sizing_scene.complete_dialogue_reveal()
_assert(sizing_scene.dialogue_bubble.get_global_rect().encloses(sizing_scene.dialogue_continue_hint.get_global_rect()), "the continuation hint should remain inside the resized bubble")
sizing_scene.queue_free()
screen.on_hide()
await process_frame
@@ -83,15 +282,143 @@ 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 {
"id": "attempt-1", "state": "active", "revision": 1,
"simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male", "interviewRevision": 2},
"medicalHistory": "Hyperlipidemia",
"vitals": {
"temperature": "36.8 C (98.2 F)", "bloodPressure": "122/76 mm Hg",
"pulse": "76/min", "respirations": "14/min", "oxygenSaturation": "99% on room air"
},
"openingCompleted": completed,
"experienceMode": "graphical_interview",
"availableExams": _sample_exams(), "chart": [],
"interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed}
}
func _sample_exams() -> Array:
var definitions := [
["physical_appearance", "Physical Appearance"], ["skin", "Skin"], ["breasts", "Breasts"],
["lymph_nodes", "Lymph Nodes"], ["heent_neck", "HEENT / Neck"], ["chest_lungs", "Chest / Lungs"],
["heart_cardiovascular", "Heart / Cardiovascular"], ["abdomen", "Abdomen"], ["genitalia", "Genitalia"],
["rectal", "Rectal"], ["extremities_spine", "Extremities / Spine"], ["neuro_psych", "Neuro / Psych"]
]
var exams: Array = []
for definition in definitions:
exams.append({"code": definition[0], "name": definition[1], "durationMinutes": 1})
return exams
func _sample_graphical_debrief() -> Dictionary:
return {
"id": "attempt-2", "caseExternalId": "interactive", "state": "completed", "revision": 7,
"experienceMode": "graphical_interview", "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"},
"interviewDebrief": {
"likelyDiagnosis": "Probable duodenal ulcer", "summary": "A focused history supports the assessment.",
"teachingPoints": ["Characterize dizziness.", "Screen for bleeding."]
},
"score": {"percent": 85.0}
}
func _sample_branching_attempt() -> Dictionary:
var option_texts := [
"Tell me more about the dizziness. Does the room spin, and have you fainted?",
"How has the fatigue affected you, and how have you been sleeping and eating?",
"You mentioned your stomach. Please describe the discomfort and when it occurs.",
"Have you had black stools, blood in vomit or stool, trouble swallowing, persistent vomiting, weight loss, or severe sudden pain?",
"What medicines do you take, including aspirin, ibuprofen, naproxen, or other over-the-counter products?",
"Do you have prior stomach problems, other medical conditions, surgeries, or medication allergies?",
"Do you smoke, drink alcohol, or use recreational drugs?",
"Any fever, shortness of breath, headaches, bowel changes, or urinary symptoms?"
]
var choices: Array = []
for index in 8:
choices.append({"id": "topic_%d" % index, "text": option_texts[index], "nextNodeId": "topic_%d" % index})
return {
"id": "attempt-1", "state": "active", "revision": 2,
"simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"},
"openingCompleted": false,
"interview": {
"currentNodeId": "chief_concern",
"currentNode": {
"id": "chief_concern",
"patientText": "For the past few weeks, I have felt worn out, and every so often I get lightheaded—especially when I stand up quickly.",
"isTerminal": false,
"choices": choices
},
"visitedChoiceIds": ["chief_concern"], "completed": false
}
}
func _visible_choice_ids(scene: Control) -> Array:
var ids: Array = []
for child in scene.choice_container.get_children():
ids.append(str(child.get_meta("choice_id", "")))
return ids
func _vital_values(scene: Control) -> Array:
var values: Array = []
for index in range(1, scene.vitals_rows.get_child_count(), 2):
values.append(scene.vitals_rows.get_child(index).text)
return values
func _vital_values_fit(scene: Control) -> bool:
for index in range(1, scene.vitals_rows.get_child_count(), 2):
var label := scene.vitals_rows.get_child(index) as Label
if label == null or label.size.x < 220.0 or label.get_line_count() != 1:
return false
return true
func _choice_labels_fit(scene: Control) -> bool:
for button in scene.choice_container.get_children():
var label := button.get_child(0) as Label
if label == null or not _label_content_fits(label):
return false
if not scene.choice_area.get_global_rect().encloses(button.get_global_rect()):
return false
return true
func _label_content_fits(label: Control) -> bool:
if label is RichTextLabel:
return label.get_content_height() <= label.size.y + 0.5
var plain_label := label as Label
if plain_label == null:
return false
var font := plain_label.get_theme_font("font")
var font_size := plain_label.get_theme_font_size("font_size")
var content_height := float(plain_label.get_line_count()) * font.get_height(font_size)
return content_height <= plain_label.size.y + 0.5
func _bubble_matches_content(scene: Control) -> bool:
var required_height: float = scene.dialogue_text.get_content_height() + scene.DIALOGUE_BUBBLE_VERTICAL_CHROME
var expected_height := clampf(
required_height, scene.DIALOGUE_BUBBLE_MIN_HEIGHT, scene.DIALOGUE_BUBBLE_MAX_HEIGHT)
return is_equal_approx(scene.dialogue_bubble.size.y, expected_height) \
and _label_content_fits(scene.dialogue_text)
func _left_click(position: Vector2) -> InputEventMouseButton:
var event := InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_LEFT
event.pressed = true
event.position = position
return event
func _assert(condition: bool, message: String) -> void:
if condition:
return
+8
View File
@@ -66,7 +66,15 @@ func _run() -> void:
_assert(player.time_dialog.visible, "CCS dialog lifecycle test should begin with an open popup")
player.on_hide()
_assert(not player.time_dialog.visible, "hiding the CCS screen should dismiss popup windows")
player.visible = false
ccs_service.attempt.openingCompleted = false
player._render()
await process_frame
_assert(not player.intro_dialog.visible, "a hidden classic player must not open dialogs over CCS Cases 2")
_assert(not player.opening_active, "a hidden classic player must not begin its opening sequence")
player.visible = true
ccs_service.attempt.openingCompleted = true
ccs_service.attempt.state = "final_orders"
player._render()
_assert(player.final_orders_notice.visible, "final-orders mode should show instructions")
+5 -1
View File
@@ -183,8 +183,10 @@ test("public API and disposable learner journey", async () => {
const { data: cases } = await call("/api/ccs/cases", { token });
expect(Array.isArray(cases)).toBeTruthy();
expect(cases.length).toBeGreaterThan(0);
const managementCase = cases.find((item) => item.experienceMode === "management");
expect(managementCase, "UAT requires a published management CCS case for the smoke journey").toBeTruthy();
const { data: attempt } = await call("/api/ccs/attempts", {
method: "POST", token, body: { caseExternalId: "", mode: "timed" }
method: "POST", token, body: { caseExternalId: managementCase.externalId, mode: "timed" }
});
activeCcsAttempt = attempt.id;
expect(activeCcsAttempt).toBeTruthy();
@@ -204,6 +206,8 @@ test("public API and disposable learner journey", async () => {
}
});
expect(opened.openingCompleted).toBe(true);
expect(Array.isArray(opened.availableExams)).toBeTruthy();
expect(opened.availableExams.length, "management CCS smoke case must expose at least one exam").toBeGreaterThan(0);
const exam = opened.availableExams[0];
const { data: examined } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands/batch`, {
method: "POST", token, body: {