Phase 12I: Knowledge Regression Warnings.

This commit is contained in:
Nick Beckley 2026-06-15 09:27:31 +01:00
parent f401416fbd
commit 6a2aeeea90
3 changed files with 130 additions and 36 deletions

View File

@ -2544,7 +2544,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));
warnings.AddRange(await BuildKnowledgeRegressionWarningsAsync(characterTimeline.Characters, enabledCharacterIds, context));
}
if (filter.IncludeAssets)
@ -2975,7 +2975,7 @@ public sealed class StoryStateService(
.ToList();
}
private async Task<List<ContinuityWarningViewModel>> BuildKnowledgeChronologyWarningsAsync(
private async Task<List<ContinuityWarningViewModel>> BuildKnowledgeRegressionWarningsAsync(
IReadOnlyList<Character> projectCharacters,
IReadOnlySet<int> enabledCharacterIds,
RangeContext context)
@ -2996,54 +2996,52 @@ public sealed class StoryStateService(
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())
var progressionEvents = group
.Select(x => new { Item = x, Rank = KnowledgeStateRank(x.KnowledgeStateName) })
.Where(x => x.Rank.HasValue)
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.Item.SceneID, int.MaxValue))
.ThenBy(x => x.Item.CreatedDate)
.ThenBy(x => x.Item.CharacterKnowledgeID)
.ToList();
foreach (var reference in earlierReferences)
for (var i = 1; i < progressionEvents.Count; i++)
{
var previous = progressionEvents[i - 1];
var current = progressionEvents[i];
if (current.Rank!.Value >= previous.Rank!.Value || !context.AllowedSceneIds.Contains(current.Item.SceneID))
{
continue;
}
var knowledgeDescription = KnowledgeTitle(current.Item);
var warning = BuildWarning(
$"Knowledge Chronology Violation|K:{established.CharacterKnowledgeID}",
$"KnowledgeRegression|Knowledge:{NormaliseWarningKeyPart(knowledgeDescription)}",
"Warning",
$"{character.CharacterName} appears to know \"{KnowledgeTitle(established)}\" before this knowledge is established.",
reference.SceneID,
KnowledgeRegressionMessage(character.CharacterName, knowledgeDescription, previous.Item.KnowledgeStateName, current.Item.KnowledgeStateName),
current.Item.SceneID,
context,
characterId: character.CharacterID);
warning.WarningTypeDisplayName = "Knowledge Chronology Violation";
warning.WarningTypeDisplayName = "Knowledge Regression";
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));
warning.KnowledgeId = current.Item.CharacterKnowledgeID;
warning.KnowledgeTitle = knowledgeDescription;
warning.KnowledgeDescription = knowledgeDescription;
warning.PreviousKnowledgeState = previous.Item.KnowledgeStateName;
warning.CurrentKnowledgeState = current.Item.KnowledgeStateName;
warning.PreviousSceneId = previous.Item.SceneID;
warning.PreviousSceneDisplayName = context.SceneContexts.GetValueOrDefault(previous.Item.SceneID)?.SceneDisplayName
?? context.SceneLabels.GetValueOrDefault(previous.Item.SceneID, SceneLabel(previous.Item.SceneNumber, previous.Item.SceneTitle));
warning.CurrentSceneId = current.Item.SceneID;
warning.CurrentSceneDisplayName = context.SceneContexts.GetValueOrDefault(current.Item.SceneID)?.SceneDisplayName
?? context.SceneLabels.GetValueOrDefault(current.Item.SceneID, SceneLabel(current.Item.SceneNumber, current.Item.SceneTitle));
warnings.Add(warning);
}
}
}
return warnings
.GroupBy(x => new { x.CharacterID, x.KnowledgeId, x.ReferencedSceneId, x.EstablishedSceneId })
.GroupBy(x => new { x.CharacterID, x.KnowledgeDescription, x.CurrentSceneId })
.Select(x => x.First())
.ToList();
}
@ -3257,6 +3255,63 @@ public sealed class StoryStateService(
return string.IsNullOrWhiteSpace(item.Description) ? "Knowledge" : item.Description.Trim();
}
private static int? KnowledgeStateRank(string stateName)
{
return stateName switch
{
"Unaware" => 0,
"Suspects" => 1,
"Partially Knows" => 2,
"Knows" => 3,
_ => null
};
}
private static string KnowledgeRegressionMessage(string characterName, string knowledgeDescription, string previousState, string currentState)
{
if (string.Equals(previousState, "Knows", StringComparison.OrdinalIgnoreCase)
&& string.Equals(currentState, "Unaware", StringComparison.OrdinalIgnoreCase))
{
return $"{characterName} appears unaware of \"{knowledgeDescription}\" after previously knowing it.";
}
if (string.Equals(previousState, "Knows", StringComparison.OrdinalIgnoreCase)
&& string.Equals(currentState, "Suspects", StringComparison.OrdinalIgnoreCase))
{
return $"{characterName} appears to regress from knowing to suspecting \"{knowledgeDescription}\".";
}
if (string.Equals(previousState, "Knows", StringComparison.OrdinalIgnoreCase)
&& string.Equals(currentState, "Partially Knows", StringComparison.OrdinalIgnoreCase))
{
return $"{characterName} appears to lose understanding of \"{knowledgeDescription}\".";
}
if (string.Equals(currentState, "Unaware", StringComparison.OrdinalIgnoreCase))
{
return $"{characterName} appears to lose awareness of \"{knowledgeDescription}\".";
}
return $"{characterName} appears to regress from {KnowledgeStatePhrase(previousState)} to {KnowledgeStatePhrase(currentState)} \"{knowledgeDescription}\".";
}
private static string KnowledgeStatePhrase(string stateName)
{
return stateName switch
{
"Unaware" => "being unaware of",
"Suspects" => "suspecting",
"Partially Knows" => "partially knowing",
"Knows" => "knowing",
_ => stateName.ToLowerInvariant()
};
}
private static string NormaliseWarningKeyPart(string value)
{
return string.Join(" ", value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
private static void ApplyAcknowledgements(
IReadOnlyList<ContinuityWarningViewModel> warnings,
IReadOnlyList<ContinuityWarningAcknowledgement> acknowledgements)
@ -4080,6 +4135,13 @@ public sealed class ContinuityValidationService(
WarningKey = warning.WarningKey,
KnowledgeId = warning.KnowledgeId,
KnowledgeTitle = warning.KnowledgeTitle,
KnowledgeDescription = warning.KnowledgeDescription,
PreviousKnowledgeState = warning.PreviousKnowledgeState,
CurrentKnowledgeState = warning.CurrentKnowledgeState,
PreviousSceneId = warning.PreviousSceneId,
PreviousSceneDisplayName = warning.PreviousSceneDisplayName,
CurrentSceneId = warning.CurrentSceneId,
CurrentSceneDisplayName = warning.CurrentSceneDisplayName,
EstablishedSceneId = warning.EstablishedSceneId,
EstablishedSceneDisplayName = warning.EstablishedSceneDisplayName,
ReferencedSceneId = warning.ReferencedSceneId,
@ -4103,7 +4165,7 @@ public sealed class ContinuityValidationService(
private static string WarningCategoryForGenerated(ContinuityWarningViewModel warning)
{
if (string.Equals(warning.WarningTypeDisplayName, "Knowledge Chronology Violation", StringComparison.OrdinalIgnoreCase))
if (string.Equals(warning.WarningTypeDisplayName, "Knowledge Regression", StringComparison.OrdinalIgnoreCase))
{
return "Knowledge";
}

View File

@ -333,6 +333,13 @@ public sealed class ContinuityWarningViewModel
public string? AcknowledgementNotes { get; set; }
public int? KnowledgeId { get; set; }
public string KnowledgeTitle { get; set; } = string.Empty;
public string KnowledgeDescription { get; set; } = string.Empty;
public string PreviousKnowledgeState { get; set; } = string.Empty;
public string CurrentKnowledgeState { get; set; } = string.Empty;
public int? PreviousSceneId { get; set; }
public string PreviousSceneDisplayName { get; set; } = string.Empty;
public int? CurrentSceneId { get; set; }
public string CurrentSceneDisplayName { get; set; } = string.Empty;
public int? EstablishedSceneId { get; set; }
public string EstablishedSceneDisplayName { get; set; } = string.Empty;
public int? ReferencedSceneId { get; set; }
@ -1689,6 +1696,13 @@ public sealed class ProjectWarningViewModel
public string WarningKey { get; set; } = string.Empty;
public int? KnowledgeId { get; set; }
public string KnowledgeTitle { get; set; } = string.Empty;
public string KnowledgeDescription { get; set; } = string.Empty;
public string PreviousKnowledgeState { get; set; } = string.Empty;
public string CurrentKnowledgeState { get; set; } = string.Empty;
public int? PreviousSceneId { get; set; }
public string PreviousSceneDisplayName { get; set; } = string.Empty;
public int? CurrentSceneId { get; set; }
public string CurrentSceneDisplayName { get; set; } = string.Empty;
public int? EstablishedSceneId { get; set; }
public string EstablishedSceneDisplayName { get; set; } = string.Empty;
public int? ReferencedSceneId { get; set; }

View File

@ -175,6 +175,24 @@
{
<small class="text-muted d-block">Knowledge: @warning.KnowledgeTitle</small>
}
@if (!string.IsNullOrWhiteSpace(warning.PreviousKnowledgeState) && !string.IsNullOrWhiteSpace(warning.CurrentKnowledgeState))
{
<small class="text-muted d-block">State: @warning.PreviousKnowledgeState &rarr; @warning.CurrentKnowledgeState</small>
}
@if (warning.PreviousSceneId.HasValue)
{
<small class="text-muted d-block">
Previous:
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.PreviousSceneId">@warning.PreviousSceneDisplayName</a>
</small>
}
@if (warning.CurrentSceneId.HasValue)
{
<small class="text-muted d-block">
Current:
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.CurrentSceneId">@warning.CurrentSceneDisplayName</a>
</small>
}
@if (warning.EstablishedSceneId.HasValue)
{
<small class="text-muted d-block">