134 lines
9.4 KiB
C#
134 lines
9.4 KiB
C#
using PlotLine.Data;
|
|
using PlotLine.Models;
|
|
using PlotLine.ViewModels;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public sealed class StoryPointInputs
|
|
{
|
|
public TimelineData Timeline { get; init; } = new();
|
|
public IReadOnlyList<Character> Characters { get; init; } = [];
|
|
public IReadOnlyList<SceneCharacter> Appearances { get; init; } = [];
|
|
public IReadOnlyList<StoryAsset> Assets { get; init; } = [];
|
|
public IReadOnlyList<AssetEvent> AssetEvents { get; init; } = [];
|
|
public IReadOnlyList<LocationItem> Locations { get; init; } = [];
|
|
public RelationshipMapData Relationships { get; init; } = new();
|
|
public StoryPointHistory History { get; init; } = new();
|
|
public IReadOnlyList<ContinuityWarningAcknowledgement> Acknowledgements { get; init; } = [];
|
|
}
|
|
|
|
public interface IStoryPointContextService
|
|
{
|
|
Task<StoryPointViewModel?> 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<StoryPointViewModel?> 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<StoryPointThread> BuildThreads(StoryPointHistory history, HashSet<int> allowed,
|
|
IReadOnlyDictionary<int, int> indexes, IReadOnlyDictionary<int, StoryPointSource> sources)
|
|
{
|
|
var events = history.ThreadEvents.Where(x => allowed.Contains(x.SceneID)).ToLookup(x => x.PlotThreadID);
|
|
var result = new List<StoryPointThread>();
|
|
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();
|
|
}
|
|
}
|