Phase 12C: Inferred Location Activity for Continuity Explorer.

This commit is contained in:
Nick Beckley 2026-06-14 19:47:15 +01:00
parent dc4324f970
commit c7e02870cf
5 changed files with 285 additions and 15 deletions

View File

@ -152,6 +152,7 @@ public interface IAssetRepository
Task DeleteDependencyAsync(int assetDependencyId);
Task<IReadOnlyList<AssetCustodyEvent>> ListCustodyBySceneAsync(int sceneId);
Task<IReadOnlyList<AssetCustodyEvent>> ListCustodyByAssetAsync(int storyAssetId);
Task<IReadOnlyList<AssetCustodyCharacter>> ListCustodyCharactersByProjectAsync(int projectId);
Task<int> SaveCustodyEventAsync(AssetCustodyEvent custodyEvent, string custodianNames, IEnumerable<int> custodianCharacterIds, int custodyRoleId);
Task DeleteCustodyEventAsync(int assetCustodyEventId);
Task<(IReadOnlyList<StoryAsset> Assets, IReadOnlyList<AssetEvent> Events)> GetTimelineAsync(int projectId, int? bookId);
@ -2775,6 +2776,31 @@ public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : I
return rows.ToList();
}
public async Task<IReadOnlyList<AssetCustodyCharacter>> ListCustodyCharactersByProjectAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<AssetCustodyCharacter>(
"""
SELECT acec.AssetCustodyEventID,
ace.StoryAssetID,
ace.SceneID,
acec.CharacterID,
c.CharacterName,
acec.CharacterNameText,
cr.RoleName,
ace.UpdatedDate
FROM dbo.AssetCustodyEventCharacters acec
INNER JOIN dbo.AssetCustodyEvents ace ON ace.AssetCustodyEventID = acec.AssetCustodyEventID
INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ace.StoryAssetID
LEFT JOIN dbo.Characters c ON c.CharacterID = acec.CharacterID
LEFT JOIN dbo.CustodyRoles cr ON cr.CustodyRoleID = acec.CustodyRoleID
WHERE sa.ProjectID = @ProjectID
AND sa.IsArchived = 0;
""",
new { ProjectID = projectId });
return rows.ToList();
}
public async Task<int> SaveCustodyEventAsync(AssetCustodyEvent custodyEvent, string custodianNames, IEnumerable<int> custodianCharacterIds, int custodyRoleId)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -688,6 +688,18 @@ public sealed class AssetCustodyEvent
public DateTime UpdatedDate { get; set; }
}
public sealed class AssetCustodyCharacter
{
public int AssetCustodyEventID { get; set; }
public int StoryAssetID { get; set; }
public int SceneID { get; set; }
public int? CharacterID { get; set; }
public string? CharacterName { get; set; }
public string? CharacterNameText { get; set; }
public string RoleName { get; set; } = string.Empty;
public DateTime UpdatedDate { get; set; }
}
public sealed class AssetDependencyIssue
{
public int AssetDependencyID { get; set; }

View File

@ -2448,27 +2448,46 @@ public sealed class StoryStateService(
}
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
var assetLocations = await GetAssetLocationsThroughCurrentAsync(context.ScopedScenes);
var maxScopedSceneIndex = context.ScopedScenes
.Select(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1))
.DefaultIfEmpty(-1)
.Max();
var scenesThroughRange = context.OrderedScenes
.Where(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1) <= maxScopedSceneIndex)
.ToList();
var assetTimeline = await assets.GetTimelineAsync(projectId, null);
var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughRange);
var custodyCharacters = await assets.ListCustodyCharactersByProjectAsync(projectId);
var characterIds = filter.CharacterIDs.ToHashSet();
var assetIds = filter.AssetIDs.ToHashSet();
var characterAssignments = characterTimeline.Appearances
.Where(x => x.LocationID.HasValue)
.GroupBy(x => x.CharacterID)
.ToDictionary(x => x.Key, x => x.OrderBy(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, -1)).ThenBy(row => row.UpdatedDate).ToList());
var assetAssignments = BuildAssetLocationAssignments(assetLocations, custodyCharacters)
.GroupBy(x => x.StoryAssetID)
.ToDictionary(x => x.Key, x => x.OrderBy(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, -1)).ThenBy(row => row.UpdatedDate).ToList());
var result = new List<LocationActivityViewModel>();
foreach (var scene in context.ScopedScenes)
{
var sceneIndex = context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1);
var sceneCharacters = filter.IncludeCharacters
? characterTimeline.Appearances
.Where(x => x.SceneID == scene.SceneID && x.LocationID == locationId && (!characterIds.Any() || characterIds.Contains(x.CharacterID)))
.Select(x => x.CharacterName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x)
? characterTimeline.Characters
.Where(character => !characterIds.Any() || characterIds.Contains(character.CharacterID))
.Select(character => BuildCurrentCharacterPresence(character, characterAssignments.GetValueOrDefault(character.CharacterID, []), locationId, scene.SceneID, sceneIndex, context))
.Where(x => x is not null)
.Select(x => x!)
.OrderBy(x => x.EntityName)
.ToList()
: [];
var sceneAssets = filter.IncludeAssets
? assetLocations
.Where(x => x.SceneID == scene.SceneID && x.LocationID == locationId && (!assetIds.Any() || assetIds.Contains(x.StoryAssetID)))
.Select(x => x.AssetName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x)
? assetTimeline.Assets
.Where(asset => !assetIds.Any() || assetIds.Contains(asset.StoryAssetID))
.Select(asset => BuildCurrentAssetPresence(asset, assetAssignments.GetValueOrDefault(asset.StoryAssetID, []), characterAssignments, locationId, scene.SceneID, sceneIndex, context))
.Where(x => x is not null)
.Select(x => x!)
.OrderBy(x => x.EntityName)
.ToList()
: [];
@ -2490,8 +2509,10 @@ public sealed class StoryStateService(
SceneTitle = sceneContext?.SceneTitle ?? scene.SceneTitle,
SceneLabel = SceneLabel(scene.SceneNumber, scene.SceneTitle),
SceneDisplayName = sceneContext?.SceneDisplayName ?? SceneLabel(scene.SceneNumber, scene.SceneTitle),
CharactersPresent = sceneCharacters.Any() ? string.Join(", ", sceneCharacters) : "None",
AssetsPresent = sceneAssets.Any() ? string.Join(", ", sceneAssets) : "None"
Characters = sceneCharacters,
Assets = sceneAssets,
CharactersPresent = sceneCharacters.Any() ? string.Join(", ", sceneCharacters.Select(x => x.EntityName)) : "None",
AssetsPresent = sceneAssets.Any() ? string.Join(", ", sceneAssets.Select(x => x.EntityName)) : "None"
});
}
@ -2505,6 +2526,7 @@ public sealed class StoryStateService(
var orderedScenes = OrderScenes(timeline).ToList();
var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList();
return new RangeContext(
orderedScenes,
scopedScenes,
scopedScenes.Select(x => x.SceneID).ToHashSet(),
BuildSceneIndexes(orderedScenes),
@ -2713,6 +2735,146 @@ public sealed class StoryStateService(
.ToList();
}
private static LocationActivityEntityViewModel? BuildCurrentCharacterPresence(
Character character,
IReadOnlyList<SceneCharacter> assignments,
int locationId,
int currentSceneId,
int currentSceneIndex,
RangeContext context)
{
var latest = assignments
.Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex)
.OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ThenByDescending(x => x.UpdatedDate)
.FirstOrDefault();
if (latest?.LocationID != locationId)
{
return null;
}
return new LocationActivityEntityViewModel
{
EntityID = character.CharacterID,
EntityName = character.CharacterName,
EntityType = "Character",
StateType = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmedSceneID = latest.SceneID,
LastConfirmedSceneDisplayName = context.SceneLabels.GetValueOrDefault(latest.SceneID, SceneLabel(latest.SceneNumber, latest.SceneTitle))
};
}
private static LocationActivityEntityViewModel? BuildCurrentAssetPresence(
StoryAsset asset,
IReadOnlyList<AssetLocationActivityAssignment> assignments,
IReadOnlyDictionary<int, List<SceneCharacter>> characterAssignments,
int locationId,
int currentSceneId,
int currentSceneIndex,
RangeContext context)
{
var latest = assignments
.Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex)
.OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ThenByDescending(x => x.Priority)
.ThenByDescending(x => x.UpdatedDate)
.FirstOrDefault();
if (latest is null)
{
return null;
}
var note = latest.Note;
if (latest.LocationID != locationId)
{
if (!latest.HolderCharacterIDs.Any())
{
return null;
}
var holderInLocation = latest.HolderCharacterIDs
.Select(holderId => ResolveCharacterLocationAtScene(characterAssignments.GetValueOrDefault(holderId, []), currentSceneIndex, context))
.FirstOrDefault(x => x?.LocationID == locationId);
if (holderInLocation is null)
{
return null;
}
note = string.IsNullOrWhiteSpace(holderInLocation.CharacterName)
? latest.Note
: $"Via {holderInLocation.CharacterName}";
}
return new LocationActivityEntityViewModel
{
EntityID = asset.StoryAssetID,
EntityName = asset.AssetName,
EntityType = "Asset",
StateType = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmedSceneID = latest.SceneID,
LastConfirmedSceneDisplayName = context.SceneLabels.GetValueOrDefault(latest.SceneID, "Unknown scene"),
Note = note
};
}
private static SceneCharacter? ResolveCharacterLocationAtScene(
IReadOnlyList<SceneCharacter> assignments,
int currentSceneIndex,
RangeContext context)
{
return assignments
.Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex)
.OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ThenByDescending(x => x.UpdatedDate)
.FirstOrDefault();
}
private static List<AssetLocationActivityAssignment> BuildAssetLocationAssignments(
IReadOnlyList<SceneAssetLocation> assetLocations,
IReadOnlyList<AssetCustodyCharacter> custodyCharacters)
{
var locationAssignments = assetLocations.Select(x => new AssetLocationActivityAssignment(
x.StoryAssetID,
x.SceneID,
x.LocationID,
[],
x.LocationPath,
2,
x.UpdatedDate,
null));
var custodyAssignments = custodyCharacters
.GroupBy(x => new { x.AssetCustodyEventID, x.StoryAssetID, x.SceneID })
.Select(group =>
{
var first = group.First();
var holderIds = group
.Select(x => x.CharacterID)
.Where(x => x.HasValue)
.Select(x => x!.Value)
.Distinct()
.ToList();
var holderNames = group
.Select(x => x.CharacterName ?? x.CharacterNameText)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase);
return new AssetLocationActivityAssignment(
group.Key.StoryAssetID,
group.Key.SceneID,
null,
holderIds,
string.Join(", ", holderNames),
3,
first.UpdatedDate,
holderIds.Any() ? null : "Named holder has no known character location");
});
return locationAssignments.Concat(custodyAssignments).ToList();
}
private static void FilterStoryState(StoryStateViewModel storyState, ContinuityFilter filter)
{
storyState.Characters = filter.IncludeCharacters
@ -2836,7 +2998,18 @@ public sealed class StoryStateService(
int Priority,
DateTime UpdatedDate);
private sealed record AssetLocationActivityAssignment(
int StoryAssetID,
int SceneID,
int? LocationID,
IReadOnlyList<int> HolderCharacterIDs,
string Label,
int Priority,
DateTime UpdatedDate,
string? Note);
private sealed record RangeContext(
IReadOnlyList<Scene> OrderedScenes,
IReadOnlyList<Scene> ScopedScenes,
IReadOnlySet<int> AllowedSceneIds,
IReadOnlyDictionary<int, int> SceneIndexes,

View File

@ -355,6 +355,19 @@ public sealed class LocationActivityViewModel
public string SceneDisplayName { get; set; } = string.Empty;
public string CharactersPresent { get; set; } = string.Empty;
public string AssetsPresent { get; set; } = string.Empty;
public List<LocationActivityEntityViewModel> Characters { get; set; } = [];
public List<LocationActivityEntityViewModel> Assets { get; set; } = [];
}
public sealed class LocationActivityEntityViewModel
{
public int EntityID { get; set; }
public string EntityName { get; set; } = string.Empty;
public string EntityType { get; set; } = string.Empty;
public string StateType { get; set; } = string.Empty;
public int? LastConfirmedSceneID { get; set; }
public string LastConfirmedSceneDisplayName { get; set; } = string.Empty;
public string? Note { get; set; }
}
public sealed class SceneWorkflowEditViewModel

View File

@ -364,8 +364,54 @@ else
<td>@row.ChapterDisplayName</td>
}
<td>@row.SceneDisplayName</td>
<td>@row.CharactersPresent</td>
<td>@row.AssetsPresent</td>
<td>
@if (!row.Characters.Any())
{
<span class="muted">None</span>
}
else
{
<div class="d-grid gap-1">
@foreach (var character in row.Characters)
{
<div>
<span>@character.EntityName</span>
<span class="badge @StateBadgeClass(character.StateType)">@character.StateType</span>
@if (!string.IsNullOrWhiteSpace(character.LastConfirmedSceneDisplayName))
{
<small class="text-muted d-block">Last confirmed: @character.LastConfirmedSceneDisplayName</small>
}
</div>
}
</div>
}
</td>
<td>
@if (!row.Assets.Any())
{
<span class="muted">None</span>
}
else
{
<div class="d-grid gap-1">
@foreach (var asset in row.Assets)
{
<div>
<span>@asset.EntityName</span>
<span class="badge @StateBadgeClass(asset.StateType)">@asset.StateType</span>
@if (!string.IsNullOrWhiteSpace(asset.Note))
{
<small class="text-muted"> @asset.Note</small>
}
@if (!string.IsNullOrWhiteSpace(asset.LastConfirmedSceneDisplayName))
{
<small class="text-muted d-block">Last confirmed: @asset.LastConfirmedSceneDisplayName</small>
}
</div>
}
</div>
}
</td>
</tr>
}
</tbody>