Phase 12E: Continuity Warnings Engine (Informational).

This commit is contained in:
Nick Beckley 2026-06-14 20:04:48 +01:00
parent 9cc20ea266
commit 7c7815642a
3 changed files with 343 additions and 0 deletions

View File

@ -118,6 +118,7 @@ public interface IStoryStateService
Task<List<CharacterJourneyViewModel>> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter);
Task<List<AssetJourneyViewModel>> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter);
Task<List<LocationActivityViewModel>> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter);
Task<List<ContinuityWarningViewModel>> GetContinuityWarningsAsync(int projectId, ContinuityFilter filter);
}
public interface IPlotService
@ -2336,6 +2337,10 @@ public sealed class StoryStateService(
.ThenBy(x => x.LocationName)
.ToList();
}
else if (string.Equals(filter.DisplayMode, "ContinuityWarnings", StringComparison.OrdinalIgnoreCase))
{
model.ContinuityWarnings = await GetContinuityWarningsAsync(filter.ProjectID, filter);
}
return model;
}
@ -2519,6 +2524,42 @@ public sealed class StoryStateService(
return result;
}
public async Task<List<ContinuityWarningViewModel>> GetContinuityWarningsAsync(int projectId, ContinuityFilter filter)
{
var context = await GetRangeContextAsync(projectId, filter);
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
var assetTimeline = await assets.GetTimelineAsync(projectId, null);
var allAssetLocations = await GetAssetLocationsThroughCurrentAsync(context.OrderedScenes);
var custodyCharacters = await assets.ListCustodyCharactersByProjectAsync(projectId);
var enabledCharacterIds = filter.CharacterIDs.ToHashSet();
var enabledAssetIds = filter.AssetIDs.ToHashSet();
var warnings = new List<ContinuityWarningViewModel>();
if (filter.IncludeCharacters)
{
warnings.AddRange(BuildMultipleCharacterLocationWarnings(characterTimeline.Appearances, enabledCharacterIds, context));
warnings.AddRange(BuildImpossibleMovementWarnings(characterTimeline.Appearances, enabledCharacterIds, context));
warnings.AddRange(BuildUnknownCharacterLocationWarnings(characterTimeline.Characters, characterTimeline.Appearances, enabledCharacterIds, context));
}
if (filter.IncludeAssets)
{
var scopedAssetLocations = allAssetLocations.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).ToList();
var scopedCustody = custodyCharacters.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).ToList();
warnings.AddRange(BuildAssetLocationConflictWarnings(assetTimeline.Assets, scopedAssetLocations, scopedCustody, enabledAssetIds, context));
warnings.AddRange(BuildUnknownAssetLocationWarnings(assetTimeline.Assets, assetTimeline.Events, allAssetLocations, custodyCharacters, enabledAssetIds, context));
}
return warnings
.OrderBy(x => WarningSeveritySort(x.Severity))
.ThenBy(x => x.BookDisplayName)
.ThenBy(x => x.ChapterDisplayName)
.ThenBy(x => x.SceneID ?? int.MaxValue)
.ThenBy(x => x.WarningType)
.ThenBy(x => x.Message)
.ToList();
}
private async Task<RangeContext> GetRangeContextAsync(int projectId, ContinuityFilter filter)
{
NormaliseFilter(filter);
@ -2737,6 +2778,203 @@ public sealed class StoryStateService(
.ToList();
}
private static List<ContinuityWarningViewModel> BuildMultipleCharacterLocationWarnings(
IReadOnlyList<SceneCharacter> appearances,
IReadOnlySet<int> enabledCharacterIds,
RangeContext context)
{
return appearances
.Where(x => context.AllowedSceneIds.Contains(x.SceneID)
&& x.LocationID.HasValue
&& (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(x.CharacterID)))
.GroupBy(x => new { x.SceneID, x.CharacterID, x.CharacterName })
.Where(group => group.Select(x => x.LocationID).Distinct().Count() > 1)
.Select(group => BuildWarning(
"Multiple Character Locations",
"Critical",
$"{group.Key.CharacterName} appears in multiple locations during {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.",
group.Key.SceneID,
context,
characterId: group.Key.CharacterID))
.ToList();
}
private static List<ContinuityWarningViewModel> BuildImpossibleMovementWarnings(
IReadOnlyList<SceneCharacter> appearances,
IReadOnlySet<int> enabledCharacterIds,
RangeContext context)
{
var warnings = new List<ContinuityWarningViewModel>();
var rowsByCharacter = appearances
.Where(x => x.LocationID.HasValue && (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(x.CharacterID)))
.GroupBy(x => x.CharacterID);
foreach (var group in rowsByCharacter)
{
var explicitLocations = group
.GroupBy(x => x.SceneID)
.Select(sceneGroup => sceneGroup.OrderByDescending(x => x.UpdatedDate).First())
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ToList();
for (var index = 1; index < explicitLocations.Count; index++)
{
var previous = explicitLocations[index - 1];
var current = explicitLocations[index];
if (previous.LocationID == current.LocationID || !context.AllowedSceneIds.Contains(current.SceneID))
{
continue;
}
var previousIndex = context.SceneIndexes.GetValueOrDefault(previous.SceneID, -1);
var currentIndex = context.SceneIndexes.GetValueOrDefault(current.SceneID, -1);
if (currentIndex - previousIndex != 1)
{
continue;
}
warnings.Add(BuildWarning(
"Impossible Character Movement",
"Warning",
$"{current.CharacterName} moved from {previous.LocationPath ?? previous.LocationName ?? "an unknown location"} to {current.LocationPath ?? current.LocationName ?? "an unknown location"} between consecutive scenes.",
current.SceneID,
context,
characterId: current.CharacterID));
}
}
return warnings;
}
private static List<ContinuityWarningViewModel> BuildUnknownCharacterLocationWarnings(
IReadOnlyList<Character> projectCharacters,
IReadOnlyList<SceneCharacter> appearances,
IReadOnlySet<int> enabledCharacterIds,
RangeContext context)
{
var appearancesByCharacter = appearances
.Where(x => context.AllowedSceneIds.Contains(x.SceneID))
.GroupBy(x => x.CharacterID)
.ToDictionary(x => x.Key, x => x.ToList());
var locationCharacterIds = appearances
.Where(x => x.LocationID.HasValue)
.Select(x => x.CharacterID)
.ToHashSet();
return projectCharacters
.Where(character => (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(character.CharacterID))
&& appearancesByCharacter.ContainsKey(character.CharacterID)
&& !locationCharacterIds.Contains(character.CharacterID))
.Select(character =>
{
var firstSceneId = appearancesByCharacter[character.CharacterID]
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.First()
.SceneID;
return BuildWarning(
"Characters With Unknown Locations",
"Information",
$"{character.CharacterName} has no known location.",
firstSceneId,
context,
characterId: character.CharacterID);
})
.ToList();
}
private static List<ContinuityWarningViewModel> BuildAssetLocationConflictWarnings(
IReadOnlyList<StoryAsset> projectAssets,
IReadOnlyList<SceneAssetLocation> sceneAssetLocations,
IReadOnlyList<AssetCustodyCharacter> custodyCharacters,
IReadOnlySet<int> enabledAssetIds,
RangeContext context)
{
var assetNames = projectAssets.ToDictionary(x => x.StoryAssetID, x => x.AssetName);
var directLocationGroups = sceneAssetLocations
.Where(x => !enabledAssetIds.Any() || enabledAssetIds.Contains(x.StoryAssetID))
.GroupBy(x => new { x.SceneID, x.StoryAssetID });
var custodyGroups = custodyCharacters
.Where(x => !enabledAssetIds.Any() || enabledAssetIds.Contains(x.StoryAssetID))
.GroupBy(x => new { x.SceneID, x.StoryAssetID })
.ToDictionary(x => x.Key, x => x.ToList());
var warnings = new List<ContinuityWarningViewModel>();
foreach (var group in directLocationGroups)
{
var directLocationCount = group.Select(x => x.LocationID).Distinct().Count();
custodyGroups.TryGetValue(group.Key, out var custodyRows);
var hasCustody = custodyRows?.Any() == true;
if (directLocationCount <= 1 && !hasCustody)
{
continue;
}
warnings.Add(BuildWarning(
"Asset Location Conflict",
"Critical",
$"{assetNames.GetValueOrDefault(group.Key.StoryAssetID, "An asset")} has multiple explicit locations in {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.",
group.Key.SceneID,
context,
assetId: group.Key.StoryAssetID));
}
foreach (var group in custodyGroups.Where(x => !directLocationGroups.Any(locationGroup => locationGroup.Key.SceneID == x.Key.SceneID && locationGroup.Key.StoryAssetID == x.Key.StoryAssetID)))
{
var holderCount = group.Value.Select(x => x.CharacterID?.ToString() ?? x.CharacterNameText ?? string.Empty).Where(x => x.Length > 0).Distinct().Count();
if (holderCount <= 1)
{
continue;
}
warnings.Add(BuildWarning(
"Asset Location Conflict",
"Critical",
$"{assetNames.GetValueOrDefault(group.Key.StoryAssetID, "An asset")} has multiple explicit holders in {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.",
group.Key.SceneID,
context,
assetId: group.Key.StoryAssetID));
}
return warnings;
}
private static List<ContinuityWarningViewModel> BuildUnknownAssetLocationWarnings(
IReadOnlyList<StoryAsset> projectAssets,
IReadOnlyList<AssetEvent> assetEvents,
IReadOnlyList<SceneAssetLocation> allAssetLocations,
IReadOnlyList<AssetCustodyCharacter> custodyCharacters,
IReadOnlySet<int> enabledAssetIds,
RangeContext context)
{
var appearedAssetSceneIds = assetEvents
.Where(x => context.AllowedSceneIds.Contains(x.SceneID))
.Select(x => new { x.StoryAssetID, x.SceneID })
.Concat(allAssetLocations.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).Select(x => new { x.StoryAssetID, x.SceneID }))
.Concat(custodyCharacters.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).Select(x => new { x.StoryAssetID, x.SceneID }))
.GroupBy(x => x.StoryAssetID)
.ToDictionary(x => x.Key, x => x.Min(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, int.MaxValue)));
var locatedAssetIds = allAssetLocations.Select(x => x.StoryAssetID)
.Concat(custodyCharacters.Select(x => x.StoryAssetID))
.ToHashSet();
return projectAssets
.Where(asset => (!enabledAssetIds.Any() || enabledAssetIds.Contains(asset.StoryAssetID))
&& appearedAssetSceneIds.ContainsKey(asset.StoryAssetID)
&& !locatedAssetIds.Contains(asset.StoryAssetID))
.Select(asset =>
{
var firstSceneId = context.OrderedScenes.FirstOrDefault(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, int.MaxValue) == appearedAssetSceneIds[asset.StoryAssetID])?.SceneID;
return BuildWarning(
"Assets With Unknown Locations",
"Information",
$"{asset.AssetName} has no known location.",
firstSceneId,
context,
assetId: asset.StoryAssetID);
})
.ToList();
}
private static LocationActivityEntityViewModel? BuildCurrentCharacterPresence(
Character character,
IReadOnlyList<SceneCharacter> assignments,
@ -2890,6 +3128,40 @@ public sealed class StoryStateService(
: [];
}
private static ContinuityWarningViewModel BuildWarning(
string warningType,
string severity,
string message,
int? sceneId,
RangeContext context,
int? characterId = null,
int? assetId = null)
{
var sceneContext = sceneId.HasValue ? context.SceneContexts.GetValueOrDefault(sceneId.Value) : null;
return new ContinuityWarningViewModel
{
WarningType = warningType,
Severity = severity,
Message = message,
CharacterID = characterId,
AssetID = assetId,
SceneID = sceneId,
SceneDisplayName = sceneContext?.SceneDisplayName ?? (sceneId.HasValue ? context.SceneLabels.GetValueOrDefault(sceneId.Value, string.Empty) : string.Empty),
BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty,
ChapterDisplayName = sceneContext?.ChapterDisplayName ?? string.Empty
};
}
private static int WarningSeveritySort(string severity)
{
return severity switch
{
"Critical" => 0,
"Warning" => 1,
_ => 2
};
}
private static Dictionary<int, int> BuildSceneIndexes(IReadOnlyList<Scene> orderedScenes)
{
return orderedScenes

View File

@ -303,6 +303,7 @@ public sealed class ContinuityExplorerViewModel
public IReadOnlyList<CharacterJourneyViewModel> CharacterJourney { get; set; } = [];
public IReadOnlyList<AssetJourneyViewModel> AssetJourney { get; set; } = [];
public IReadOnlyList<LocationActivityViewModel> LocationActivity { get; set; } = [];
public IReadOnlyList<ContinuityWarningViewModel> ContinuityWarnings { get; set; } = [];
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> ChapterOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SceneOptions { get; set; } = [];
@ -313,6 +314,19 @@ public sealed class ContinuityExplorerViewModel
public bool ShowChapterContext { get; set; }
}
public sealed class ContinuityWarningViewModel
{
public string WarningType { get; set; } = string.Empty;
public string Severity { get; set; } = "Information";
public string Message { get; set; } = string.Empty;
public int? CharacterID { get; set; }
public int? AssetID { get; set; }
public int? SceneID { get; set; }
public string SceneDisplayName { get; set; } = string.Empty;
public string BookDisplayName { get; set; } = string.Empty;
public string ChapterDisplayName { get; set; } = string.Empty;
}
public sealed class CharacterJourneyViewModel
{
public int CharacterID { get; set; }

View File

@ -12,6 +12,13 @@
_ => "bg-warning text-dark"
};
private static string WarningBadgeClass(string severity) => severity switch
{
"Critical" => "bg-danger",
"Warning" => "bg-warning text-dark",
_ => "bg-info"
};
private bool IsEntitySelected(string entityType) =>
Model.Filter.EntityTypes.Contains(entityType, StringComparer.OrdinalIgnoreCase);
}
@ -144,6 +151,10 @@
<input class="form-check-input" type="radio" name="DisplayMode" value="LocationActivity" checked="@(Model.Filter.DisplayMode == "LocationActivity")" />
<span class="form-check-label">Location Activity</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="DisplayMode" value="ContinuityWarnings" checked="@(Model.Filter.DisplayMode == "ContinuityWarnings")" />
<span class="form-check-label">Continuity Warnings</span>
</label>
</div>
</div>
@ -350,6 +361,52 @@ else if (Model.Filter.DisplayMode == "Journey")
}
</section>
}
else if (Model.Filter.DisplayMode == "ContinuityWarnings")
{
<section class="story-bible-section">
<h2>Continuity Warnings</h2>
@if (!Model.ContinuityWarnings.Any())
{
<p class="muted">No continuity concerns detected.</p>
<p class="muted">Continuity warnings highlight potential issues but cannot replace author judgement.</p>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Severity</th>
<th>Warning Type</th>
<th>Message</th>
<th>Scene</th>
</tr>
</thead>
<tbody>
@foreach (var warning in Model.ContinuityWarnings)
{
<tr>
<td><span class="badge @WarningBadgeClass(warning.Severity)">@warning.Severity</span></td>
<td>@warning.WarningType</td>
<td>@warning.Message</td>
<td>
@if (warning.SceneID.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.SceneID">@warning.SceneDisplayName</a>
}
else
{
<span class="muted">Project-wide</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
else
{
<section class="story-bible-section">