diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 1829068..ac89d50 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -2546,6 +2546,7 @@ public sealed class StoryStateService( warnings.AddRange(BuildMultipleCharacterLocationWarnings(characterTimeline.Appearances, enabledCharacterIds, context)); warnings.AddRange(BuildImpossibleMovementWarnings(characterTimeline.Appearances, enabledCharacterIds, context)); warnings.AddRange(BuildUnknownCharacterLocationWarnings(characterTimeline.Characters, characterTimeline.Appearances, enabledCharacterIds, context)); + warnings.AddRange(await BuildKnowledgeChronologyWarningsAsync(characterTimeline.Characters, enabledCharacterIds, context)); } if (filter.IncludeAssets) @@ -2565,7 +2566,7 @@ public sealed class StoryStateService( .ThenBy(x => x.BookDisplayName) .ThenBy(x => x.ChapterDisplayName) .ThenBy(x => x.SceneID ?? int.MaxValue) - .ThenBy(x => x.WarningType) + .ThenBy(x => x.WarningTypeDisplayName) .ThenBy(x => x.Message) .ToList(); } @@ -2976,6 +2977,79 @@ public sealed class StoryStateService( .ToList(); } + private async Task> BuildKnowledgeChronologyWarningsAsync( + IReadOnlyList projectCharacters, + IReadOnlySet enabledCharacterIds, + RangeContext context) + { + var warnings = new List(); + var charactersToCheck = projectCharacters + .Where(character => !enabledCharacterIds.Any() || enabledCharacterIds.Contains(character.CharacterID)) + .ToList(); + + foreach (var character in charactersToCheck) + { + var knowledgeEvents = (await characters.ListKnowledgeByCharacterAsync(character.CharacterID)) + .Where(x => context.SceneIndexes.ContainsKey(x.SceneID)) + .OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, int.MaxValue)) + .ThenBy(x => x.CreatedDate) + .ThenBy(x => x.CharacterKnowledgeID) + .ToList(); + + foreach (var group in knowledgeEvents.GroupBy(KnowledgeIdentity)) + { + var established = group + .Where(x => string.Equals(x.KnowledgeStateName, "Knows", StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, int.MaxValue)) + .ThenBy(x => x.CreatedDate) + .ThenBy(x => x.CharacterKnowledgeID) + .FirstOrDefault(); + + if (established is null) + { + continue; + } + + var establishedSceneIndex = context.SceneIndexes.GetValueOrDefault(established.SceneID, int.MaxValue); + var earlierReferences = group + .Where(x => x.CharacterKnowledgeID != established.CharacterKnowledgeID + && context.AllowedSceneIds.Contains(x.SceneID) + && context.SceneIndexes.GetValueOrDefault(x.SceneID, int.MaxValue) < establishedSceneIndex) + .GroupBy(x => x.SceneID) + .Select(x => x.OrderBy(row => row.CreatedDate).ThenBy(row => row.CharacterKnowledgeID).First()) + .ToList(); + + foreach (var reference in earlierReferences) + { + var warning = BuildWarning( + $"Knowledge Chronology Violation|K:{established.CharacterKnowledgeID}", + "Warning", + $"{character.CharacterName} appears to know \"{KnowledgeTitle(established)}\" before this knowledge is established.", + reference.SceneID, + context, + characterId: character.CharacterID); + + warning.WarningTypeDisplayName = "Knowledge Chronology Violation"; + warning.CharacterName = character.CharacterName; + warning.KnowledgeId = established.CharacterKnowledgeID; + warning.KnowledgeTitle = KnowledgeTitle(established); + warning.EstablishedSceneId = established.SceneID; + warning.EstablishedSceneDisplayName = context.SceneContexts.GetValueOrDefault(established.SceneID)?.SceneDisplayName + ?? context.SceneLabels.GetValueOrDefault(established.SceneID, SceneLabel(established.SceneNumber, established.SceneTitle)); + warning.ReferencedSceneId = reference.SceneID; + warning.ReferencedSceneDisplayName = context.SceneContexts.GetValueOrDefault(reference.SceneID)?.SceneDisplayName + ?? context.SceneLabels.GetValueOrDefault(reference.SceneID, SceneLabel(reference.SceneNumber, reference.SceneTitle)); + warnings.Add(warning); + } + } + } + + return warnings + .GroupBy(x => new { x.CharacterID, x.KnowledgeId, x.ReferencedSceneId, x.EstablishedSceneId }) + .Select(x => x.First()) + .ToList(); + } + private static LocationActivityEntityViewModel? BuildCurrentCharacterPresence( Character character, IReadOnlyList assignments, @@ -3143,6 +3217,7 @@ public sealed class StoryStateService( { WarningKey = BuildWarningKey(warningType, sceneId, characterId, assetId), WarningType = warningType, + WarningTypeDisplayName = warningType, Severity = severity, Message = message, CharacterID = characterId, @@ -3154,6 +3229,36 @@ public sealed class StoryStateService( }; } + private static string KnowledgeIdentity(CharacterKnowledgeItem item) + { + if (item.StoryAssetID.HasValue) + { + return $"Asset:{item.StoryAssetID.Value}"; + } + + if (item.PlotThreadID.HasValue) + { + return $"Thread:{item.PlotThreadID.Value}"; + } + + return $"Description:{(item.Description ?? string.Empty).Trim().ToUpperInvariant()}"; + } + + private static string KnowledgeTitle(CharacterKnowledgeItem item) + { + if (!string.IsNullOrWhiteSpace(item.AssetName)) + { + return item.AssetName; + } + + if (!string.IsNullOrWhiteSpace(item.ThreadTitle)) + { + return item.ThreadTitle; + } + + return string.IsNullOrWhiteSpace(item.Description) ? "Knowledge" : item.Description.Trim(); + } + private static void ApplyAcknowledgements( IReadOnlyList warnings, IReadOnlyList acknowledgements) diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index e73341e..1921d2c 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -319,9 +319,11 @@ public sealed class ContinuityWarningViewModel { public string WarningKey { get; set; } = string.Empty; public string WarningType { get; set; } = string.Empty; + public string WarningTypeDisplayName { get; set; } = string.Empty; public string Severity { get; set; } = "Information"; public string Message { get; set; } = string.Empty; public int? CharacterID { get; set; } + public string CharacterName { get; set; } = string.Empty; public int? AssetID { get; set; } public int? SceneID { get; set; } public string SceneDisplayName { get; set; } = string.Empty; @@ -329,6 +331,12 @@ public sealed class ContinuityWarningViewModel public string ChapterDisplayName { get; set; } = string.Empty; public bool IsAcknowledged { get; set; } public string? AcknowledgementNotes { get; set; } + public int? KnowledgeId { get; set; } + public string KnowledgeTitle { get; set; } = string.Empty; + public int? EstablishedSceneId { get; set; } + public string EstablishedSceneDisplayName { get; set; } = string.Empty; + public int? ReferencedSceneId { get; set; } + public string ReferencedSceneDisplayName { get; set; } = string.Empty; } public sealed class CharacterJourneyViewModel diff --git a/PlotLine/Views/ContinuityExplorer/Index.cshtml b/PlotLine/Views/ContinuityExplorer/Index.cshtml index 54b40b1..364d7b3 100644 --- a/PlotLine/Views/ContinuityExplorer/Index.cshtml +++ b/PlotLine/Views/ContinuityExplorer/Index.cshtml @@ -382,7 +382,11 @@ else if (Model.Filter.DisplayMode == "ContinuityWarnings") Severity Status - Warning Type + Type + Character + Knowledge + Established + Earlier Reference Message Scene Actions @@ -403,7 +407,47 @@ else if (Model.Filter.DisplayMode == "ContinuityWarnings") Active } - @warning.WarningType + @warning.WarningTypeDisplayName + + @if (!string.IsNullOrWhiteSpace(warning.CharacterName)) + { + @warning.CharacterName + } + else + { + - + } + + + @if (!string.IsNullOrWhiteSpace(warning.KnowledgeTitle)) + { + @warning.KnowledgeTitle + } + else + { + - + } + + + @if (warning.EstablishedSceneId.HasValue) + { + @warning.EstablishedSceneDisplayName + } + else + { + - + } + + + @if (warning.ReferencedSceneId.HasValue) + { + @warning.ReferencedSceneDisplayName + } + else + { + - + } +
@warning.Message
@if (warning.IsAcknowledged && !string.IsNullOrWhiteSpace(warning.AcknowledgementNotes))