Phase 11B PlotDirector Character Age and Story Date Management feature.
This commit is contained in:
parent
b5decb0dc6
commit
5f6ea92938
158
PlotLine/Services/CharacterAgeContinuityService.cs
Normal file
158
PlotLine/Services/CharacterAgeContinuityService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user