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)
|
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;
|
var years = sceneDate.Year - birthDate.Year;
|
||||||
if (sceneDate < birthDate.AddYears(years))
|
if (sceneDate < birthDate.AddYears(years))
|
||||||
@ -100,9 +109,7 @@ public static class CharacterAgeDisplayService
|
|||||||
months = 0;
|
months = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return months == 0
|
return (years, months);
|
||||||
? $"{years} year{Pluralise(years)}"
|
|
||||||
: $"{years} year{Pluralise(years)}, {months} month{Pluralise(months)}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Pluralise(int value) => value == 1 ? string.Empty : "s";
|
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.SceneAssetLocations = model.SceneID == 0 ? [] : await locations.ListSceneAssetLocationsAsync(model.SceneID);
|
||||||
model.SceneCharacters = model.SceneID == 0 ? [] : await characters.ListSceneCharactersAsync(model.SceneID);
|
model.SceneCharacters = model.SceneID == 0 ? [] : await characters.ListSceneCharactersAsync(model.SceneID);
|
||||||
model.Warnings = model.SceneID == 0 ? [] : await warnings.ListBySceneAsync(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);
|
var sceneDependencyRows = model.SceneID == 0 ? [] : await sceneDependencies.ListBySceneAsync(model.SceneID);
|
||||||
model.DependenciesThisSceneNeeds = sceneDependencyRows.Where(x => x.TargetSceneID == model.SceneID).ToList();
|
model.DependenciesThisSceneNeeds = sceneDependencyRows.Where(x => x.TargetSceneID == model.SceneID).ToList();
|
||||||
model.DependenciesNeedingThisScene = sceneDependencyRows.Where(x => x.SourceSceneID == model.SceneID).ToList();
|
model.DependenciesNeedingThisScene = sceneDependencyRows.Where(x => x.SourceSceneID == model.SceneID).ToList();
|
||||||
@ -1341,6 +1342,60 @@ public sealed class SceneService(
|
|||||||
return model;
|
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)
|
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();
|
=> 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> AssetDependencyMessages { get; set; } = [];
|
||||||
public IReadOnlyList<string> LocationConsistencyMessages { get; set; } = [];
|
public IReadOnlyList<string> LocationConsistencyMessages { get; set; } = [];
|
||||||
public IReadOnlyList<ContinuityWarning> Warnings { get; set; } = [];
|
public IReadOnlyList<ContinuityWarning> Warnings { get; set; } = [];
|
||||||
|
public IReadOnlyList<ContinuityWarning> AgeContinuityWarnings { get; set; } = [];
|
||||||
public IReadOnlyList<SceneDependency> DependenciesThisSceneNeeds { get; set; } = [];
|
public IReadOnlyList<SceneDependency> DependenciesThisSceneNeeds { get; set; } = [];
|
||||||
public IReadOnlyList<SceneDependency> DependenciesNeedingThisScene { get; set; } = [];
|
public IReadOnlyList<SceneDependency> DependenciesNeedingThisScene { get; set; } = [];
|
||||||
public SceneDependencyCreateViewModel NewDependency { get; set; } = new();
|
public SceneDependencyCreateViewModel NewDependency { get; set; } = new();
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
.Select(x => x.PurposeName)
|
.Select(x => x.PurposeName)
|
||||||
.ToList();
|
.ToList();
|
||||||
var dependencyCount = Model.DependenciesThisSceneNeeds.Count + Model.DependenciesNeedingThisScene.Count;
|
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 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;
|
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">
|
<div class="overview-chip-grid">
|
||||||
<span class="overview-chip">@revisionStatus</span>
|
<span class="overview-chip">@revisionStatus</span>
|
||||||
<span class="overview-chip">@timeLabel</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.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.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>
|
<span class="overview-chip">@Model.ThreadEvents.Count thread event@(Model.ThreadEvents.Count == 1 ? "" : "s")</span>
|
||||||
@ -1437,7 +1438,7 @@
|
|||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
</div>
|
</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>
|
<p class="muted">No active warnings for this scene. Run validation again after major story changes.</p>
|
||||||
}
|
}
|
||||||
@ -1470,6 +1471,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user