using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public sealed class StoryPointInputs { public TimelineData Timeline { get; init; } = new(); public IReadOnlyList Characters { get; init; } = []; public IReadOnlyList Appearances { get; init; } = []; public IReadOnlyList Assets { get; init; } = []; public IReadOnlyList AssetEvents { get; init; } = []; public IReadOnlyList Locations { get; init; } = []; public RelationshipMapData Relationships { get; init; } = new(); public StoryPointHistory History { get; init; } = new(); public IReadOnlyList Acknowledgements { get; init; } = []; } public interface IStoryPointContextService { Task GetAsync(int projectId, int? sceneId); } public sealed class StoryPointContextService(IProjectAccessService access, ITimelineRepository timelineRepository, ICharacterRepository characters, IAssetRepository assets, ILocationRepository locations, IStoryPointRepository history, IContinuityWarningAcknowledgementRepository acknowledgements) : IStoryPointContextService { public async Task GetAsync(int projectId, int? sceneId) { if (!await access.CanAccessProjectAsync(projectId)) return null; var timeline = await timelineRepository.GetByProjectAsync(projectId, null, false, [], false); if (timeline.Project is null) return null; var ordered = StoryStateService.OrderScenes(timeline).ToList(); if (sceneId.HasValue && ordered.All(x => x.SceneID != sceneId)) return null; sceneId ??= ordered.FirstOrDefault()?.SceneID; StoryPointSnapshot? snapshot = null; if (sceneId.HasValue) { // Each repository uses its own connection; batch count is independent of scene/entity count. var peopleTask = characters.GetTimelineAsync(projectId, null); var assetsTask = assets.GetTimelineAsync(projectId, null); var locationsTask = locations.ListByProjectAsync(projectId); var relationshipsTask = characters.GetRelationshipMapAsync(projectId); var historyTask = history.GetHistoryAsync(projectId, sceneId.Value); var acknowledgementsTask = acknowledgements.ListByProjectAsync(projectId); await Task.WhenAll(peopleTask, assetsTask, locationsTask, relationshipsTask, historyTask, acknowledgementsTask); snapshot = Compose(new StoryPointInputs { Timeline = timeline, Characters = peopleTask.Result.Characters, Appearances = peopleTask.Result.Appearances, Assets = assetsTask.Result.Assets, AssetEvents = assetsTask.Result.Events, Locations = locationsTask.Result, Relationships = relationshipsTask.Result, History = historyTask.Result, Acknowledgements = acknowledgementsTask.Result }, sceneId.Value); } return new StoryPointViewModel { Project = timeline.Project, Books = timeline.Books.OrderBy(x => x.SortOrder).ThenBy(x => x.BookNumber).ToList(), Chapters = timeline.Chapters.OrderBy(x => x.SortOrder).ThenBy(x => x.ChapterNumber).ToList(), Scenes = ordered, Snapshot = snapshot }; } public static StoryPointSnapshot? Compose(StoryPointInputs input, int sceneId) { var ordered = StoryStateService.OrderScenes(input.Timeline).ToList(); var maximum = ordered.FindIndex(x => x.SceneID == sceneId); if (maximum < 0 || input.Timeline.Project is null) return null; var scene = ordered[maximum]; var chapter = input.Timeline.Chapters.Single(x => x.ChapterID == scene.ChapterID); var book = input.Timeline.Books.Single(x => x.BookID == chapter.BookID); var indexes = ordered.Select((x, i) => (x.SceneID, i)).ToDictionary(x => x.SceneID, x => x.i); var allowed = ordered.Take(maximum + 1).Select(x => x.SceneID).ToHashSet(); var chaptersById = input.Timeline.Chapters.ToDictionary(x => x.ChapterID); var booksById = input.Timeline.Books.ToDictionary(x => x.BookID); var sources = ordered.Take(maximum + 1).ToDictionary(x => x.SceneID, x => { var ch = chaptersById[x.ChapterID]; return new StoryPointSource(x.SceneID, $"{booksById[ch.BookID].BookDisplayTitle} · Ch {ch.ChapterNumber:0.##} · Scene {x.SceneNumber:0.##}"); }); var present = input.Appearances.Where(x => x.SceneID == sceneId).GroupBy(x => x.CharacterID) .Select(x => x.OrderByDescending(y => y.UpdatedDate).First()).OrderBy(x => x.CharacterName).ToList(); var presentIds = present.Select(x => x.CharacterID).ToHashSet(); var state = StoryStateService.BuildAtScene(input, sceneId); var knowledge = StoryStateService.KnowledgeAtScene(input.History.Knowledge, indexes, sceneId).ToLookup(x => x.CharacterID); var previous = input.Appearances.Where(x => allowed.Contains(x.SceneID) && x.SceneID != sceneId) .GroupBy(x => x.CharacterID).ToDictionary(x => x.Key, x => x.OrderBy(y => indexes[y.SceneID]).Last().SceneID); var characterModels = input.Characters.ToDictionary(x => x.CharacterID); var people = present.Select(x => new StoryPointCharacter(x, characterModels.TryGetValue(x.CharacterID, out var character) ? CharacterAgeDisplayService.GetAgeForScene(character, scene.TimeModeName, scene.StartDateTime) : CharacterAgeDisplayService.GetAgeForScene(x, scene.TimeModeName, scene.StartDateTime), state.Characters.FirstOrDefault(y => y.CharacterID == x.CharacterID), previous.TryGetValue(x.CharacterID, out var previousId) ? sources[previousId] : null, knowledge[x.CharacterID].ToList())).ToList(); var custodyIds = input.History.Custodians.Where(x => x.CharacterID.HasValue && presentIds.Contains(x.CharacterID.Value)) .Select(x => x.AssetCustodyEventID).ToHashSet(); var pointAssets = state.Assets.Where(x => x.LastConfirmedSceneID == sceneId || (scene.PrimaryLocationID.HasValue && x.LocationID == scene.PrimaryLocationID) || (x.CustodyEventID.HasValue && custodyIds.Contains(x.CustodyEventID.Value))).ToList(); var relevantCharacters = presentIds.Concat(state.Characters.Where(x => scene.PrimaryLocationID.HasValue && x.LocationID == scene.PrimaryLocationID).Select(x => x.CharacterID)).ToHashSet(); var location = scene.PrimaryLocationID.HasValue ? state.Locations.FirstOrDefault(x => x.LocationID == scene.PrimaryLocationID) ?? input.Locations.Where(x => x.LocationID == scene.PrimaryLocationID) .Select(x => new LocationStoryStateViewModel { LocationID = x.LocationID, LocationName = x.LocationName, LocationPath = x.LocationPath }).FirstOrDefault() : null; var lastLocationScene = ordered.Take(maximum).LastOrDefault(x => scene.PrimaryLocationID.HasValue && x.PrimaryLocationID == scene.PrimaryLocationID); return new StoryPointSnapshot { ProjectID = input.Timeline.Project.ProjectID, Book = book, Chapter = chapter, Scene = scene, Characters = people, PovName = scene.POVCharacterID.HasValue ? characterModels.GetValueOrDefault(scene.POVCharacterID.Value)?.CharacterName ?? scene.POVCharacterName : scene.POVCharacterName, Relationships = RelationshipMapService.BuildAtScene(input.Relationships, indexes, sceneId) .Where(x => presentIds.Contains(x.CharacterAID) || presentIds.Contains(x.CharacterBID)).ToList(), Assets = pointAssets, Location = location, PreviousLocationUse = lastLocationScene is null ? null : sources[lastLocationScene.SceneID], Threads = BuildThreads(input.History, allowed, indexes, sources), Warnings = StoryStateService.WarningsAtScene(input, sceneId, relevantCharacters, pointAssets.Select(x => x.StoryAssetID).ToHashSet()), Sources = sources }; } private static IReadOnlyList BuildThreads(StoryPointHistory history, HashSet allowed, IReadOnlyDictionary indexes, IReadOnlyDictionary sources) { var events = history.ThreadEvents.Where(x => allowed.Contains(x.SceneID)).ToLookup(x => x.PlotThreadID); var result = new List(); foreach (var thread in history.Threads.Where(x => !x.IsArchived)) { var threadEvents = events[thread.PlotThreadID].OrderBy(x => indexes[x.SceneID]).ThenBy(x => x.ThreadEventID).ToList(); if (thread.IntroducedSceneID.HasValue ? !allowed.Contains(thread.IntroducedSceneID.Value) : threadEvents.Count == 0) continue; if (thread.ActualResolutionSceneID.HasValue && allowed.Contains(thread.ActualResolutionSceneID.Value)) continue; var latestLifecycle = threadEvents.LastOrDefault(x => x.EventTypeName is "Resolved" or "Payoff" or "Introduced"); if (latestLifecycle?.EventTypeName is "Resolved" or "Payoff") continue; var last = threadEvents.LastOrDefault(); var sourceId = last?.SceneID ?? thread.IntroducedSceneID; result.Add(new StoryPointThread(thread.PlotThreadID, thread.ThreadTitle, thread.ThreadTypeName, thread.PlotLineName, sourceId.HasValue ? sources.GetValueOrDefault(sourceId.Value) : null, last?.EventTypeName ?? "Introduced")); } return result.OrderBy(x => x.Title).ToList(); } }