From eafa9927c71ce2f64b6f1e5875b3e2eddb76431c Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 14 Jun 2026 18:50:58 +0100 Subject: [PATCH] Phase 12A: Story State Engine and Scene Inspector Integration for the new Continuity Explorer feature. --- PlotLine/Program.cs | 1 + PlotLine/Services/CoreServices.cs | 210 +++++++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 25 +++ PlotLine/Views/Scenes/_SceneInspector.cshtml | 78 +++++++ 4 files changed, 314 insertions(+) diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index bf2db97..3fc82e4 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -117,6 +117,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 23634cf..68ceacb 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -111,6 +111,11 @@ public interface ISceneService Task DeleteDependencyAsync(int sceneDependencyId); } +public interface IStoryStateService +{ + Task GetStoryStateAsync(int sceneId); +} + public interface IPlotService { Task GetPlotLinesAsync(int projectId); @@ -983,6 +988,7 @@ public sealed class SceneService( ISubscriptionService subscriptions, IProjectActivityService activity, IStorageService storage, + IStoryStateService storyState, ICurrentUserService currentUser) : ISceneService { public async Task GetCreateAsync(int chapterId) @@ -1195,6 +1201,7 @@ public sealed class SceneService( model.ChecklistItems = model.SceneID == 0 ? [] : await writerWorkspace.ListChecklistItemsAsync(model.SceneID); model.Attachments = model.SceneID == 0 ? [] : await writerWorkspace.ListAttachmentsAsync(model.SceneID); model.StorageUsage = model.SceneID == 0 ? null : await storage.GetSceneStorageUsageAsync(model.SceneID); + model.StoryState = model.SceneID == 0 ? new StoryStateViewModel() : await storyState.GetStoryStateAsync(model.SceneID); model.SceneNoteTypeOptions = ChapterService.ToSelectList(noteTypes, x => x.SceneNoteTypeID, x => x.TypeName); model.SceneAttachmentTypeOptions = ChapterService.ToSelectList(attachmentTypes, x => x.SceneAttachmentTypeID, x => x.TypeName); model.PlotLineOptions = ToOptionalSelectList(plotLines, x => x.PlotLineID, x => x.PlotLineName); @@ -2196,6 +2203,209 @@ public sealed class TimelineService( } } +public sealed class StoryStateService( + IBookRepository books, + IChapterRepository chapters, + ISceneRepository scenes, + ITimelineRepository timelineRepository, + IAssetRepository assets, + ICharacterRepository characters, + ILocationRepository locations) : IStoryStateService +{ + public async Task GetStoryStateAsync(int sceneId) + { + var currentScene = await scenes.GetAsync(sceneId); + if (currentScene is null) + { + return new StoryStateViewModel(); + } + + var chapter = await chapters.GetAsync(currentScene.ChapterID); + var book = chapter is null ? null : await books.GetAsync(chapter.BookID); + if (book is null) + { + return new StoryStateViewModel(); + } + + var timeline = await timelineRepository.GetByProjectAsync(book.ProjectID, book.BookID, false, [], false); + var orderedScenes = OrderScenes(timeline).ToList(); + var currentSceneIndex = orderedScenes.FindIndex(x => x.SceneID == sceneId); + if (currentSceneIndex < 0) + { + return new StoryStateViewModel(); + } + + var sceneIndexes = orderedScenes + .Select((scene, index) => new { scene.SceneID, Index = index }) + .ToDictionary(x => x.SceneID, x => x.Index); + var scenesThroughCurrent = orderedScenes.Take(currentSceneIndex + 1).ToList(); + var allowedSceneIds = scenesThroughCurrent.Select(x => x.SceneID).ToHashSet(); + + var characterTimeline = await characters.GetTimelineAsync(book.ProjectID, book.BookID); + var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, book.BookID); + var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughCurrent); + var assetCustody = await GetAssetCustodyThroughCurrentAsync(assetTimeline.Assets); + + return new StoryStateViewModel + { + Characters = BuildCharacterState(characterTimeline.Characters, characterTimeline.Appearances, sceneId, allowedSceneIds, sceneIndexes), + Assets = BuildAssetState(assetTimeline.Assets, assetTimeline.Events, assetLocations, assetCustody, sceneId, allowedSceneIds, sceneIndexes, orderedScenes.ToDictionary(x => x.SceneID)) + }; + } + + private static IEnumerable OrderScenes(TimelineData timeline) + { + var chaptersById = timeline.Chapters.ToDictionary(x => x.ChapterID); + var bookSortOrders = timeline.Books.ToDictionary(x => x.BookID, x => x.SortOrder); + var chapterSortOrders = timeline.Chapters.ToDictionary(x => x.ChapterID, x => x.SortOrder); + + return timeline.Scenes + .OrderBy(scene => bookSortOrders.GetValueOrDefault(chaptersById.GetValueOrDefault(scene.ChapterID)?.BookID ?? 0)) + .ThenBy(scene => chapterSortOrders.GetValueOrDefault(scene.ChapterID)) + .ThenBy(scene => scene.SortOrder) + .ThenBy(scene => scene.SceneNumber) + .ThenBy(scene => scene.SceneID); + } + + private async Task> GetAssetLocationsThroughCurrentAsync(IReadOnlyList scenesThroughCurrent) + { + var rows = new List(); + foreach (var scene in scenesThroughCurrent) + { + rows.AddRange(await locations.ListSceneAssetLocationsAsync(scene.SceneID)); + } + + return rows; + } + + private async Task> GetAssetCustodyThroughCurrentAsync(IReadOnlyList projectAssets) + { + var rows = new List(); + foreach (var asset in projectAssets) + { + rows.AddRange(await assets.ListCustodyByAssetAsync(asset.StoryAssetID)); + } + + return rows; + } + + private static List BuildCharacterState( + IReadOnlyList projectCharacters, + IReadOnlyList appearances, + int currentSceneId, + IReadOnlySet allowedSceneIds, + IReadOnlyDictionary sceneIndexes) + { + var appearancesWithLocations = appearances + .Where(x => allowedSceneIds.Contains(x.SceneID) && x.LocationID.HasValue) + .ToList(); + + return projectCharacters + .OrderByDescending(x => x.CharacterImportance ?? 0) + .ThenBy(x => x.CharacterName) + .Select(character => + { + var latest = appearancesWithLocations + .Where(x => x.CharacterID == character.CharacterID) + .OrderByDescending(x => sceneIndexes.GetValueOrDefault(x.SceneID, -1)) + .ThenByDescending(x => x.UpdatedDate) + .FirstOrDefault(); + + if (latest is null) + { + return new CharacterStoryStateViewModel + { + CharacterID = character.CharacterID, + CharacterName = character.CharacterName + }; + } + + return new CharacterStoryStateViewModel + { + CharacterID = character.CharacterID, + CharacterName = character.CharacterName, + CurrentLocation = latest.LocationPath ?? latest.LocationName ?? "Unknown", + State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred", + LastConfirmed = SceneLabel(latest.SceneNumber, latest.SceneTitle) + }; + }) + .ToList(); + } + + private static List BuildAssetState( + IReadOnlyList projectAssets, + IReadOnlyList assetEvents, + IReadOnlyList assetLocations, + IReadOnlyList assetCustody, + int currentSceneId, + IReadOnlySet allowedSceneIds, + IReadOnlyDictionary sceneIndexes, + IReadOnlyDictionary scenesById) + { + var stateAssignments = assetEvents + .Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.ToStateName)) + .Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.ToStateName!, 1, x.UpdatedDate)); + var locationAssignments = assetLocations + .Where(x => allowedSceneIds.Contains(x.SceneID)) + .Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.LocationPath, 2, x.UpdatedDate)); + var custodyAssignments = assetCustody + .Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.CustodianSummary)) + .Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.CustodianSummary!, 3, x.UpdatedDate)); + var assignments = stateAssignments + .Concat(locationAssignments) + .Concat(custodyAssignments) + .ToList(); + + return projectAssets + .OrderByDescending(x => x.Importance) + .ThenBy(x => x.AssetName) + .Select(asset => + { + var latest = assignments + .Where(x => x.StoryAssetID == asset.StoryAssetID) + .OrderByDescending(x => sceneIndexes.GetValueOrDefault(x.SceneID, -1)) + .ThenByDescending(x => x.Priority) + .ThenByDescending(x => x.UpdatedDate) + .FirstOrDefault(); + + if (latest is null) + { + return new AssetStoryStateViewModel + { + StoryAssetID = asset.StoryAssetID, + AssetName = asset.AssetName + }; + } + + return new AssetStoryStateViewModel + { + StoryAssetID = asset.StoryAssetID, + AssetName = asset.AssetName, + CurrentHolderOrLocation = latest.Label, + State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred", + LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene) + ? SceneLabel(scene.SceneNumber, scene.SceneTitle) + : "Unknown" + }; + }) + .ToList(); + } + + private static string SceneLabel(decimal sceneNumber, string sceneTitle) + { + return string.IsNullOrWhiteSpace(sceneTitle) + ? $"Scene {sceneNumber:g}" + : $"Scene {sceneNumber:g}: {sceneTitle}"; + } + + private sealed record AssetStateAssignment( + int StoryAssetID, + int SceneID, + string Label, + int Priority, + DateTime UpdatedDate); +} + public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots, IProjectActivityService activity) : IPlotService { public async Task GetPlotLinesAsync(int projectId) diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 53a5e14..c119690 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -221,6 +221,7 @@ public sealed class SceneEditViewModel public IReadOnlyList SceneNoteTypeOptions { get; set; } = []; public IReadOnlyList SceneAttachmentTypeOptions { get; set; } = []; public StorageUsage? StorageUsage { get; set; } + public StoryStateViewModel StoryState { get; set; } = new(); public int? ReturnProjectID { get; set; } public int? ReturnBookID { get; set; } @@ -236,6 +237,30 @@ public sealed class SceneEditViewModel public IReadOnlyList ScenePurposeTypes { get; set; } = []; } +public sealed class StoryStateViewModel +{ + public List Characters { get; set; } = []; + public List Assets { get; set; } = []; +} + +public sealed class CharacterStoryStateViewModel +{ + public int CharacterID { get; set; } + public string CharacterName { get; set; } = string.Empty; + public string CurrentLocation { get; set; } = "Unknown"; + public string State { get; set; } = "Unknown"; + public string LastConfirmed { get; set; } = "Never"; +} + +public sealed class AssetStoryStateViewModel +{ + public int StoryAssetID { get; set; } + public string AssetName { get; set; } = string.Empty; + public string CurrentHolderOrLocation { get; set; } = "Unknown"; + public string State { get; set; } = "Unknown"; + public string LastConfirmed { get; set; } = "Never"; +} + public sealed class SceneWorkflowEditViewModel { public int SceneID { get; set; } diff --git a/PlotLine/Views/Scenes/_SceneInspector.cshtml b/PlotLine/Views/Scenes/_SceneInspector.cshtml index 8351f73..c289d41 100644 --- a/PlotLine/Views/Scenes/_SceneInspector.cshtml +++ b/PlotLine/Views/Scenes/_SceneInspector.cshtml @@ -20,6 +20,15 @@ var exactDateModeId = Model.TimeModes.FirstOrDefault(x => string.Equals(x.Text, "Exact Date", StringComparison.OrdinalIgnoreCase))?.Value ?? string.Empty; } +@functions { + private static string StoryStateBadgeClass(string state) => state switch + { + "Explicit" => "bg-success", + "Inferred" => "bg-secondary", + _ => "bg-warning text-dark" + }; +} +
@@ -77,6 +86,7 @@ Assets Locations Custody + Story State Writer Checklist @@ -1203,6 +1213,74 @@
+
$"{x.CharacterName} {x.CurrentLocation} {x.State} {x.LastConfirmed}"))} {string.Join(' ', Model.StoryState.Assets.Select(x => $"{x.AssetName} {x.CurrentHolderOrLocation} {x.State} {x.LastConfirmed}"))}")"> +

Story State

+ +

Characters

+ @if (!Model.StoryState.Characters.Any()) + { +

No project characters found.

+ } + else + { +
+ + + + + + + + + + + @foreach (var characterState in Model.StoryState.Characters) + { + + + + + + + } + +
CharacterCurrent LocationStateLast Confirmed
@characterState.CharacterName@characterState.CurrentLocation@characterState.State@characterState.LastConfirmed
+
+ } + +

Assets

+ @if (!Model.StoryState.Assets.Any()) + { +

No project assets found.

+ } + else + { +
+ + + + + + + + + + + @foreach (var assetState in Model.StoryState.Assets) + { + + + + + + + } + +
AssetCurrent Holder / LocationStateLast Confirmed
@assetState.AssetName@assetState.CurrentHolderOrLocation@assetState.State@assetState.LastConfirmed
+
+ } +
+