From 5f6ea929388ccf8f2e2f343b9a712c981b04bac9 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 14 Jun 2026 18:30:50 +0100 Subject: [PATCH] Phase 11B PlotDirector Character Age and Story Date Management feature. --- .../Services/CharacterAgeContinuityService.cs | 158 ++++++++++++++++++ .../Services/CharacterAgeDisplayService.cs | 13 +- PlotLine/Services/CoreServices.cs | 55 ++++++ PlotLine/ViewModels/CoreViewModels.cs | 1 + PlotLine/Views/Scenes/_SceneInspector.cshtml | 17 +- 5 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 PlotLine/Services/CharacterAgeContinuityService.cs diff --git a/PlotLine/Services/CharacterAgeContinuityService.cs b/PlotLine/Services/CharacterAgeContinuityService.cs new file mode 100644 index 0000000..76690ec --- /dev/null +++ b/PlotLine/Services/CharacterAgeContinuityService.cs @@ -0,0 +1,158 @@ +using PlotLine.Models; + +namespace PlotLine.Services; + +public sealed class CharacterAgeContinuityScene +{ + public int SceneID { get; init; } + public int ProjectID { get; init; } + public int? BookID { get; init; } + public int? ChapterID { get; init; } + public decimal? ChapterNumber { get; init; } + public string? ChapterTitle { get; init; } + public decimal? SceneNumber { get; init; } + public string SceneTitle { get; init; } = string.Empty; + public int ChapterSortOrder { get; init; } + public int SceneSortOrder { get; init; } + public string TimeModeName { get; init; } = string.Empty; + public DateTime? StartDateTime { get; init; } +} + +public static class CharacterAgeContinuityService +{ + public static IReadOnlyList GetWarningsForScene( + CharacterAgeContinuityScene currentScene, + IReadOnlyList scenes, + IReadOnlyList appearances, + IReadOnlyList currentSceneCharacters) + { + var warnings = new List(); + warnings.AddRange(GetApproximateAgeMismatchWarnings(currentScene, currentSceneCharacters)); + warnings.AddRange(GetBirthdayTransitionWarnings(currentScene, scenes, appearances)); + return warnings; + } + + public static IReadOnlyList GetApproximateAgeMismatchWarnings( + CharacterAgeContinuityScene scene, + IReadOnlyList sceneCharacters) + { + var sceneDate = CharacterAgeDisplayService.ResolveEffectiveSceneDate(scene.TimeModeName, scene.StartDateTime); + if (!sceneDate.HasValue) + { + return []; + } + + return sceneCharacters + .Where(character => character.BirthDate.HasValue && character.AgeAtSeriesStart.HasValue) + .Select(character => new + { + Character = character, + ExactAge = CharacterAgeDisplayService.CalculateExactAge(character.BirthDate!.Value.Date, sceneDate.Value.Date) + }) + .Where(item => Math.Abs(item.Character.AgeAtSeriesStart!.Value - item.ExactAge.Years) > 1) + .Select(item => CreateWarning( + scene, + item.Character.CharacterID, + "Warning", + "Character Age", + "Character profile age differs significantly from calculated age.", + $"Profile states approximately {item.Character.AgeAtSeriesStart} years old. Calculated age in this scene is {CharacterAgeDisplayService.FormatExactAge(item.Character.BirthDate!.Value.Date, sceneDate.Value.Date)}.")) + .ToList(); + } + + public static IReadOnlyList GetBirthdayTransitionWarnings( + CharacterAgeContinuityScene currentScene, + IReadOnlyList scenes, + IReadOnlyList appearances) + { + var orderedScenes = scenes + .Select(scene => new + { + Scene = scene, + EffectiveDate = CharacterAgeDisplayService.ResolveEffectiveSceneDate(scene.TimeModeName, scene.StartDateTime) + }) + .Where(scene => scene.EffectiveDate.HasValue) + .OrderBy(scene => scene.EffectiveDate!.Value) + .ThenBy(scene => scene.Scene.ChapterSortOrder) + .ThenBy(scene => scene.Scene.SceneSortOrder) + .ThenBy(scene => scene.Scene.SceneID) + .ToList(); + + var currentIndex = orderedScenes.FindIndex(scene => scene.Scene.SceneID == currentScene.SceneID); + if (currentIndex < 0 || currentIndex == orderedScenes.Count - 1) + { + return []; + } + + var current = orderedScenes[currentIndex]; + var next = orderedScenes[currentIndex + 1]; + if (next.EffectiveDate!.Value <= current.EffectiveDate!.Value) + { + return []; + } + + var nextSceneCharacterIds = appearances + .Where(appearance => appearance.SceneID == next.Scene.SceneID) + .Select(appearance => appearance.CharacterID) + .ToHashSet(); + + return appearances + .Where(appearance => appearance.SceneID == current.Scene.SceneID + && appearance.BirthDate.HasValue + && nextSceneCharacterIds.Contains(appearance.CharacterID)) + .GroupBy(appearance => appearance.CharacterID) + .Select(group => group.First()) + .Select(appearance => new + { + Appearance = appearance, + Birthday = GetNextBirthdayAfter(appearance.BirthDate!.Value.Date, current.EffectiveDate!.Value.Date) + }) + .Where(item => item.Birthday <= next.EffectiveDate!.Value.Date) + .Select(item => + { + var age = CharacterAgeDisplayService.CalculateExactAge(item.Appearance.BirthDate!.Value.Date, item.Birthday).Years; + return CreateWarning( + current.Scene, + item.Appearance.CharacterID, + "Info", + "Character Age", + $"{item.Appearance.CharacterName} turns {age} between this scene and the next.", + $"Birthday falls on {item.Birthday:dd MMM yyyy}. Next scene: {next.Scene.SceneTitle}."); + }) + .ToList(); + } + + private static DateTime GetNextBirthdayAfter(DateTime birthDate, DateTime sceneDate) + { + var birthday = new DateTime(sceneDate.Year, birthDate.Month, birthDate.Day); + return birthday > sceneDate ? birthday : birthday.AddYears(1); + } + + private static ContinuityWarning CreateWarning( + CharacterAgeContinuityScene scene, + int? characterId, + string severityName, + string warningTypeName, + string message, + string details) => + new() + { + ContinuityWarningID = 0, + ProjectID = scene.ProjectID, + BookID = scene.BookID, + ChapterID = scene.ChapterID, + ChapterNumber = scene.ChapterNumber, + ChapterTitle = scene.ChapterTitle, + SceneID = scene.SceneID, + SceneNumber = scene.SceneNumber, + SceneTitle = scene.SceneTitle, + EntityType = "Character", + EntityID = characterId, + WarningTypeName = warningTypeName, + SeverityName = severityName, + Message = message, + Details = details, + CreatedDate = DateTime.UtcNow, + LastDetectedDate = DateTime.UtcNow + }; +} diff --git a/PlotLine/Services/CharacterAgeDisplayService.cs b/PlotLine/Services/CharacterAgeDisplayService.cs index 34b5de6..c001e7d 100644 --- a/PlotLine/Services/CharacterAgeDisplayService.cs +++ b/PlotLine/Services/CharacterAgeDisplayService.cs @@ -76,6 +76,15 @@ public static class CharacterAgeDisplayService } public static string FormatExactAge(DateTime birthDate, DateTime sceneDate) + { + var (years, months) = CalculateExactAge(birthDate, sceneDate); + + return months == 0 + ? $"{years} year{Pluralise(years)}" + : $"{years} year{Pluralise(years)}, {months} month{Pluralise(months)}"; + } + + public static (int Years, int Months) CalculateExactAge(DateTime birthDate, DateTime sceneDate) { var years = sceneDate.Year - birthDate.Year; if (sceneDate < birthDate.AddYears(years)) @@ -100,9 +109,7 @@ public static class CharacterAgeDisplayService months = 0; } - return months == 0 - ? $"{years} year{Pluralise(years)}" - : $"{years} year{Pluralise(years)}, {months} month{Pluralise(months)}"; + return (years, months); } private static string Pluralise(int value) => value == 1 ? string.Empty : "s"; diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 43a0d0d..23634cf 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -1182,6 +1182,7 @@ public sealed class SceneService( model.SceneAssetLocations = model.SceneID == 0 ? [] : await locations.ListSceneAssetLocationsAsync(model.SceneID); model.SceneCharacters = model.SceneID == 0 ? [] : await characters.ListSceneCharactersAsync(model.SceneID); model.Warnings = model.SceneID == 0 ? [] : await warnings.ListBySceneAsync(model.SceneID); + model.AgeContinuityWarnings = await BuildAgeContinuityWarningsAsync(model); var sceneDependencyRows = model.SceneID == 0 ? [] : await sceneDependencies.ListBySceneAsync(model.SceneID); model.DependenciesThisSceneNeeds = sceneDependencyRows.Where(x => x.TargetSceneID == model.SceneID).ToList(); model.DependenciesNeedingThisScene = sceneDependencyRows.Where(x => x.SourceSceneID == model.SceneID).ToList(); @@ -1341,6 +1342,60 @@ public sealed class SceneService( return model; } + private async Task> BuildAgeContinuityWarningsAsync(SceneEditViewModel model) + { + if (model.SceneID == 0 || model.Project is null || model.Book is null) + { + return []; + } + + var chapterRows = await chapters.ListByBookAsync(model.Book.BookID); + var scenesInBook = new List(); + foreach (var chapter in chapterRows) + { + var chapterScenes = await scenes.ListByChapterAsync(chapter.ChapterID); + scenesInBook.AddRange(chapterScenes.Select(scene => new CharacterAgeContinuityScene + { + SceneID = scene.SceneID, + ProjectID = model.Project.ProjectID, + BookID = model.Book.BookID, + ChapterID = chapter.ChapterID, + ChapterNumber = chapter.ChapterNumber, + ChapterTitle = chapter.ChapterTitle, + SceneNumber = scene.SceneNumber, + SceneTitle = scene.SceneTitle, + ChapterSortOrder = chapter.SortOrder, + SceneSortOrder = scene.SortOrder, + TimeModeName = scene.TimeModeName, + StartDateTime = scene.StartDateTime + })); + } + + var currentScene = scenesInBook.FirstOrDefault(scene => scene.SceneID == model.SceneID) + ?? new CharacterAgeContinuityScene + { + SceneID = model.SceneID, + ProjectID = model.Project.ProjectID, + BookID = model.Book.BookID, + ChapterID = model.ChapterID, + ChapterNumber = model.Chapter?.ChapterNumber, + ChapterTitle = model.Chapter?.ChapterTitle, + SceneNumber = model.SceneNumber, + SceneTitle = model.SceneTitle, + ChapterSortOrder = model.Chapter?.SortOrder ?? 0, + SceneSortOrder = 0, + TimeModeName = model.TimeModeName, + StartDateTime = model.StartDateTime + }; + + var characterTimeline = await characters.GetTimelineAsync(model.Project.ProjectID, model.Book.BookID); + return CharacterAgeContinuityService.GetWarningsForScene( + currentScene, + scenesInBook, + characterTimeline.Appearances, + model.SceneCharacters); + } + private static IReadOnlyList ToOptionalSelectList(IEnumerable rows, Func value, Func text) => rows.Select(x => new SelectListItem(text(x), value(x).ToString())).ToList(); diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index b5a316f..53a5e14 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -206,6 +206,7 @@ public sealed class SceneEditViewModel public IReadOnlyList AssetDependencyMessages { get; set; } = []; public IReadOnlyList LocationConsistencyMessages { get; set; } = []; public IReadOnlyList Warnings { get; set; } = []; + public IReadOnlyList AgeContinuityWarnings { get; set; } = []; public IReadOnlyList DependenciesThisSceneNeeds { get; set; } = []; public IReadOnlyList DependenciesNeedingThisScene { get; set; } = []; public SceneDependencyCreateViewModel NewDependency { get; set; } = new(); diff --git a/PlotLine/Views/Scenes/_SceneInspector.cshtml b/PlotLine/Views/Scenes/_SceneInspector.cshtml index d921f93..8351f73 100644 --- a/PlotLine/Views/Scenes/_SceneInspector.cshtml +++ b/PlotLine/Views/Scenes/_SceneInspector.cshtml @@ -15,6 +15,7 @@ .Select(x => x.PurposeName) .ToList(); var dependencyCount = Model.DependenciesThisSceneNeeds.Count + Model.DependenciesNeedingThisScene.Count; + var totalWarningCount = Model.Warnings.Count + Model.AgeContinuityWarnings.Count; var exactDateTimeModeId = Model.TimeModes.FirstOrDefault(x => string.Equals(x.Text, "Exact DateTime", StringComparison.OrdinalIgnoreCase))?.Value ?? string.Empty; var exactDateModeId = Model.TimeModes.FirstOrDefault(x => string.Equals(x.Text, "Exact Date", StringComparison.OrdinalIgnoreCase))?.Value ?? string.Empty; } @@ -37,7 +38,7 @@
@revisionStatus @timeLabel - @Model.Warnings.Count warning@(Model.Warnings.Count == 1 ? "" : "s") + @totalWarningCount warning@(totalWarningCount == 1 ? "" : "s") @Model.SceneCharacters.Count character@(Model.SceneCharacters.Count == 1 ? "" : "s") @Model.AssetEvents.Count asset event@(Model.AssetEvents.Count == 1 ? "" : "s") @Model.ThreadEvents.Count thread event@(Model.ThreadEvents.Count == 1 ? "" : "s") @@ -1437,7 +1438,7 @@ }
- @if (!Model.Warnings.Any()) + @if (!Model.Warnings.Any() && !Model.AgeContinuityWarnings.Any()) {

No active warnings for this scene. Run validation again after major story changes.

} @@ -1470,6 +1471,18 @@ } + @foreach (var warning in Model.AgeContinuityWarnings) + { +
+ @warning.SeverityName + @warning.WarningTypeName +

@warning.Message

+ @if (!string.IsNullOrWhiteSpace(warning.Details)) + { +

@warning.Details

+ } +
+ } }