From 7c7815642a6d5aa65bca6b26af44a470227aec59 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 14 Jun 2026 20:04:48 +0100 Subject: [PATCH] Phase 12E: Continuity Warnings Engine (Informational). --- PlotLine/Services/CoreServices.cs | 272 ++++++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 14 + .../Views/ContinuityExplorer/Index.cshtml | 57 ++++ 3 files changed, 343 insertions(+) diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index cc2a1b5..8e7b74c 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -118,6 +118,7 @@ public interface IStoryStateService Task> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter); Task> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter); Task> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter); + Task> GetContinuityWarningsAsync(int projectId, ContinuityFilter filter); } public interface IPlotService @@ -2336,6 +2337,10 @@ public sealed class StoryStateService( .ThenBy(x => x.LocationName) .ToList(); } + else if (string.Equals(filter.DisplayMode, "ContinuityWarnings", StringComparison.OrdinalIgnoreCase)) + { + model.ContinuityWarnings = await GetContinuityWarningsAsync(filter.ProjectID, filter); + } return model; } @@ -2519,6 +2524,42 @@ public sealed class StoryStateService( return result; } + public async Task> GetContinuityWarningsAsync(int projectId, ContinuityFilter filter) + { + var context = await GetRangeContextAsync(projectId, filter); + var characterTimeline = await characters.GetTimelineAsync(projectId, null); + var assetTimeline = await assets.GetTimelineAsync(projectId, null); + var allAssetLocations = await GetAssetLocationsThroughCurrentAsync(context.OrderedScenes); + var custodyCharacters = await assets.ListCustodyCharactersByProjectAsync(projectId); + var enabledCharacterIds = filter.CharacterIDs.ToHashSet(); + var enabledAssetIds = filter.AssetIDs.ToHashSet(); + var warnings = new List(); + + if (filter.IncludeCharacters) + { + warnings.AddRange(BuildMultipleCharacterLocationWarnings(characterTimeline.Appearances, enabledCharacterIds, context)); + warnings.AddRange(BuildImpossibleMovementWarnings(characterTimeline.Appearances, enabledCharacterIds, context)); + warnings.AddRange(BuildUnknownCharacterLocationWarnings(characterTimeline.Characters, characterTimeline.Appearances, enabledCharacterIds, context)); + } + + if (filter.IncludeAssets) + { + var scopedAssetLocations = allAssetLocations.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).ToList(); + var scopedCustody = custodyCharacters.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).ToList(); + warnings.AddRange(BuildAssetLocationConflictWarnings(assetTimeline.Assets, scopedAssetLocations, scopedCustody, enabledAssetIds, context)); + warnings.AddRange(BuildUnknownAssetLocationWarnings(assetTimeline.Assets, assetTimeline.Events, allAssetLocations, custodyCharacters, enabledAssetIds, context)); + } + + return warnings + .OrderBy(x => WarningSeveritySort(x.Severity)) + .ThenBy(x => x.BookDisplayName) + .ThenBy(x => x.ChapterDisplayName) + .ThenBy(x => x.SceneID ?? int.MaxValue) + .ThenBy(x => x.WarningType) + .ThenBy(x => x.Message) + .ToList(); + } + private async Task GetRangeContextAsync(int projectId, ContinuityFilter filter) { NormaliseFilter(filter); @@ -2737,6 +2778,203 @@ public sealed class StoryStateService( .ToList(); } + private static List BuildMultipleCharacterLocationWarnings( + IReadOnlyList appearances, + IReadOnlySet enabledCharacterIds, + RangeContext context) + { + return appearances + .Where(x => context.AllowedSceneIds.Contains(x.SceneID) + && x.LocationID.HasValue + && (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(x.CharacterID))) + .GroupBy(x => new { x.SceneID, x.CharacterID, x.CharacterName }) + .Where(group => group.Select(x => x.LocationID).Distinct().Count() > 1) + .Select(group => BuildWarning( + "Multiple Character Locations", + "Critical", + $"{group.Key.CharacterName} appears in multiple locations during {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.", + group.Key.SceneID, + context, + characterId: group.Key.CharacterID)) + .ToList(); + } + + private static List BuildImpossibleMovementWarnings( + IReadOnlyList appearances, + IReadOnlySet enabledCharacterIds, + RangeContext context) + { + var warnings = new List(); + var rowsByCharacter = appearances + .Where(x => x.LocationID.HasValue && (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(x.CharacterID))) + .GroupBy(x => x.CharacterID); + + foreach (var group in rowsByCharacter) + { + var explicitLocations = group + .GroupBy(x => x.SceneID) + .Select(sceneGroup => sceneGroup.OrderByDescending(x => x.UpdatedDate).First()) + .OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1)) + .ToList(); + + for (var index = 1; index < explicitLocations.Count; index++) + { + var previous = explicitLocations[index - 1]; + var current = explicitLocations[index]; + if (previous.LocationID == current.LocationID || !context.AllowedSceneIds.Contains(current.SceneID)) + { + continue; + } + + var previousIndex = context.SceneIndexes.GetValueOrDefault(previous.SceneID, -1); + var currentIndex = context.SceneIndexes.GetValueOrDefault(current.SceneID, -1); + if (currentIndex - previousIndex != 1) + { + continue; + } + + warnings.Add(BuildWarning( + "Impossible Character Movement", + "Warning", + $"{current.CharacterName} moved from {previous.LocationPath ?? previous.LocationName ?? "an unknown location"} to {current.LocationPath ?? current.LocationName ?? "an unknown location"} between consecutive scenes.", + current.SceneID, + context, + characterId: current.CharacterID)); + } + } + + return warnings; + } + + private static List BuildUnknownCharacterLocationWarnings( + IReadOnlyList projectCharacters, + IReadOnlyList appearances, + IReadOnlySet enabledCharacterIds, + RangeContext context) + { + var appearancesByCharacter = appearances + .Where(x => context.AllowedSceneIds.Contains(x.SceneID)) + .GroupBy(x => x.CharacterID) + .ToDictionary(x => x.Key, x => x.ToList()); + var locationCharacterIds = appearances + .Where(x => x.LocationID.HasValue) + .Select(x => x.CharacterID) + .ToHashSet(); + + return projectCharacters + .Where(character => (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(character.CharacterID)) + && appearancesByCharacter.ContainsKey(character.CharacterID) + && !locationCharacterIds.Contains(character.CharacterID)) + .Select(character => + { + var firstSceneId = appearancesByCharacter[character.CharacterID] + .OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1)) + .First() + .SceneID; + return BuildWarning( + "Characters With Unknown Locations", + "Information", + $"{character.CharacterName} has no known location.", + firstSceneId, + context, + characterId: character.CharacterID); + }) + .ToList(); + } + + private static List BuildAssetLocationConflictWarnings( + IReadOnlyList projectAssets, + IReadOnlyList sceneAssetLocations, + IReadOnlyList custodyCharacters, + IReadOnlySet enabledAssetIds, + RangeContext context) + { + var assetNames = projectAssets.ToDictionary(x => x.StoryAssetID, x => x.AssetName); + var directLocationGroups = sceneAssetLocations + .Where(x => !enabledAssetIds.Any() || enabledAssetIds.Contains(x.StoryAssetID)) + .GroupBy(x => new { x.SceneID, x.StoryAssetID }); + var custodyGroups = custodyCharacters + .Where(x => !enabledAssetIds.Any() || enabledAssetIds.Contains(x.StoryAssetID)) + .GroupBy(x => new { x.SceneID, x.StoryAssetID }) + .ToDictionary(x => x.Key, x => x.ToList()); + var warnings = new List(); + + foreach (var group in directLocationGroups) + { + var directLocationCount = group.Select(x => x.LocationID).Distinct().Count(); + custodyGroups.TryGetValue(group.Key, out var custodyRows); + var hasCustody = custodyRows?.Any() == true; + if (directLocationCount <= 1 && !hasCustody) + { + continue; + } + + warnings.Add(BuildWarning( + "Asset Location Conflict", + "Critical", + $"{assetNames.GetValueOrDefault(group.Key.StoryAssetID, "An asset")} has multiple explicit locations in {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.", + group.Key.SceneID, + context, + assetId: group.Key.StoryAssetID)); + } + + foreach (var group in custodyGroups.Where(x => !directLocationGroups.Any(locationGroup => locationGroup.Key.SceneID == x.Key.SceneID && locationGroup.Key.StoryAssetID == x.Key.StoryAssetID))) + { + var holderCount = group.Value.Select(x => x.CharacterID?.ToString() ?? x.CharacterNameText ?? string.Empty).Where(x => x.Length > 0).Distinct().Count(); + if (holderCount <= 1) + { + continue; + } + + warnings.Add(BuildWarning( + "Asset Location Conflict", + "Critical", + $"{assetNames.GetValueOrDefault(group.Key.StoryAssetID, "An asset")} has multiple explicit holders in {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.", + group.Key.SceneID, + context, + assetId: group.Key.StoryAssetID)); + } + + return warnings; + } + + private static List BuildUnknownAssetLocationWarnings( + IReadOnlyList projectAssets, + IReadOnlyList assetEvents, + IReadOnlyList allAssetLocations, + IReadOnlyList custodyCharacters, + IReadOnlySet enabledAssetIds, + RangeContext context) + { + var appearedAssetSceneIds = assetEvents + .Where(x => context.AllowedSceneIds.Contains(x.SceneID)) + .Select(x => new { x.StoryAssetID, x.SceneID }) + .Concat(allAssetLocations.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).Select(x => new { x.StoryAssetID, x.SceneID })) + .Concat(custodyCharacters.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).Select(x => new { x.StoryAssetID, x.SceneID })) + .GroupBy(x => x.StoryAssetID) + .ToDictionary(x => x.Key, x => x.Min(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, int.MaxValue))); + var locatedAssetIds = allAssetLocations.Select(x => x.StoryAssetID) + .Concat(custodyCharacters.Select(x => x.StoryAssetID)) + .ToHashSet(); + + return projectAssets + .Where(asset => (!enabledAssetIds.Any() || enabledAssetIds.Contains(asset.StoryAssetID)) + && appearedAssetSceneIds.ContainsKey(asset.StoryAssetID) + && !locatedAssetIds.Contains(asset.StoryAssetID)) + .Select(asset => + { + var firstSceneId = context.OrderedScenes.FirstOrDefault(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, int.MaxValue) == appearedAssetSceneIds[asset.StoryAssetID])?.SceneID; + return BuildWarning( + "Assets With Unknown Locations", + "Information", + $"{asset.AssetName} has no known location.", + firstSceneId, + context, + assetId: asset.StoryAssetID); + }) + .ToList(); + } + private static LocationActivityEntityViewModel? BuildCurrentCharacterPresence( Character character, IReadOnlyList assignments, @@ -2890,6 +3128,40 @@ public sealed class StoryStateService( : []; } + private static ContinuityWarningViewModel BuildWarning( + string warningType, + string severity, + string message, + int? sceneId, + RangeContext context, + int? characterId = null, + int? assetId = null) + { + var sceneContext = sceneId.HasValue ? context.SceneContexts.GetValueOrDefault(sceneId.Value) : null; + return new ContinuityWarningViewModel + { + WarningType = warningType, + Severity = severity, + Message = message, + CharacterID = characterId, + AssetID = assetId, + SceneID = sceneId, + SceneDisplayName = sceneContext?.SceneDisplayName ?? (sceneId.HasValue ? context.SceneLabels.GetValueOrDefault(sceneId.Value, string.Empty) : string.Empty), + BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty, + ChapterDisplayName = sceneContext?.ChapterDisplayName ?? string.Empty + }; + } + + private static int WarningSeveritySort(string severity) + { + return severity switch + { + "Critical" => 0, + "Warning" => 1, + _ => 2 + }; + } + private static Dictionary BuildSceneIndexes(IReadOnlyList orderedScenes) { return orderedScenes diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 7e7d3c1..c7a75d1 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -303,6 +303,7 @@ public sealed class ContinuityExplorerViewModel public IReadOnlyList CharacterJourney { get; set; } = []; public IReadOnlyList AssetJourney { get; set; } = []; public IReadOnlyList LocationActivity { get; set; } = []; + public IReadOnlyList ContinuityWarnings { get; set; } = []; public IReadOnlyList BookOptions { get; set; } = []; public IReadOnlyList ChapterOptions { get; set; } = []; public IReadOnlyList SceneOptions { get; set; } = []; @@ -313,6 +314,19 @@ public sealed class ContinuityExplorerViewModel public bool ShowChapterContext { get; set; } } +public sealed class ContinuityWarningViewModel +{ + public string WarningType { get; set; } = string.Empty; + public string Severity { get; set; } = "Information"; + public string Message { get; set; } = string.Empty; + public int? CharacterID { get; set; } + public int? AssetID { get; set; } + public int? SceneID { get; set; } + public string SceneDisplayName { get; set; } = string.Empty; + public string BookDisplayName { get; set; } = string.Empty; + public string ChapterDisplayName { get; set; } = string.Empty; +} + public sealed class CharacterJourneyViewModel { public int CharacterID { get; set; } diff --git a/PlotLine/Views/ContinuityExplorer/Index.cshtml b/PlotLine/Views/ContinuityExplorer/Index.cshtml index 59d82b7..17bcdb8 100644 --- a/PlotLine/Views/ContinuityExplorer/Index.cshtml +++ b/PlotLine/Views/ContinuityExplorer/Index.cshtml @@ -12,6 +12,13 @@ _ => "bg-warning text-dark" }; + private static string WarningBadgeClass(string severity) => severity switch + { + "Critical" => "bg-danger", + "Warning" => "bg-warning text-dark", + _ => "bg-info" + }; + private bool IsEntitySelected(string entityType) => Model.Filter.EntityTypes.Contains(entityType, StringComparer.OrdinalIgnoreCase); } @@ -144,6 +151,10 @@ Location Activity + @@ -350,6 +361,52 @@ else if (Model.Filter.DisplayMode == "Journey") } } +else if (Model.Filter.DisplayMode == "ContinuityWarnings") +{ +
+

Continuity Warnings

+ @if (!Model.ContinuityWarnings.Any()) + { +

No continuity concerns detected.

+

Continuity warnings highlight potential issues but cannot replace author judgement.

+ } + else + { +
+ + + + + + + + + + + @foreach (var warning in Model.ContinuityWarnings) + { + + + + + + + } + +
SeverityWarning TypeMessageScene
@warning.Severity@warning.WarningType@warning.Message + @if (warning.SceneID.HasValue) + { + @warning.SceneDisplayName + } + else + { + Project-wide + } +
+
+ } +
+} else {