Phase 12G: Knowledge Continuity Warnings.

This commit is contained in:
Nick Beckley 2026-06-14 20:45:36 +01:00
parent 738b9f496d
commit e008da0cf4
3 changed files with 160 additions and 3 deletions

View File

@ -2546,6 +2546,7 @@ public sealed class StoryStateService(
warnings.AddRange(BuildMultipleCharacterLocationWarnings(characterTimeline.Appearances, enabledCharacterIds, context));
warnings.AddRange(BuildImpossibleMovementWarnings(characterTimeline.Appearances, enabledCharacterIds, context));
warnings.AddRange(BuildUnknownCharacterLocationWarnings(characterTimeline.Characters, characterTimeline.Appearances, enabledCharacterIds, context));
warnings.AddRange(await BuildKnowledgeChronologyWarningsAsync(characterTimeline.Characters, enabledCharacterIds, context));
}
if (filter.IncludeAssets)
@ -2565,7 +2566,7 @@ public sealed class StoryStateService(
.ThenBy(x => x.BookDisplayName)
.ThenBy(x => x.ChapterDisplayName)
.ThenBy(x => x.SceneID ?? int.MaxValue)
.ThenBy(x => x.WarningType)
.ThenBy(x => x.WarningTypeDisplayName)
.ThenBy(x => x.Message)
.ToList();
}
@ -2976,6 +2977,79 @@ public sealed class StoryStateService(
.ToList();
}
private async Task<List<ContinuityWarningViewModel>> BuildKnowledgeChronologyWarningsAsync(
IReadOnlyList<Character> projectCharacters,
IReadOnlySet<int> enabledCharacterIds,
RangeContext context)
{
var warnings = new List<ContinuityWarningViewModel>();
var charactersToCheck = projectCharacters
.Where(character => !enabledCharacterIds.Any() || enabledCharacterIds.Contains(character.CharacterID))
.ToList();
foreach (var character in charactersToCheck)
{
var knowledgeEvents = (await characters.ListKnowledgeByCharacterAsync(character.CharacterID))
.Where(x => context.SceneIndexes.ContainsKey(x.SceneID))
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, int.MaxValue))
.ThenBy(x => x.CreatedDate)
.ThenBy(x => x.CharacterKnowledgeID)
.ToList();
foreach (var group in knowledgeEvents.GroupBy(KnowledgeIdentity))
{
var established = group
.Where(x => string.Equals(x.KnowledgeStateName, "Knows", StringComparison.OrdinalIgnoreCase))
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, int.MaxValue))
.ThenBy(x => x.CreatedDate)
.ThenBy(x => x.CharacterKnowledgeID)
.FirstOrDefault();
if (established is null)
{
continue;
}
var establishedSceneIndex = context.SceneIndexes.GetValueOrDefault(established.SceneID, int.MaxValue);
var earlierReferences = group
.Where(x => x.CharacterKnowledgeID != established.CharacterKnowledgeID
&& context.AllowedSceneIds.Contains(x.SceneID)
&& context.SceneIndexes.GetValueOrDefault(x.SceneID, int.MaxValue) < establishedSceneIndex)
.GroupBy(x => x.SceneID)
.Select(x => x.OrderBy(row => row.CreatedDate).ThenBy(row => row.CharacterKnowledgeID).First())
.ToList();
foreach (var reference in earlierReferences)
{
var warning = BuildWarning(
$"Knowledge Chronology Violation|K:{established.CharacterKnowledgeID}",
"Warning",
$"{character.CharacterName} appears to know \"{KnowledgeTitle(established)}\" before this knowledge is established.",
reference.SceneID,
context,
characterId: character.CharacterID);
warning.WarningTypeDisplayName = "Knowledge Chronology Violation";
warning.CharacterName = character.CharacterName;
warning.KnowledgeId = established.CharacterKnowledgeID;
warning.KnowledgeTitle = KnowledgeTitle(established);
warning.EstablishedSceneId = established.SceneID;
warning.EstablishedSceneDisplayName = context.SceneContexts.GetValueOrDefault(established.SceneID)?.SceneDisplayName
?? context.SceneLabels.GetValueOrDefault(established.SceneID, SceneLabel(established.SceneNumber, established.SceneTitle));
warning.ReferencedSceneId = reference.SceneID;
warning.ReferencedSceneDisplayName = context.SceneContexts.GetValueOrDefault(reference.SceneID)?.SceneDisplayName
?? context.SceneLabels.GetValueOrDefault(reference.SceneID, SceneLabel(reference.SceneNumber, reference.SceneTitle));
warnings.Add(warning);
}
}
}
return warnings
.GroupBy(x => new { x.CharacterID, x.KnowledgeId, x.ReferencedSceneId, x.EstablishedSceneId })
.Select(x => x.First())
.ToList();
}
private static LocationActivityEntityViewModel? BuildCurrentCharacterPresence(
Character character,
IReadOnlyList<SceneCharacter> assignments,
@ -3143,6 +3217,7 @@ public sealed class StoryStateService(
{
WarningKey = BuildWarningKey(warningType, sceneId, characterId, assetId),
WarningType = warningType,
WarningTypeDisplayName = warningType,
Severity = severity,
Message = message,
CharacterID = characterId,
@ -3154,6 +3229,36 @@ public sealed class StoryStateService(
};
}
private static string KnowledgeIdentity(CharacterKnowledgeItem item)
{
if (item.StoryAssetID.HasValue)
{
return $"Asset:{item.StoryAssetID.Value}";
}
if (item.PlotThreadID.HasValue)
{
return $"Thread:{item.PlotThreadID.Value}";
}
return $"Description:{(item.Description ?? string.Empty).Trim().ToUpperInvariant()}";
}
private static string KnowledgeTitle(CharacterKnowledgeItem item)
{
if (!string.IsNullOrWhiteSpace(item.AssetName))
{
return item.AssetName;
}
if (!string.IsNullOrWhiteSpace(item.ThreadTitle))
{
return item.ThreadTitle;
}
return string.IsNullOrWhiteSpace(item.Description) ? "Knowledge" : item.Description.Trim();
}
private static void ApplyAcknowledgements(
IReadOnlyList<ContinuityWarningViewModel> warnings,
IReadOnlyList<ContinuityWarningAcknowledgement> acknowledgements)

View File

@ -319,9 +319,11 @@ public sealed class ContinuityWarningViewModel
{
public string WarningKey { get; set; } = string.Empty;
public string WarningType { get; set; } = string.Empty;
public string WarningTypeDisplayName { get; set; } = string.Empty;
public string Severity { get; set; } = "Information";
public string Message { get; set; } = string.Empty;
public int? CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public int? AssetID { get; set; }
public int? SceneID { get; set; }
public string SceneDisplayName { get; set; } = string.Empty;
@ -329,6 +331,12 @@ public sealed class ContinuityWarningViewModel
public string ChapterDisplayName { get; set; } = string.Empty;
public bool IsAcknowledged { get; set; }
public string? AcknowledgementNotes { get; set; }
public int? KnowledgeId { get; set; }
public string KnowledgeTitle { get; set; } = string.Empty;
public int? EstablishedSceneId { get; set; }
public string EstablishedSceneDisplayName { get; set; } = string.Empty;
public int? ReferencedSceneId { get; set; }
public string ReferencedSceneDisplayName { get; set; } = string.Empty;
}
public sealed class CharacterJourneyViewModel

View File

@ -382,7 +382,11 @@ else if (Model.Filter.DisplayMode == "ContinuityWarnings")
<tr>
<th>Severity</th>
<th>Status</th>
<th>Warning Type</th>
<th>Type</th>
<th>Character</th>
<th>Knowledge</th>
<th>Established</th>
<th>Earlier Reference</th>
<th>Message</th>
<th>Scene</th>
<th>Actions</th>
@ -403,7 +407,47 @@ else if (Model.Filter.DisplayMode == "ContinuityWarnings")
<span class="badge bg-warning text-dark">Active</span>
}
</td>
<td>@warning.WarningType</td>
<td>@warning.WarningTypeDisplayName</td>
<td>
@if (!string.IsNullOrWhiteSpace(warning.CharacterName))
{
@warning.CharacterName
}
else
{
<span class="muted">-</span>
}
</td>
<td>
@if (!string.IsNullOrWhiteSpace(warning.KnowledgeTitle))
{
@warning.KnowledgeTitle
}
else
{
<span class="muted">-</span>
}
</td>
<td>
@if (warning.EstablishedSceneId.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.EstablishedSceneId">@warning.EstablishedSceneDisplayName</a>
}
else
{
<span class="muted">-</span>
}
</td>
<td>
@if (warning.ReferencedSceneId.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.ReferencedSceneId">@warning.ReferencedSceneDisplayName</a>
}
else
{
<span class="muted">-</span>
}
</td>
<td>
<div>@warning.Message</div>
@if (warning.IsAcknowledged && !string.IsNullOrWhiteSpace(warning.AcknowledgementNotes))