Phase 11B PlotDirector Character Age and Story Date Management feature.

This commit is contained in:
Nick Beckley 2026-06-14 18:30:50 +01:00
parent b5decb0dc6
commit 5f6ea92938
5 changed files with 239 additions and 5 deletions

View File

@ -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<ContinuityWarning> GetWarningsForScene(
CharacterAgeContinuityScene currentScene,
IReadOnlyList<CharacterAgeContinuityScene> scenes,
IReadOnlyList<SceneCharacter> appearances,
IReadOnlyList<SceneCharacter> currentSceneCharacters)
{
var warnings = new List<ContinuityWarning>();
warnings.AddRange(GetApproximateAgeMismatchWarnings(currentScene, currentSceneCharacters));
warnings.AddRange(GetBirthdayTransitionWarnings(currentScene, scenes, appearances));
return warnings;
}
public static IReadOnlyList<ContinuityWarning> GetApproximateAgeMismatchWarnings(
CharacterAgeContinuityScene scene,
IReadOnlyList<SceneCharacter> 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<ContinuityWarning> GetBirthdayTransitionWarnings(
CharacterAgeContinuityScene currentScene,
IReadOnlyList<CharacterAgeContinuityScene> scenes,
IReadOnlyList<SceneCharacter> 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
};
}

View File

@ -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";

View File

@ -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<IReadOnlyList<ContinuityWarning>> 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<CharacterAgeContinuityScene>();
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<SelectListItem> ToOptionalSelectList<T>(IEnumerable<T> rows, Func<T, int> value, Func<T, string> text)
=> rows.Select(x => new SelectListItem(text(x), value(x).ToString())).ToList();

View File

@ -206,6 +206,7 @@ public sealed class SceneEditViewModel
public IReadOnlyList<string> AssetDependencyMessages { get; set; } = [];
public IReadOnlyList<string> LocationConsistencyMessages { get; set; } = [];
public IReadOnlyList<ContinuityWarning> Warnings { get; set; } = [];
public IReadOnlyList<ContinuityWarning> AgeContinuityWarnings { get; set; } = [];
public IReadOnlyList<SceneDependency> DependenciesThisSceneNeeds { get; set; } = [];
public IReadOnlyList<SceneDependency> DependenciesNeedingThisScene { get; set; } = [];
public SceneDependencyCreateViewModel NewDependency { get; set; } = new();

View File

@ -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 @@
<div class="overview-chip-grid">
<span class="overview-chip">@revisionStatus</span>
<span class="overview-chip">@timeLabel</span>
<span class="overview-chip @(Model.Warnings.Any() ? "attention" : "")">@Model.Warnings.Count warning@(Model.Warnings.Count == 1 ? "" : "s")</span>
<span class="overview-chip @(totalWarningCount > 0 ? "attention" : "")">@totalWarningCount warning@(totalWarningCount == 1 ? "" : "s")</span>
<span class="overview-chip">@Model.SceneCharacters.Count character@(Model.SceneCharacters.Count == 1 ? "" : "s")</span>
<span class="overview-chip">@Model.AssetEvents.Count asset event@(Model.AssetEvents.Count == 1 ? "" : "s")</span>
<span class="overview-chip">@Model.ThreadEvents.Count thread event@(Model.ThreadEvents.Count == 1 ? "" : "s")</span>
@ -1437,7 +1438,7 @@
</form>
}
</div>
@if (!Model.Warnings.Any())
@if (!Model.Warnings.Any() && !Model.AgeContinuityWarnings.Any())
{
<p class="muted">No active warnings for this scene. Run validation again after major story changes.</p>
}
@ -1470,6 +1471,18 @@
</div>
</div>
}
@foreach (var warning in Model.AgeContinuityWarnings)
{
<div class="warning-mini-card">
<span class="severity-pill @warning.SeverityName.ToLowerInvariant()">@warning.SeverityName</span>
<strong>@warning.WarningTypeName</strong>
<p>@warning.Message</p>
@if (!string.IsNullOrWhiteSpace(warning.Details))
{
<p class="muted">@warning.Details</p>
}
</div>
}
</div>
}
</section>